mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-11 01:26:37 +00:00
Merge origin/main into fix/starlark-apps-dir-ownership
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:
|
||||
|
||||
+8
-36
@@ -30,7 +30,6 @@ from src.plugin_system.schema_manager import SchemaManager
|
||||
from src.plugin_system.operation_queue import PluginOperationQueue
|
||||
from src.plugin_system.state_manager import PluginStateManager
|
||||
from src.plugin_system.operation_history import OperationHistory
|
||||
from src.plugin_system.health_monitor import PluginHealthMonitor
|
||||
|
||||
_JOURNALCTL = shutil.which('journalctl')
|
||||
_SYSTEMCTL = shutil.which('systemctl')
|
||||
@@ -90,12 +89,15 @@ config = config_manager.load_config()
|
||||
plugin_system_config = config.get('plugin_system', {})
|
||||
plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos')
|
||||
|
||||
# Project root (LEDMatrix directory). Needed below for data/ and assets/ paths
|
||||
# whether or not the plugins directory is absolute.
|
||||
project_root = Path(__file__).parent.parent
|
||||
|
||||
# Resolve plugin directory - handle both absolute and relative paths
|
||||
if os.path.isabs(plugins_dir_name):
|
||||
plugins_dir = Path(plugins_dir_name)
|
||||
else:
|
||||
# If relative, resolve relative to the project root (LEDMatrix directory)
|
||||
project_root = Path(__file__).parent.parent
|
||||
# If relative, resolve relative to the project root
|
||||
plugins_dir = project_root / plugins_dir_name
|
||||
|
||||
plugin_manager = PluginManager(
|
||||
@@ -154,11 +156,6 @@ operation_history = OperationHistory(
|
||||
lazy_load=True
|
||||
)
|
||||
|
||||
# Initialize health monitoring (if health tracker is available)
|
||||
# Deferred until first request to improve startup time
|
||||
health_monitor = None
|
||||
_health_monitor_initialized = False
|
||||
|
||||
# Plugin discovery is deferred until first API request that needs it
|
||||
# This improves startup time - endpoints will call discover_plugins() when needed
|
||||
|
||||
@@ -181,7 +178,6 @@ api_v3.schema_manager = schema_manager
|
||||
api_v3.operation_queue = operation_queue
|
||||
api_v3.plugin_state_manager = plugin_state_manager
|
||||
api_v3.operation_history = operation_history
|
||||
api_v3.health_monitor = health_monitor
|
||||
# Initialize cache manager for API endpoints
|
||||
from src.cache_manager import CacheManager
|
||||
api_v3.cache_manager = CacheManager()
|
||||
@@ -926,28 +922,6 @@ def favicon():
|
||||
"""Return 204 No Content for favicon to avoid 404 errors"""
|
||||
return '', 204
|
||||
|
||||
def _initialize_health_monitor():
|
||||
"""Initialize health monitoring after server is ready to accept requests."""
|
||||
global health_monitor, _health_monitor_initialized
|
||||
if _health_monitor_initialized:
|
||||
return
|
||||
|
||||
if health_monitor is None and hasattr(plugin_manager, 'health_tracker') and plugin_manager.health_tracker:
|
||||
try:
|
||||
health_monitor = PluginHealthMonitor(
|
||||
health_tracker=plugin_manager.health_tracker,
|
||||
check_interval=60.0, # Check every minute
|
||||
degraded_threshold=0.5,
|
||||
unhealthy_threshold=0.8,
|
||||
max_response_time=5.0
|
||||
)
|
||||
health_monitor.start_monitoring()
|
||||
print("✓ Plugin health monitoring started")
|
||||
except Exception as e:
|
||||
print(f"⚠ Could not start health monitoring: {e}")
|
||||
|
||||
_health_monitor_initialized = True
|
||||
|
||||
_reconciliation_done = False
|
||||
_reconciliation_started = False
|
||||
import threading as _threading
|
||||
@@ -1035,13 +1009,11 @@ def _run_startup_reconciliation() -> None:
|
||||
# retrigger reconciliation on every subsequent request.
|
||||
_reconciliation_done = True
|
||||
|
||||
# Initialize health monitor and run reconciliation on first request
|
||||
# Run reconciliation in the background on first request
|
||||
@app.before_request
|
||||
def check_health_monitor():
|
||||
"""Ensure health monitor is initialized; launch reconciliation in background."""
|
||||
def start_startup_reconciliation():
|
||||
"""Launch startup reconciliation in the background once."""
|
||||
global _reconciliation_started
|
||||
if not _health_monitor_initialized:
|
||||
_initialize_health_monitor()
|
||||
with _reconciliation_lock:
|
||||
if not _reconciliation_started:
|
||||
_reconciliation_started = True
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -985,6 +985,10 @@ def save_main_config():
|
||||
mode_key = field[len('duration__'):]
|
||||
if not mode_key:
|
||||
continue
|
||||
if raw_value is None or str(raw_value).strip() == '':
|
||||
# Blank means "use the plugin's own duration".
|
||||
current_config['display']['display_durations'].pop(mode_key, None)
|
||||
continue
|
||||
try:
|
||||
int_value = int(raw_value)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -10,10 +10,11 @@ from web_interface.blueprints.api_v3 import (
|
||||
_MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value,
|
||||
_get_display_service_status, _mqtt_bridge_service_state,
|
||||
_read_mqtt_bridge_config, api_v3, contextlib, describe_exception,
|
||||
error_response, get_error_aggregator, json, jsonify, logger, os, request,
|
||||
error_response, json, jsonify, logger, os, redact_text, request,
|
||||
subprocess, success_response, tempfile,
|
||||
)
|
||||
from src.common.path_safety import safe_path_component
|
||||
from src import error_aggregator as _errors
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
# as module attributes, and a value binding would not see the patch.
|
||||
@@ -326,17 +327,62 @@ def delete_cache_file():
|
||||
except Exception as e:
|
||||
logger.error('Error in delete_cache_file', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
def _errors_cache():
|
||||
"""The shared cache the display service publishes its errors to."""
|
||||
if not api_v3.cache_manager:
|
||||
from src.cache_manager import CacheManager
|
||||
api_v3.cache_manager = CacheManager()
|
||||
return api_v3.cache_manager
|
||||
|
||||
|
||||
def _redact_error_text(text, keep_lines=False):
|
||||
"""Credentials out of plugin exception text, which can quote a URL with
|
||||
an API key in it. Stack traces keep their line breaks and indentation."""
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
if not keep_lines:
|
||||
return redact_text(text, max_length=len(text) + 1)
|
||||
return '\n'.join(
|
||||
line[:len(line) - len(line.lstrip())] + redact_text(line, max_length=len(line) + 1)
|
||||
for line in text.splitlines()
|
||||
)
|
||||
|
||||
|
||||
def _redact_error_record(record):
|
||||
if not isinstance(record, dict):
|
||||
return record
|
||||
record = dict(record)
|
||||
record['message'] = _redact_error_text(record.get('message'))
|
||||
record['stack_trace'] = _redact_error_text(record.get('stack_trace'), keep_lines=True)
|
||||
if isinstance(record.get('context'), dict):
|
||||
record['context'] = {k: _redact_error_text(v) for k, v in record['context'].items()}
|
||||
return record
|
||||
|
||||
|
||||
def _read_errors():
|
||||
snapshot, clear_request = _errors.read_error_report(_errors_cache())
|
||||
return snapshot, clear_request
|
||||
|
||||
|
||||
@api_v3.route('/errors/summary', methods=['GET'])
|
||||
def get_error_summary():
|
||||
"""
|
||||
Get summary of all errors for monitoring and debugging.
|
||||
|
||||
Returns error counts, detected patterns, and recent errors.
|
||||
Returns error counts, detected patterns, and recent errors, as last
|
||||
reported by the display service (which runs the plugins, so it is the
|
||||
only process that records their errors). ``snapshot_available`` is false
|
||||
until it has reported; ``generated_at`` says when it did.
|
||||
"""
|
||||
try:
|
||||
aggregator = get_error_aggregator()
|
||||
summary = aggregator.get_error_summary()
|
||||
return success_response(data=summary, message="Error summary retrieved")
|
||||
summary = _errors.error_summary_from_report(*_read_errors())
|
||||
summary['recent_errors'] = [_redact_error_record(r) for r in summary['recent_errors']]
|
||||
for pattern in summary['active_patterns'].values():
|
||||
if isinstance(pattern, dict) and isinstance(pattern.get('sample_messages'), list):
|
||||
pattern['sample_messages'] = [_redact_error_text(m) for m in pattern['sample_messages']]
|
||||
message = ("Error summary retrieved" if summary['snapshot_available']
|
||||
else "The display service has not reported any errors yet")
|
||||
return success_response(data=summary, message=message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting error summary: {e}", exc_info=True)
|
||||
return error_response(
|
||||
@@ -352,11 +398,13 @@ def get_plugin_errors(plugin_id):
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns health status and error statistics for the plugin.
|
||||
Returns health status and error statistics for the plugin, from the
|
||||
display service's last report (see get_error_summary). A plugin with no
|
||||
recorded errors is "healthy".
|
||||
"""
|
||||
try:
|
||||
aggregator = get_error_aggregator()
|
||||
health = aggregator.get_plugin_health(plugin_id)
|
||||
health = _errors.plugin_health_from_report(*_read_errors(), plugin_id)
|
||||
health['last_error'] = _redact_error_record(health['last_error'])
|
||||
return success_response(data=health, message="Plugin health retrieved")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True)
|
||||
@@ -372,42 +420,61 @@ def clear_old_errors():
|
||||
|
||||
Request body (optional):
|
||||
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
|
||||
all: true clears every error recorded so far (max_age_hours ignored)
|
||||
|
||||
The errors live in the display service, so this records a clear request
|
||||
that it applies within a few seconds. Reads hide the cleared errors from
|
||||
the moment the request is recorded.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
clear_all = _coerce_to_bool(data.get('all'))
|
||||
raw_max_age = data.get('max_age_hours', 24)
|
||||
|
||||
# Validate and coerce max_age_hours
|
||||
max_age_hours = None
|
||||
if not clear_all:
|
||||
try:
|
||||
max_age_hours = int(raw_max_age)
|
||||
if max_age_hours < 1:
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours must be at least 1",
|
||||
context={'provided_value': raw_max_age},
|
||||
status_code=400
|
||||
)
|
||||
if max_age_hours > 8760: # 1 year max
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours cannot exceed 8760 (1 year)",
|
||||
context={'provided_value': raw_max_age},
|
||||
status_code=400
|
||||
)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours must be a valid integer",
|
||||
context={'provided_value': str(raw_max_age)},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
now = _pkg.time.time()
|
||||
cutoff = now if clear_all else now - max_age_hours * 3600
|
||||
try:
|
||||
max_age_hours = int(raw_max_age)
|
||||
if max_age_hours < 1:
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours must be at least 1",
|
||||
context={'provided_value': raw_max_age},
|
||||
status_code=400
|
||||
)
|
||||
if max_age_hours > 8760: # 1 year max
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours cannot exceed 8760 (1 year)",
|
||||
context={'provided_value': raw_max_age},
|
||||
status_code=400
|
||||
)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
result = _errors.request_error_clear(_errors_cache(), cutoff)
|
||||
except OSError as e:
|
||||
logger.error("Could not record an error clear request: %s", e)
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours must be a valid integer",
|
||||
context={'provided_value': str(raw_max_age)},
|
||||
status_code=400
|
||||
error_code=ErrorCode.SYSTEM_ERROR,
|
||||
message="Could not record the clear request in the shared cache",
|
||||
status_code=500
|
||||
)
|
||||
|
||||
aggregator = get_error_aggregator()
|
||||
cleared_count = aggregator.clear_old_records(max_age_hours=max_age_hours)
|
||||
|
||||
scope = "all errors" if clear_all else f"errors older than {max_age_hours} hours"
|
||||
return success_response(
|
||||
data={'cleared_count': cleared_count},
|
||||
message=f"Cleared {cleared_count} error records older than {max_age_hours} hours"
|
||||
data=result,
|
||||
message=(f"Clear of {scope} requested; the display service applies it "
|
||||
f"within about {int(_errors.SNAPSHOT_TICK_INTERVAL)} seconds")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing old errors: {e}", exc_info=True)
|
||||
|
||||
@@ -16,7 +16,7 @@ from web_interface.blueprints.api_v3 import (
|
||||
api_v3, datetime, deep_merge, describe_exception, error_response,
|
||||
find_secret_fields, hashlib, json, jsonify, logger, logging,
|
||||
merge_secrets, os, redact_text, remove_empty_secrets, request,
|
||||
separate_secrets, shutil, stat, subprocess, success_response, sys,
|
||||
separate_secrets, shutil, stat, subprocess, success_response,
|
||||
tempfile, uuid, validate_request_json,
|
||||
)
|
||||
from src.common.path_safety import (
|
||||
@@ -409,6 +409,8 @@ def manage_plugin_limits(plugin_id):
|
||||
@api_v3.route('/plugins/toggle', methods=['POST'])
|
||||
def toggle_plugin():
|
||||
"""Toggle plugin enabled/disabled"""
|
||||
plugin_id = None
|
||||
enabled = None
|
||||
try:
|
||||
if not api_v3.plugin_manager or not api_v3.config_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin or config manager not initialized'}), 500
|
||||
@@ -506,21 +508,21 @@ def toggle_plugin():
|
||||
message=f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} successfully"
|
||||
)
|
||||
except Exception as e:
|
||||
from src.web_interface.errors import WebInterfaceError
|
||||
error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_OPERATION_CONFLICT)
|
||||
# Not PLUGIN_OPERATION_CONFLICT: that told the user "an operation is
|
||||
# already in progress" whatever actually went wrong.
|
||||
logger.error('Error toggling plugin %s', plugin_id, exc_info=True)
|
||||
if api_v3.operation_history:
|
||||
toggle_type = "enable" if ('data' in locals() and data.get('enabled')) else "disable"
|
||||
api_v3.operation_history.record_operation(
|
||||
toggle_type,
|
||||
plugin_id=data.get('plugin_id') if 'data' in locals() else None,
|
||||
"enable" if enabled else "disable",
|
||||
plugin_id=plugin_id,
|
||||
status="failed",
|
||||
error=str(e)
|
||||
)
|
||||
action = 'enable' if enabled else 'disable' if enabled is not None else 'toggle'
|
||||
return error_response(
|
||||
error.error_code,
|
||||
error.message,
|
||||
details=error.details,
|
||||
context=error.context,
|
||||
ErrorCode.UNKNOWN_ERROR,
|
||||
f"Failed to {action} plugin {plugin_id or ''}".rstrip(),
|
||||
details=describe_exception(e),
|
||||
status_code=500
|
||||
)
|
||||
@api_v3.route('/plugins/operation/<operation_id>', methods=['GET'])
|
||||
@@ -3179,195 +3181,6 @@ sys.exit(proc.returncode)
|
||||
except Exception as e:
|
||||
logger.error('Error in execute_plugin_action', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/authenticate/spotify', methods=['POST'])
|
||||
def authenticate_spotify():
|
||||
"""Run Spotify authentication script"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
redirect_url = data.get('redirect_url', '').strip()
|
||||
|
||||
# Get plugin directory
|
||||
plugin_id = 'ledmatrix-music'
|
||||
if api_v3.plugin_manager:
|
||||
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
||||
else:
|
||||
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
||||
|
||||
if not plugin_dir or not Path(plugin_dir).exists():
|
||||
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
||||
|
||||
auth_script = Path(plugin_dir) / 'authenticate_spotify.py'
|
||||
if not auth_script.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404
|
||||
|
||||
# Set LEDMATRIX_ROOT environment variable
|
||||
env = os.environ.copy()
|
||||
env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT)
|
||||
|
||||
if redirect_url:
|
||||
# Step 2: Complete authentication with redirect URL
|
||||
# Create a wrapper script that provides the redirect URL as input
|
||||
import tempfile
|
||||
|
||||
# Create a wrapper script that provides the redirect URL
|
||||
import json
|
||||
redirect_url_escaped = json.dumps(redirect_url) # Properly escape the URL
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper:
|
||||
wrapper.write(f'''import sys
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
# Set LEDMATRIX_ROOT
|
||||
os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}"
|
||||
|
||||
# Run the auth script and provide redirect URL
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, r"{auth_script}"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=os.environ
|
||||
)
|
||||
|
||||
# Send redirect URL to stdin
|
||||
redirect_url = {redirect_url_escaped}
|
||||
stdout, _ = proc.communicate(input=redirect_url + "\\n", timeout=120)
|
||||
print(stdout)
|
||||
sys.exit(proc.returncode)
|
||||
''')
|
||||
wrapper_path = wrapper.name
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['python3', wrapper_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env=env
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Spotify authentication completed successfully',
|
||||
'output': result.stdout
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Spotify authentication failed',
|
||||
'output': result.stdout + result.stderr
|
||||
}), 400
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408
|
||||
finally:
|
||||
# The wrapper carries the user's redirect URL, so it must not
|
||||
# survive the request on any path — including a failure to
|
||||
# launch, which the previous per-branch unlinks missed.
|
||||
if os.path.exists(wrapper_path):
|
||||
os.unlink(wrapper_path)
|
||||
else:
|
||||
# Step 1: Get authorization URL
|
||||
# Import the script's functions directly to get the auth URL
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
# Load the authentication script as a module
|
||||
spec = importlib.util.spec_from_file_location("auth_spotify", auth_script)
|
||||
auth_module = importlib.util.module_from_spec(spec)
|
||||
sys.modules["auth_spotify"] = auth_module
|
||||
|
||||
# Set LEDMATRIX_ROOT before loading
|
||||
os.environ['LEDMATRIX_ROOT'] = str(PROJECT_ROOT)
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(auth_module)
|
||||
|
||||
# Get credentials and create OAuth object
|
||||
client_id, client_secret, redirect_uri = auth_module.load_spotify_credentials()
|
||||
if not all([client_id, client_secret, redirect_uri]):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Could not load Spotify credentials. Please check config/config_secrets.json.'
|
||||
}), 400
|
||||
|
||||
from spotipy.oauth2 import SpotifyOAuth
|
||||
sp_oauth = SpotifyOAuth(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=auth_module.SCOPE,
|
||||
cache_path=auth_module.SPOTIFY_AUTH_CACHE_PATH,
|
||||
open_browser=False
|
||||
)
|
||||
|
||||
auth_url = sp_oauth.get_authorize_url()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Authorization URL generated',
|
||||
'auth_url': auth_url
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error getting Spotify auth URL", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error in authenticate_spotify', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/authenticate/ytm', methods=['POST'])
|
||||
def authenticate_ytm():
|
||||
"""Run YouTube Music authentication script"""
|
||||
try:
|
||||
# Get plugin directory
|
||||
plugin_id = 'ledmatrix-music'
|
||||
if api_v3.plugin_manager:
|
||||
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
||||
else:
|
||||
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
||||
|
||||
if not plugin_dir or not Path(plugin_dir).exists():
|
||||
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
||||
|
||||
auth_script = Path(plugin_dir) / 'authenticate_ytm.py'
|
||||
if not auth_script.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404
|
||||
|
||||
# Set LEDMATRIX_ROOT environment variable
|
||||
env = os.environ.copy()
|
||||
env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT)
|
||||
|
||||
# Run the authentication script
|
||||
result = subprocess.run(
|
||||
['python3', str(auth_script)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
env=env
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'YouTube Music authentication completed successfully',
|
||||
'output': result.stdout
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'YouTube Music authentication failed',
|
||||
'output': result.stdout + result.stderr
|
||||
}), 400
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408
|
||||
except Exception as e:
|
||||
logger.error('Error in authenticate_ytm', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
def _plugin_uploads_dir(plugin_id):
|
||||
"""assets/plugins/<plugin_id>/uploads for a request-supplied id, or None.
|
||||
|
||||
@@ -3525,210 +3338,6 @@ def upload_plugin_asset():
|
||||
'total_files': len(metadata)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/of-the-day/json/upload', methods=['POST'])
|
||||
def upload_of_the_day_json():
|
||||
"""Upload JSON files for of-the-day plugin"""
|
||||
try:
|
||||
if 'files' not in request.files:
|
||||
return jsonify({'status': 'error', 'message': 'No files provided'}), 400
|
||||
|
||||
files = request.files.getlist('files')
|
||||
if not files or all(not f.filename for f in files):
|
||||
return jsonify({'status': 'error', 'message': 'No files provided'}), 400
|
||||
|
||||
# Get plugin directory
|
||||
plugin_id = 'ledmatrix-of-the-day'
|
||||
if api_v3.plugin_manager:
|
||||
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
||||
else:
|
||||
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
||||
|
||||
if not plugin_dir or not Path(plugin_dir).exists():
|
||||
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
||||
|
||||
# Setup of_the_day directory
|
||||
data_dir = Path(plugin_dir) / 'of_the_day'
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
uploaded_files = []
|
||||
max_size_per_file = 5 * 1024 * 1024 # 5MB
|
||||
|
||||
for file in files:
|
||||
if not file.filename:
|
||||
continue
|
||||
|
||||
# Validate file extension
|
||||
if not file.filename.lower().endswith('.json'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'File {file.filename} must be a JSON file (.json)'
|
||||
}), 400
|
||||
|
||||
# Read and validate file size
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_size = file.tell()
|
||||
file.seek(0)
|
||||
|
||||
if file_size > max_size_per_file:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'File {file.filename} exceeds 5MB limit'
|
||||
}), 400
|
||||
|
||||
# Read and validate JSON content
|
||||
try:
|
||||
file_content = file.read().decode('utf-8')
|
||||
json_data = json.loads(file_content)
|
||||
except json.JSONDecodeError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid JSON in request body'
|
||||
}), 400
|
||||
except UnicodeDecodeError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'File {file.filename} is not valid UTF-8 text'
|
||||
}), 400
|
||||
|
||||
# Validate JSON structure (must be object with day number keys)
|
||||
if not isinstance(json_data, dict):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'JSON in {file.filename} must be an object with day numbers (1-365) as keys'
|
||||
}), 400
|
||||
|
||||
# Check if keys are valid day numbers
|
||||
for key in json_data.keys():
|
||||
try:
|
||||
day_num = int(key)
|
||||
if day_num < 1 or day_num > 365:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Day number {day_num} in {file.filename} is out of range (must be 1-365)'
|
||||
}), 400
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid key "{key}" in {file.filename}: must be a day number (1-365)'
|
||||
}), 400
|
||||
|
||||
# Generate safe filename from original (preserve user's filename)
|
||||
original_filename = file.filename
|
||||
safe_filename = original_filename.lower().replace(' ', '_')
|
||||
# Ensure it's a valid filename
|
||||
safe_filename = ''.join(c for c in safe_filename if c.isalnum() or c in '._-')
|
||||
if not safe_filename.endswith('.json'):
|
||||
safe_filename += '.json'
|
||||
|
||||
file_path = data_dir / safe_filename
|
||||
|
||||
# If file exists, add counter
|
||||
counter = 1
|
||||
base_name = safe_filename.replace('.json', '')
|
||||
while file_path.exists():
|
||||
safe_filename = f"{base_name}_{counter}.json"
|
||||
file_path = data_dir / safe_filename
|
||||
counter += 1
|
||||
|
||||
# Save file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(json_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Make file readable
|
||||
os.chmod(file_path, 0o644)
|
||||
|
||||
# Extract category name from filename (remove .json extension)
|
||||
category_name = safe_filename.replace('.json', '')
|
||||
display_name = category_name.replace('_', ' ').title()
|
||||
|
||||
# Update plugin config to add category
|
||||
try:
|
||||
sys.path.insert(0, str(plugin_dir))
|
||||
from scripts.update_config import add_category_to_config
|
||||
add_category_to_config(category_name, f'of_the_day/{safe_filename}', display_name)
|
||||
except Exception as e:
|
||||
logger.warning("Could not update config: %s", e)
|
||||
# Continue anyway - file is uploaded
|
||||
|
||||
# Generate file ID (use category name as ID for simplicity)
|
||||
file_id = category_name
|
||||
|
||||
uploaded_files.append({
|
||||
'id': file_id,
|
||||
'filename': safe_filename,
|
||||
'original_filename': original_filename,
|
||||
'path': f'of_the_day/{safe_filename}',
|
||||
'size': file_size,
|
||||
'uploaded_at': datetime.utcnow().isoformat() + 'Z',
|
||||
'category_name': category_name,
|
||||
'display_name': display_name,
|
||||
'entry_count': len(json_data)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'uploaded_files': uploaded_files,
|
||||
'total_files': len(uploaded_files)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/of-the-day/json/delete', methods=['POST'])
|
||||
def delete_of_the_day_json():
|
||||
"""Delete a JSON file from of-the-day plugin"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
file_id = data.get('file_id') # This is the category_name
|
||||
|
||||
if not file_id:
|
||||
return jsonify({'status': 'error', 'message': 'file_id is required'}), 400
|
||||
|
||||
# file_id names a file that is about to be unlinked, and it arrives
|
||||
# straight from the request body. Unvalidated, a file_id of
|
||||
# "../../../../etc/cron" made this endpoint delete any .json file on
|
||||
# the device the service could write to.
|
||||
safe_file_id = safe_path_component(file_id)
|
||||
if not safe_file_id:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400
|
||||
|
||||
# Get plugin directory
|
||||
plugin_id = 'ledmatrix-of-the-day'
|
||||
if api_v3.plugin_manager:
|
||||
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
||||
else:
|
||||
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
||||
|
||||
if not plugin_dir or not Path(plugin_dir).exists():
|
||||
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
||||
|
||||
filename = f"{safe_file_id}.json"
|
||||
file_path = resolve_under(Path(plugin_dir) / 'of_the_day', filename)
|
||||
if file_path is None:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400
|
||||
|
||||
if not file_path.exists():
|
||||
return jsonify({'status': 'error', 'message': f'File {filename} not found'}), 404
|
||||
|
||||
# Delete file
|
||||
file_path.unlink()
|
||||
|
||||
# Update config to remove category
|
||||
try:
|
||||
sys.path.insert(0, str(plugin_dir))
|
||||
from scripts.update_config import remove_category_from_config
|
||||
remove_category_from_config(safe_file_id)
|
||||
except Exception as e:
|
||||
logger.warning("Could not update config: %s", e)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'File {filename} deleted successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint, Response, render_template, flash, jsonify, url_for
|
||||
from flask import Blueprint, Response, render_template, jsonify, url_for
|
||||
from jinja2 import TemplateNotFound
|
||||
from markupsafe import escape
|
||||
from html.parser import HTMLParser
|
||||
@@ -20,12 +20,8 @@ from web_interface import widget_bundle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Will be initialized when blueprint is registered
|
||||
config_manager = None
|
||||
plugin_manager = None
|
||||
plugin_store_manager = None
|
||||
schema_manager = None
|
||||
|
||||
# The managers live on the blueprint object: app.py sets
|
||||
# pages_v3.config_manager, pages_v3.plugin_manager and the rest.
|
||||
pages_v3 = Blueprint('pages_v3', __name__)
|
||||
|
||||
|
||||
@@ -162,37 +158,8 @@ _SEARCH_INDEX_CACHE = {'sig': None, 'fields': None}
|
||||
|
||||
@pages_v3.route('/')
|
||||
def index():
|
||||
"""Main v3 interface page"""
|
||||
try:
|
||||
if pages_v3.config_manager:
|
||||
# Load configuration data
|
||||
main_config = pages_v3.config_manager.load_config()
|
||||
schedule_config = main_config.get('schedule', {})
|
||||
|
||||
# Get raw config files for JSON editor
|
||||
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
|
||||
secrets_config_data = pages_v3.config_manager.get_raw_file_content('secrets')
|
||||
main_config_json = json.dumps(main_config_data, indent=4)
|
||||
secrets_config_json = json.dumps(secrets_config_data, indent=4)
|
||||
else:
|
||||
raise Exception("Config manager not initialized")
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Error loading configuration: {e}", "error")
|
||||
schedule_config = {}
|
||||
main_config_json = "{}"
|
||||
secrets_config_json = "{}"
|
||||
main_config_data = {}
|
||||
secrets_config_data = {}
|
||||
|
||||
return render_template('v3/index.html',
|
||||
schedule_config=schedule_config,
|
||||
main_config_json=main_config_json,
|
||||
secrets_config_json=secrets_config_json,
|
||||
main_config_path=pages_v3.config_manager.get_config_path() if pages_v3.config_manager else "",
|
||||
secrets_config_path=pages_v3.config_manager.get_secrets_path() if pages_v3.config_manager else "",
|
||||
main_config=main_config_data,
|
||||
secrets_config=secrets_config_data)
|
||||
"""Main v3 interface page: the app shell. Every tab loads as a partial."""
|
||||
return render_template('v3/base.html')
|
||||
|
||||
@pages_v3.route('/partials/<partial_name>')
|
||||
def load_partial(partial_name):
|
||||
@@ -552,14 +519,39 @@ def _load_display_partial():
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _plugin_default_duration(plugin_id, plugin_config):
|
||||
"""Seconds a plugin shows each screen when the Rotation page sets none.
|
||||
|
||||
Mirrors BasePlugin.get_display_duration's config fallback: the plugin's
|
||||
display_duration, else its schema default, else 15.
|
||||
"""
|
||||
def _valid(v):
|
||||
return isinstance(v, (int, float)) and not isinstance(v, bool) and v > 0
|
||||
|
||||
value = plugin_config.get('display_duration')
|
||||
if not _valid(value):
|
||||
schema = None
|
||||
schema_mgr = getattr(pages_v3, 'schema_manager', None)
|
||||
if schema_mgr is not None:
|
||||
try:
|
||||
schema = schema_mgr.load_schema(plugin_id)
|
||||
except Exception:
|
||||
logger.debug("durations: no schema for %s", plugin_id, exc_info=True)
|
||||
value = plugin_config_defaults(schema if isinstance(schema, dict) else None).get(
|
||||
'display_duration')
|
||||
if not _valid(value):
|
||||
value = 15
|
||||
return int(value) if float(value).is_integer() else value
|
||||
|
||||
|
||||
def _load_durations_partial():
|
||||
"""Load rotation & durations partial.
|
||||
|
||||
Builds one duration entry per display mode of every enabled plugin
|
||||
(falling back to the display controller's 30s default), overlaid with any
|
||||
values saved in display.display_durations. Historically the template only
|
||||
looped over saved keys, and nothing ever populated them, so the page
|
||||
rendered empty.
|
||||
Builds one duration entry per display mode of every enabled plugin. A mode
|
||||
with a value saved in display.display_durations shows it; the rest are
|
||||
blank, with the plugin's own duration as the placeholder, because a saved
|
||||
value overrides the plugin (see DisplayController._get_display_duration).
|
||||
Pre-filling every mode would pin them all on the first save.
|
||||
"""
|
||||
try:
|
||||
if pages_v3.config_manager:
|
||||
@@ -578,10 +570,12 @@ def _load_durations_partial():
|
||||
continue
|
||||
modes = pages_v3.plugin_manager.get_plugin_display_modes(pid) or [pid]
|
||||
covered_keys.update(modes)
|
||||
default = _plugin_default_duration(pid, main_config.get(pid, {}) or {})
|
||||
duration_groups.append({
|
||||
'plugin_id': pid,
|
||||
'plugin_name': info.get('name') or pid,
|
||||
'modes': [{'key': m, 'value': saved.get(m, 30)} for m in modes],
|
||||
'modes': [{'key': m, 'value': saved.get(m, ''), 'default': default}
|
||||
for m in modes],
|
||||
})
|
||||
# Saved keys not owned by any enabled plugin (disabled or
|
||||
# uninstalled plugins) stay visible rather than vanishing.
|
||||
|
||||
@@ -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);
|
||||
@@ -401,124 +243,25 @@ window.__pendingInstalledPlugins = window.__pendingInstalledPlugins || null;
|
||||
window.__pendingStorePlugins = window.__pendingStorePlugins || null;
|
||||
window.__pluginDomReady = window.__pluginDomReady || false;
|
||||
|
||||
// Set up global event delegation for plugin actions (works even before plugins are loaded)
|
||||
// Document-level delegation for plugin card actions, so a card works even if
|
||||
// it was rendered before the grid's own listener was attached. It hands the
|
||||
// event to handlePluginAction, which the plugin-manager IIFE below exposes on
|
||||
// window. (It used to test `typeof handlePluginAction`, which is IIFE-scoped
|
||||
// and so never visible here: every click took a copied fallback instead, which
|
||||
// asked to confirm an uninstall twice and sent Starlark app uninstalls to the
|
||||
// plugin endpoint.)
|
||||
(function setupGlobalEventDelegation() {
|
||||
// Use document-level delegation so it works for dynamically added content
|
||||
const handleGlobalPluginAction = function(event) {
|
||||
// Only handle if it's a plugin action
|
||||
const button = event.target.closest('button[data-action][data-plugin-id]') ||
|
||||
event.target.closest('input[data-action][data-plugin-id]');
|
||||
if (!button) return;
|
||||
|
||||
const action = button.getAttribute('data-action');
|
||||
const pluginId = button.getAttribute('data-plugin-id');
|
||||
|
||||
// For toggle and configure, ensure functions are available
|
||||
if (action === 'toggle' || action === 'configure') {
|
||||
const funcName = action === 'toggle' ? 'togglePlugin' : 'configurePlugin';
|
||||
if (!window[funcName] || typeof window[funcName] !== 'function') {
|
||||
// Prevent default and stop propagation immediately to avoid double handling
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
console.warn(`[GLOBAL DELEGATION] ${funcName} not available yet, waiting...`);
|
||||
|
||||
// Capture state synchronously from plugin data (source of truth)
|
||||
let targetChecked = false;
|
||||
if (action === 'toggle') {
|
||||
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
|
||||
|
||||
let currentEnabled;
|
||||
if (plugin) {
|
||||
currentEnabled = Boolean(plugin.enabled);
|
||||
} else if (button.type === 'checkbox') {
|
||||
currentEnabled = button.checked;
|
||||
} else {
|
||||
currentEnabled = false;
|
||||
}
|
||||
|
||||
targetChecked = !currentEnabled; // Toggle to opposite state
|
||||
}
|
||||
|
||||
// Wait for function to be available
|
||||
let attempts = 0;
|
||||
const maxAttempts = 20; // 1 second total
|
||||
const checkInterval = setInterval(() => {
|
||||
attempts++;
|
||||
if (window[funcName] && typeof window[funcName] === 'function') {
|
||||
clearInterval(checkInterval);
|
||||
// Call the function directly
|
||||
if (action === 'toggle') {
|
||||
window.togglePlugin(pluginId, targetChecked);
|
||||
} else {
|
||||
window.configurePlugin(pluginId);
|
||||
}
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(checkInterval);
|
||||
console.error(`[GLOBAL DELEGATION] ${funcName} not available after ${maxAttempts} attempts`);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(`${funcName} not loaded. Please refresh the page.`, 'error');
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
return; // Don't proceed with normal handling
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent default and stop propagation to avoid double handling
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// If handlePluginAction exists, use it; otherwise handle directly
|
||||
if (typeof handlePluginAction === 'function') {
|
||||
handlePluginAction(event);
|
||||
} else {
|
||||
// Fallback: handle directly if functions are available
|
||||
if (action === 'toggle' && window.togglePlugin) {
|
||||
// Get the current enabled state from plugin data (source of truth)
|
||||
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
|
||||
|
||||
let currentEnabled;
|
||||
if (plugin) {
|
||||
currentEnabled = Boolean(plugin.enabled);
|
||||
} else if (button.type === 'checkbox') {
|
||||
currentEnabled = button.checked;
|
||||
} else {
|
||||
currentEnabled = false;
|
||||
}
|
||||
|
||||
// Toggle the state - we want the opposite of current state
|
||||
const isChecked = !currentEnabled;
|
||||
|
||||
// Prevent default behavior to avoid double-toggling and change event
|
||||
// (Already done at start of function, but safe to repeat)
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
debugLog('[DEBUG toggle fallback] Plugin:', pluginId, 'Current enabled (from data):', currentEnabled, 'New state:', isChecked);
|
||||
|
||||
window.togglePlugin(pluginId, isChecked);
|
||||
} else if (action === 'configure' && window.configurePlugin) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
window.configurePlugin(pluginId);
|
||||
} else if (action === 'update' && window.updatePlugin) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
debugLog('[DEBUG update fallback] Updating plugin:', pluginId);
|
||||
window.updatePlugin(pluginId);
|
||||
} else if (action === 'uninstall' && window.uninstallPlugin) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
debugLog('[DEBUG uninstall fallback] Uninstalling plugin:', pluginId);
|
||||
if (confirm(`Are you sure you want to uninstall ${pluginId}?`)) {
|
||||
window.uninstallPlugin(pluginId);
|
||||
}
|
||||
}
|
||||
}
|
||||
const target = event.target;
|
||||
if (!target || typeof target.closest !== 'function') return;
|
||||
const el = target.closest('button[data-action][data-plugin-id]') ||
|
||||
target.closest('input[data-action][data-plugin-id]');
|
||||
if (!el || typeof window.handlePluginAction !== 'function') return;
|
||||
window.handlePluginAction(event);
|
||||
};
|
||||
|
||||
// Set up delegation on document (capture phase for better reliability)
|
||||
// Capture phase, so this runs before the grid's own listener;
|
||||
// handlePluginAction stops propagation, so an action is handled once.
|
||||
document.addEventListener('click', handleGlobalPluginAction, true);
|
||||
document.addEventListener('change', handleGlobalPluginAction, true);
|
||||
debugLog('[PLUGINS SCRIPT] Global event delegation set up');
|
||||
@@ -1846,6 +1589,7 @@ function handlePluginAction(event) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
window.handlePluginAction = handlePluginAction;
|
||||
|
||||
function findInstalledPlugin(pluginId) {
|
||||
const plugins = window.installedPlugins || installedPlugins || [];
|
||||
@@ -2220,7 +1964,6 @@ window.__openOnDemandModalImpl = function(pluginId) {
|
||||
});
|
||||
};
|
||||
|
||||
// Replace the stub with the real implementation
|
||||
window.openOnDemandModal = window.__openOnDemandModalImpl;
|
||||
|
||||
// Release handle for the on-demand modal's focus trap (window.LEDDialog).
|
||||
@@ -2358,8 +2101,6 @@ function stopOnDemand(event) {
|
||||
requestOnDemandStop({ stopService });
|
||||
}
|
||||
|
||||
// Store the real implementation and replace the stub
|
||||
window.__requestOnDemandStopImpl = requestOnDemandStop;
|
||||
window.requestOnDemandStop = requestOnDemandStop;
|
||||
|
||||
function closeOnDemandModalOnBackdrop(event) {
|
||||
@@ -2616,98 +2357,6 @@ window.updateKeyValuePairData = function(fieldId, fullKey) {
|
||||
hiddenInput.value = JSON.stringify(pairs);
|
||||
};
|
||||
|
||||
// Functions to handle array-of-objects
|
||||
window.addArrayObjectItem = function(fieldId, fullKey, maxItems) {
|
||||
const itemsContainer = document.getElementById(fieldId + '_items');
|
||||
const hiddenInput = document.getElementById(fieldId + '_data');
|
||||
if (!itemsContainer || !hiddenInput) return;
|
||||
|
||||
const currentItems = itemsContainer.querySelectorAll('.array-object-item');
|
||||
if (currentItems.length >= maxItems) {
|
||||
alert(`Maximum ${maxItems} items allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get schema for item properties from the hidden input's data attribute or currentPluginConfig
|
||||
const schema = (typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema) || (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema);
|
||||
if (!schema) return;
|
||||
|
||||
// Navigate to the items schema
|
||||
const keys = fullKey.split('.');
|
||||
let itemsSchema = schema.properties;
|
||||
for (const key of keys) {
|
||||
if (itemsSchema && itemsSchema[key]) {
|
||||
itemsSchema = itemsSchema[key];
|
||||
if (itemsSchema.type === 'array' && itemsSchema.items) {
|
||||
itemsSchema = itemsSchema.items;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!itemsSchema || !itemsSchema.properties) return;
|
||||
|
||||
const newIndex = currentItems.length;
|
||||
const itemHtml = renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema);
|
||||
itemsContainer.insertAdjacentHTML('beforeend', itemHtml);
|
||||
updateArrayObjectData(fieldId);
|
||||
|
||||
// Update add button state
|
||||
const addButton = itemsContainer.nextElementSibling;
|
||||
if (addButton && currentItems.length + 1 >= maxItems) {
|
||||
addButton.disabled = true;
|
||||
addButton.style.opacity = '0.5';
|
||||
addButton.style.cursor = 'not-allowed';
|
||||
}
|
||||
};
|
||||
|
||||
window.removeArrayObjectItem = function(fieldId, index) {
|
||||
const itemsContainer = document.getElementById(fieldId + '_items');
|
||||
if (!itemsContainer) return;
|
||||
|
||||
const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`);
|
||||
if (item) {
|
||||
item.remove();
|
||||
// Re-index remaining items
|
||||
const remainingItems = itemsContainer.querySelectorAll('.array-object-item');
|
||||
remainingItems.forEach((itemEl, newIndex) => {
|
||||
itemEl.setAttribute('data-index', newIndex);
|
||||
// Update the id attribute to match new index (used by file upload selectors)
|
||||
const newItemId = `${fieldId}_item_${newIndex}`;
|
||||
itemEl.id = newItemId;
|
||||
// Update all inputs within this item - need to update name/id attributes
|
||||
itemEl.querySelectorAll('input, select, textarea').forEach(input => {
|
||||
const name = input.getAttribute('name') || input.id;
|
||||
if (name) {
|
||||
// Update name/id attribute with new index
|
||||
const newName = name.replace(/\[\d+\]/, `[${newIndex}]`);
|
||||
if (input.getAttribute('name')) input.setAttribute('name', newName);
|
||||
if (input.id) input.id = input.id.replace(/\d+/, newIndex);
|
||||
}
|
||||
});
|
||||
// Update button onclick attributes
|
||||
itemEl.querySelectorAll('button[onclick]').forEach(button => {
|
||||
const onclick = button.getAttribute('onclick');
|
||||
if (onclick) {
|
||||
button.setAttribute('onclick', onclick.replace(/\d+/, newIndex));
|
||||
}
|
||||
});
|
||||
});
|
||||
updateArrayObjectData(fieldId);
|
||||
|
||||
// Update add button state
|
||||
const addButton = itemsContainer.nextElementSibling;
|
||||
if (addButton) {
|
||||
const maxItems = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]);
|
||||
if (remainingItems.length < maxItems) {
|
||||
addButton.disabled = false;
|
||||
addButton.style.opacity = '1';
|
||||
addButton.style.cursor = 'pointer';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.updateArrayObjectData = function(fieldId) {
|
||||
const itemsContainer = document.getElementById(fieldId + '_items');
|
||||
const hiddenInput = document.getElementById(fieldId + '_data');
|
||||
@@ -3421,78 +3070,6 @@ window.executePluginAction = function(actionId, actionIndex, pluginIdParam = nul
|
||||
|
||||
// togglePlugin is already defined at the top of the script - no need to redefine
|
||||
|
||||
// Only override updatePlugin if it doesn't already have improved error handling
|
||||
if (!window.updatePlugin || window.updatePlugin.toString().includes('[UPDATE]')) {
|
||||
window.updatePlugin = function(pluginId) {
|
||||
// Validate pluginId
|
||||
if (!pluginId || typeof pluginId !== 'string') {
|
||||
console.error('[UPDATE] Invalid pluginId:', pluginId);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Invalid plugin ID', 'error');
|
||||
}
|
||||
return Promise.reject(new Error('Invalid plugin ID'));
|
||||
}
|
||||
|
||||
showNotification(`Updating ${pluginId}...`, 'info');
|
||||
|
||||
// Prepare request body
|
||||
const requestBody = { plugin_id: pluginId };
|
||||
const requestBodyJson = JSON.stringify(requestBody);
|
||||
|
||||
debugLog('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson });
|
||||
|
||||
return fetch('/api/v3/plugins/update', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: requestBodyJson
|
||||
})
|
||||
.then(async response => {
|
||||
// Check if response is OK before parsing
|
||||
if (!response.ok) {
|
||||
// Try to parse error response
|
||||
let errorData;
|
||||
try {
|
||||
const text = await response.text();
|
||||
console.error('[UPDATE] Error response:', { status: response.status, statusText: response.statusText, body: text });
|
||||
errorData = JSON.parse(text);
|
||||
} catch (e) {
|
||||
errorData = { message: `Server error: ${response.status} ${response.statusText}` };
|
||||
}
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(errorData.message || `Update failed: ${response.status}`, 'error');
|
||||
}
|
||||
throw new Error(errorData.message || `Update failed: ${response.status}`);
|
||||
}
|
||||
|
||||
// Parse successful response
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
showNotification(data.message || 'Update initiated', data.status || 'info');
|
||||
if (data.status === 'success') {
|
||||
// Refresh the list
|
||||
if (typeof loadInstalledPlugins === 'function') {
|
||||
loadInstalledPlugins();
|
||||
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
|
||||
window.pluginManager.loadInstalledPlugins();
|
||||
}
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[UPDATE] Error updating plugin:', error);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Error updating plugin: ' + error.message, 'error');
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
window.uninstallPlugin = function(pluginId) {
|
||||
const plugin = (window.installedPlugins || installedPlugins || []).find(p => p.id === pluginId);
|
||||
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
|
||||
@@ -4627,34 +4204,6 @@ function jsStringAttr(value) {
|
||||
return escapeAttribute(JSON.stringify(value == null ? '' : String(value)));
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'Unknown';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now - date);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 1) {
|
||||
return 'Today';
|
||||
} else if (diffDays < 2) {
|
||||
return 'Yesterday';
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} days ago`;
|
||||
} else if (diffDays < 30) {
|
||||
const weeks = Math.floor(diffDays / 7);
|
||||
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
||||
} else {
|
||||
// Return formatted date for older items
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
function isNewPlugin(lastUpdated) {
|
||||
if (!lastUpdated) return false;
|
||||
|
||||
@@ -5013,10 +4562,6 @@ window.handleCredentialsUpload = async function(event, fieldId, uploadEndpoint,
|
||||
|
||||
// handleFiles is now defined exclusively in file-upload.js widget
|
||||
|
||||
window.deleteUploadedImage = async function(fieldId, imageId, pluginId) {
|
||||
return window.deleteUploadedFile(fieldId, imageId, pluginId, 'image', null);
|
||||
}
|
||||
|
||||
window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType, customDeleteEndpoint) {
|
||||
const fileTypeLabel = fileType === 'json' ? 'file' : 'image';
|
||||
if (!confirm(`Are you sure you want to delete this ${fileTypeLabel}?`)) {
|
||||
@@ -5084,18 +4629,6 @@ window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType,
|
||||
// getUploadConfig is defined in file-upload.js widget which loads first.
|
||||
// No override needed here — file-upload.js owns this function.
|
||||
|
||||
window.getCurrentImages = function(fieldId) {
|
||||
const hiddenInput = document.getElementById(`${fieldId}_images_data`);
|
||||
if (hiddenInput && hiddenInput.value) {
|
||||
try {
|
||||
return JSON.parse(hiddenInput.value);
|
||||
} catch (e) {
|
||||
console.error('Error parsing images data:', e);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
window.updateImageList = function(fieldId, images) {
|
||||
const hiddenInput = document.getElementById(`${fieldId}_images_data`);
|
||||
if (hiddenInput) {
|
||||
@@ -5158,17 +4691,6 @@ window.updateImageList = function(fieldId, images) {
|
||||
}
|
||||
}
|
||||
|
||||
window.showUploadProgress = function(fieldId, totalFiles) {
|
||||
const dropZone = document.getElementById(`${fieldId}_drop_zone`);
|
||||
if (dropZone) {
|
||||
dropZone.innerHTML = `
|
||||
<i class="fas fa-spinner fa-spin text-3xl text-blue-500 mb-2"></i>
|
||||
<p class="text-sm text-gray-600">Uploading ${totalFiles} file(s)...</p>
|
||||
`;
|
||||
dropZone.style.pointerEvents = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
window.hideUploadProgress = function(fieldId) {
|
||||
const uploadConfig = window.getUploadConfig(fieldId);
|
||||
const maxFiles = uploadConfig.max_files || 10;
|
||||
@@ -5186,14 +4708,6 @@ window.hideUploadProgress = function(fieldId) {
|
||||
}
|
||||
}
|
||||
|
||||
window.formatFileSize = function(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'Unknown date';
|
||||
try {
|
||||
@@ -5204,30 +4718,6 @@ function formatDate(dateString) {
|
||||
}
|
||||
}
|
||||
|
||||
window.getScheduleSummary = function(schedule) {
|
||||
if (!schedule || !schedule.enabled || schedule.mode === 'always') {
|
||||
return 'Always shown';
|
||||
}
|
||||
|
||||
if (schedule.mode === 'time_range') {
|
||||
return `${schedule.start_time || '08:00'} - ${schedule.end_time || '18:00'} (daily)`;
|
||||
}
|
||||
|
||||
if (schedule.mode === 'per_day' && schedule.days) {
|
||||
const enabledDays = Object.entries(schedule.days)
|
||||
.filter(([day, config]) => config && config.enabled)
|
||||
.map(([day]) => day.charAt(0).toUpperCase() + day.slice(1, 3));
|
||||
|
||||
if (enabledDays.length === 0) {
|
||||
return 'Never shown';
|
||||
}
|
||||
|
||||
return enabledDays.join(', ') + ' only';
|
||||
}
|
||||
|
||||
return 'Scheduled';
|
||||
}
|
||||
|
||||
window.openImageSchedule = function(fieldId, imageId, imageIdx) {
|
||||
const currentImages = getCurrentImages(fieldId);
|
||||
const image = currentImages[imageIdx];
|
||||
@@ -5614,14 +5104,6 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
};
|
||||
|
||||
// updateArrayObjectData is defined earlier in the file (line ~3596)
|
||||
// Only define stub if it doesn't already exist (defensive fallback)
|
||||
if (typeof window.updateArrayObjectData === 'undefined') {
|
||||
window.updateArrayObjectData = function(fieldId) {
|
||||
console.warn('updateArrayObjectData stub called - implementation should be defined earlier');
|
||||
};
|
||||
}
|
||||
|
||||
window.updateCheckboxGroupData = function(fieldId) {
|
||||
// Update hidden _data input with currently checked values
|
||||
const hiddenInput = document.getElementById(fieldId + '_data');
|
||||
@@ -5640,22 +5122,6 @@ if (typeof window !== 'undefined') {
|
||||
hiddenInput.value = JSON.stringify(selectedValues);
|
||||
};
|
||||
|
||||
// handleArrayObjectFileUpload and removeArrayObjectFile are defined earlier in the file
|
||||
// Only define stubs if they don't already exist (defensive fallback)
|
||||
if (typeof window.handleArrayObjectFileUpload === 'undefined') {
|
||||
window.handleArrayObjectFileUpload = function(event, fieldId, itemIndex, propKey, pluginId) {
|
||||
console.warn('handleArrayObjectFileUpload stub called - implementation should be defined earlier');
|
||||
window.updateArrayObjectData(fieldId);
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window.removeArrayObjectFile === 'undefined') {
|
||||
window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) {
|
||||
console.warn('removeArrayObjectFile stub called - implementation should be defined earlier');
|
||||
window.updateArrayObjectData(fieldId);
|
||||
};
|
||||
}
|
||||
|
||||
// Debug logging (only if pluginDebug is enabled)
|
||||
if (_PLUGIN_DEBUG_EARLY) {
|
||||
debugLog('[ARRAY-OBJECTS] Functions defined on window:', {
|
||||
@@ -5674,23 +5140,6 @@ window.currentPluginConfig = null;
|
||||
// Force initialization immediately when script loads (for HTMX swapped content)
|
||||
debugLog('Plugins script loaded, checking for elements...');
|
||||
|
||||
// Ensure all functions are globally available (in case IIFE didn't expose them properly)
|
||||
// These should already be set inside the IIFE, but this ensures they're available
|
||||
if (typeof initializePluginPageWhenReady !== 'undefined') {
|
||||
window.initializePluginPageWhenReady = initializePluginPageWhenReady;
|
||||
}
|
||||
if (typeof initializePlugins !== 'undefined') {
|
||||
window.initializePlugins = initializePlugins;
|
||||
}
|
||||
if (typeof loadInstalledPlugins !== 'undefined') {
|
||||
window.loadInstalledPlugins = loadInstalledPlugins;
|
||||
}
|
||||
if (typeof renderInstalledPlugins !== 'undefined') {
|
||||
window.renderInstalledPlugins = renderInstalledPlugins;
|
||||
}
|
||||
// GitHub install handlers are now exposed inside the IIFE (see above).
|
||||
// searchPluginStore is also exposed inside the IIFE after its definition.
|
||||
|
||||
// Verify critical functions are available
|
||||
if (_PLUGIN_DEBUG_EARLY) {
|
||||
debugLog('Plugin functions available:', {
|
||||
@@ -5708,18 +5157,6 @@ if (window.checkGitHubAuthStatus && document.getElementById('github-auth-warning
|
||||
window.checkGitHubAuthStatus();
|
||||
}
|
||||
|
||||
// Initialize on-demand modal immediately since it's in base.html
|
||||
if (typeof initializeOnDemandModal === 'function') {
|
||||
// Run immediately and also after DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeOnDemandModal);
|
||||
} else {
|
||||
initializeOnDemandModal();
|
||||
}
|
||||
// Also try after a short delay to ensure elements are available
|
||||
setTimeout(initializeOnDemandModal, 100);
|
||||
}
|
||||
|
||||
setTimeout(function() {
|
||||
const installedGrid = document.getElementById('installed-plugins-grid');
|
||||
if (installedGrid) {
|
||||
|
||||
@@ -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 %}
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-5">
|
||||
<div>
|
||||
<h3 class="text-md font-medium text-gray-900 mb-1">Screen Durations</h3>
|
||||
<p class="text-sm text-gray-600">How long each screen stays on before rotating to the next one, in seconds (5–600, default 30).</p>
|
||||
<p class="text-sm text-gray-600">How long each screen stays on before rotating to the next one, in seconds (5–600). Leave a screen blank to use its plugin's own duration, shown greyed out.</p>
|
||||
</div>
|
||||
{% for group in duration_groups %}
|
||||
<div>
|
||||
@@ -45,12 +45,13 @@
|
||||
{% for mode in group.modes %}
|
||||
<div class="form-group" id="setting-durations-{{ mode.key }}" data-setting-key="display.display_durations.{{ mode.key }}">
|
||||
<label for="duration__{{ mode.key }}" class="block text-sm font-medium text-gray-700">
|
||||
{{ mode.key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (mode.key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5–600. Currently ' ~ mode.value ~ 's.', mode.key | replace('_', ' ') | title) }}
|
||||
{{ mode.key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (mode.key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5–600.' ~ (' Blank uses the plugin\'s own ' ~ mode.default ~ 's.' if mode.default else ''), mode.key | replace('_', ' ') | title) }}
|
||||
</label>
|
||||
<input type="number"
|
||||
id="duration__{{ mode.key }}"
|
||||
name="duration__{{ mode.key }}"
|
||||
value="{{ mode.value }}"
|
||||
{% if mode.default %}placeholder="{{ mode.default }}"{% endif %}
|
||||
min="5"
|
||||
max="600"
|
||||
class="form-control">
|
||||
|
||||
@@ -152,7 +152,7 @@ function initializeFontsTab() {
|
||||
console.error('Fonts tab elements not found after max retries, giving up');
|
||||
return;
|
||||
}
|
||||
console.log('Fonts tab elements not found, retrying...', {
|
||||
debugLog('Fonts tab elements not found, retrying...', {
|
||||
availableFonts: !!availableEl,
|
||||
attempt: initRetryCount
|
||||
});
|
||||
@@ -178,7 +178,7 @@ function initializeFontsTab() {
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Initializing font management...');
|
||||
debugLog('Initializing font management...');
|
||||
initializeFontManagement();
|
||||
|
||||
// Event listeners (use event delegation or ensure elements exist)
|
||||
@@ -229,7 +229,7 @@ function initializeFontsTab() {
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Fonts tab initialized successfully');
|
||||
debugLog('Fonts tab initialized successfully');
|
||||
}
|
||||
|
||||
// Expose initializeFontsTab to window for re-initialization after HTMX reload
|
||||
@@ -244,7 +244,7 @@ window.initializeFontsTab = initializeFontsTab;
|
||||
const fontsContent = document.getElementById('fonts-content');
|
||||
|
||||
if (fontsContent) {
|
||||
console.log('Fonts content detected, initializing...');
|
||||
debugLog('Fonts content detected, initializing...');
|
||||
setTimeout(() => {
|
||||
initializeFontsTab();
|
||||
}, 50);
|
||||
@@ -257,7 +257,7 @@ window.initializeFontsTab = initializeFontsTab;
|
||||
// Check if the event target is the fonts-content container or contains it
|
||||
const target = event.target;
|
||||
if (target && (target.id === 'fonts-content' || target.querySelector && target.querySelector('#fonts-content'))) {
|
||||
console.log('HTMX loaded fonts content, initializing...', target.id);
|
||||
debugLog('HTMX loaded fonts content, initializing...', target.id);
|
||||
tryInitializeFontsTab();
|
||||
}
|
||||
});
|
||||
@@ -334,7 +334,7 @@ async function loadFontData() {
|
||||
// Update displays
|
||||
updateAvailableFontsDisplay();
|
||||
|
||||
console.log('Font data loaded successfully', {
|
||||
debugLog('Font data loaded successfully', {
|
||||
catalogSize: Object.keys(fontCatalog).length,
|
||||
tokensSize: Object.keys(fontTokens).length
|
||||
});
|
||||
@@ -490,7 +490,7 @@ function populateFontSelects() {
|
||||
previewSelect.value = fontEntries[0].filename;
|
||||
}
|
||||
|
||||
console.log(`Populated font selects with ${fontEntries.length} fonts`);
|
||||
debugLog(`Populated font selects with ${fontEntries.length} fonts`);
|
||||
}
|
||||
|
||||
async function updateFontPreview() {
|
||||
|
||||
@@ -104,6 +104,25 @@
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Connected to log stream</span>
|
||||
</div>
|
||||
|
||||
<!-- Plugin errors, as recorded by the display service -->
|
||||
<div id="plugin-errors-panel" class="mt-6 border-t border-gray-200 pt-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">Plugin errors</h3>
|
||||
<p id="plugin-errors-meta" class="text-xs text-gray-600">Recorded by the display service</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="plugin-errors-refresh-btn" type="button" class="btn bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm">
|
||||
<i class="fas fa-sync-alt mr-1"></i>Refresh
|
||||
</button>
|
||||
<button id="plugin-errors-clear-btn" type="button" class="btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm">
|
||||
<i class="fas fa-eraser mr-1"></i>Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="plugin-errors-body" class="text-sm text-gray-600" aria-live="polite">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -218,10 +237,18 @@ window._filteredLogs = [];
|
||||
// Current-plugin poll and the realtime log stream run only while the Logs
|
||||
// tab is active and the page is visible (both restart with an immediate
|
||||
// refresh). Re-registering on partial reload replaces the old one.
|
||||
const pluginErrorsRefreshBtn = document.getElementById('plugin-errors-refresh-btn');
|
||||
if (pluginErrorsRefreshBtn) pluginErrorsRefreshBtn.onclick = refreshPluginErrors;
|
||||
const pluginErrorsClearBtn = document.getElementById('plugin-errors-clear-btn');
|
||||
if (pluginErrorsClearBtn) pluginErrorsClearBtn.onclick = clearPluginErrors;
|
||||
|
||||
function startLogsActivity() {
|
||||
refreshCurrentPluginStatus();
|
||||
if (window._currentPluginPollTimer) clearInterval(window._currentPluginPollTimer);
|
||||
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
|
||||
refreshPluginErrors();
|
||||
if (window._pluginErrorsPollTimer) clearInterval(window._pluginErrorsPollTimer);
|
||||
window._pluginErrorsPollTimer = setInterval(refreshPluginErrors, 15000);
|
||||
if (window._isRealtime && !window._logsEventSource) setupRealtimeLogs();
|
||||
}
|
||||
function stopLogsActivity() {
|
||||
@@ -229,6 +256,10 @@ window._filteredLogs = [];
|
||||
clearInterval(window._currentPluginPollTimer);
|
||||
window._currentPluginPollTimer = null;
|
||||
}
|
||||
if (window._pluginErrorsPollTimer) {
|
||||
clearInterval(window._pluginErrorsPollTimer);
|
||||
window._pluginErrorsPollTimer = null;
|
||||
}
|
||||
stopRealtimeLogs();
|
||||
}
|
||||
if (window.LEDVisibility) {
|
||||
@@ -787,6 +818,120 @@ function refreshCurrentPluginStatus() {
|
||||
});
|
||||
}
|
||||
|
||||
// Plugin errors panel. The display service records them and publishes a
|
||||
// snapshot every few seconds; /api/v3/errors/summary serves that snapshot.
|
||||
// Plugin ids and messages come from plugins: everything goes through escapeHtml.
|
||||
function formatErrorTime(iso) {
|
||||
return iso ? String(iso).replace('T', ' ').slice(0, 19) : '';
|
||||
}
|
||||
|
||||
function renderPluginErrors(summary) {
|
||||
const body = document.getElementById('plugin-errors-body');
|
||||
const meta = document.getElementById('plugin-errors-meta');
|
||||
if (!body) return;
|
||||
|
||||
if (meta) {
|
||||
let text = 'Recorded by the display service';
|
||||
if (summary.generated_at) text += ' · updated ' + formatErrorTime(summary.generated_at);
|
||||
if (summary.clear_pending) text += ' · clearing…';
|
||||
meta.textContent = text;
|
||||
}
|
||||
|
||||
if (!summary.snapshot_available) {
|
||||
body.innerHTML = '<p class="text-gray-600"><i class="fas fa-hourglass-half mr-1"></i>' +
|
||||
'Display service hasn’t reported yet. Errors appear here once it is running.</p>';
|
||||
return;
|
||||
}
|
||||
const plugins = Object.entries(summary.plugin_error_counts || {}).map(([id, types]) => {
|
||||
const total = Object.values(types || {}).reduce((sum, n) => sum + (Number(n) || 0), 0);
|
||||
return { id, types: types || {}, total };
|
||||
}).sort((a, b) => b.total - a.total);
|
||||
const patterns = Object.values(summary.active_patterns || {});
|
||||
|
||||
if (!Number(summary.total_errors) && plugins.length === 0 && patterns.length === 0) {
|
||||
body.innerHTML = '<p class="text-gray-600"><i class="fas fa-check-circle text-green-600 mr-1"></i>' +
|
||||
'No plugin errors recorded</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
if (plugins.length) {
|
||||
html += '<div class="overflow-x-auto"><table class="min-w-full text-sm">' +
|
||||
'<thead><tr class="text-left text-xs uppercase text-gray-500 border-b border-gray-200">' +
|
||||
'<th class="py-1 pr-4 font-medium">Plugin</th><th class="py-1 pr-4 font-medium">Errors</th>' +
|
||||
'<th class="py-1 font-medium">Types</th></tr></thead><tbody>';
|
||||
plugins.forEach(p => {
|
||||
const types = Object.entries(p.types)
|
||||
.map(([type, n]) => `${escapeHtml(type)} ×${escapeHtml(String(Number(n) || 0))}`)
|
||||
.join(', ');
|
||||
html += '<tr class="border-b border-gray-100">' +
|
||||
`<td class="py-1 pr-4"><code class="bg-gray-100 px-1 rounded">${escapeHtml(p.id)}</code></td>` +
|
||||
`<td class="py-1 pr-4 font-semibold text-gray-900">${escapeHtml(String(p.total))}</td>` +
|
||||
`<td class="py-1 text-gray-700 break-words">${types}</td></tr>`;
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
if (patterns.length) {
|
||||
const badge = {
|
||||
critical: 'bg-red-100 text-red-800',
|
||||
error: 'bg-amber-100 text-amber-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800'
|
||||
};
|
||||
html += '<h4 class="mt-4 mb-2 text-sm font-semibold text-gray-900">Repeating errors</h4><ul class="list-none pl-0 space-y-2">';
|
||||
patterns.forEach(p => {
|
||||
const severity = String(p.severity || 'warning');
|
||||
const affected = (p.affected_plugins || []).map(id => escapeHtml(id)).join(', ') || 'unknown';
|
||||
const sample = (p.sample_messages || [])[0];
|
||||
html += '<li class="border border-gray-200 rounded-lg px-3 py-2">' +
|
||||
'<div class="flex flex-wrap items-center gap-2">' +
|
||||
`<span class="px-2 py-0.5 rounded text-xs font-semibold ${badge[severity] || badge.warning}">${escapeHtml(severity)}</span>` +
|
||||
`<code class="font-semibold text-gray-900">${escapeHtml(p.error_type || '')}</code>` +
|
||||
`<span class="text-gray-700">×${escapeHtml(String(Number(p.count) || 0))}</span>` +
|
||||
`<span class="text-xs text-gray-500 ml-auto">last seen ${escapeHtml(formatErrorTime(p.last_seen))}</span></div>` +
|
||||
`<div class="mt-1 text-xs text-gray-600">Plugins: ${affected}</div>` +
|
||||
(sample ? `<div class="mt-1 text-xs font-mono text-gray-700 break-words">${escapeHtml(sample)}</div>` : '') +
|
||||
'</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
function refreshPluginErrors() {
|
||||
fetch('/api/v3/errors/summary')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status !== 'success' || !data.data) throw new Error(data.message || 'Request failed');
|
||||
renderPluginErrors(data.data);
|
||||
})
|
||||
.catch(() => {
|
||||
const body = document.getElementById('plugin-errors-body');
|
||||
if (body) body.textContent = 'Could not load plugin errors.';
|
||||
});
|
||||
}
|
||||
|
||||
function clearPluginErrors() {
|
||||
if (!confirm('Clear all recorded plugin errors?')) return;
|
||||
fetch('/api/v3/errors/clear', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ all: true })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (typeof showNotification !== 'undefined') {
|
||||
showNotification(data.status === 'success' ? 'Plugin errors cleared'
|
||||
: (data.message || 'Could not clear plugin errors'),
|
||||
data.status === 'success' ? 'success' : 'error');
|
||||
}
|
||||
refreshPluginErrors();
|
||||
})
|
||||
.catch(() => {
|
||||
if (typeof showNotification !== 'undefined') showNotification('Could not clear plugin errors', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window._logsEventSource) {
|
||||
@@ -796,5 +941,9 @@ window.addEventListener('beforeunload', function() {
|
||||
clearInterval(window._currentPluginPollTimer);
|
||||
window._currentPluginPollTimer = null;
|
||||
}
|
||||
if (window._pluginErrorsPollTimer) {
|
||||
clearInterval(window._pluginErrorsPollTimer);
|
||||
window._pluginErrorsPollTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -286,11 +286,11 @@ function wifiSetup() {
|
||||
try {
|
||||
const response = await fetch('/api/v3/wifi/scan');
|
||||
const data = await response.json();
|
||||
console.log('WiFi scan response:', data); // Debug log
|
||||
debugLog('WiFi scan response:', data);
|
||||
if (data.status === 'success') {
|
||||
// Ensure data.data is an array
|
||||
const networksArray = Array.isArray(data.data) ? data.data : [];
|
||||
console.log('WiFi scan found networks:', networksArray.length); // Debug log
|
||||
debugLog('WiFi scan found networks:', networksArray.length);
|
||||
|
||||
// Set the networks array - Alpine.js will automatically update the select dropdown via x-for
|
||||
this.networks = networksArray;
|
||||
|
||||
Reference in New Issue
Block a user