chore: merge main into feat/update-banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-04-30 08:10:42 -04:00
19 changed files with 3896 additions and 293 deletions

View File

@@ -667,8 +667,20 @@ import threading as _threading
_reconciliation_lock = _threading.Lock()
def _run_startup_reconciliation() -> None:
"""Run state reconciliation in background to auto-repair missing plugins."""
global _reconciliation_done, _reconciliation_started
"""Run state reconciliation in background to auto-repair missing plugins.
Reconciliation runs exactly once per process lifetime, regardless of
whether every inconsistency could be auto-fixed. Previously, a failed
auto-repair (e.g. a config entry referencing a plugin that no longer
exists in the registry) would reset ``_reconciliation_started`` to False,
causing the ``@app.before_request`` hook to re-trigger reconciliation on
every single HTTP request — an infinite install-retry loop that pegged
the CPU and flooded the log. Unresolved issues are now left in place for
the user to address via the UI; the reconciler itself also caches
per-plugin unrecoverable failures internally so repeated reconcile calls
stay cheap.
"""
global _reconciliation_done
from src.logging_config import get_logger
_logger = get_logger('reconciliation')
@@ -684,18 +696,22 @@ def _run_startup_reconciliation() -> None:
result = reconciler.reconcile_state()
if result.inconsistencies_found:
_logger.info("[Reconciliation] %s", result.message)
if result.reconciliation_successful:
if result.inconsistencies_fixed:
plugin_manager.discover_plugins()
_reconciliation_done = True
else:
_logger.warning("[Reconciliation] Finished with unresolved issues, will retry")
with _reconciliation_lock:
_reconciliation_started = False
if result.inconsistencies_fixed:
plugin_manager.discover_plugins()
if not result.reconciliation_successful:
_logger.warning(
"[Reconciliation] Finished with %d unresolved issue(s); "
"will not retry automatically. Use the Plugin Store or the "
"manual 'Reconcile' action to resolve.",
len(result.inconsistencies_manual),
)
except Exception as e:
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
with _reconciliation_lock:
_reconciliation_started = False
finally:
# Always mark done — we do not want an unhandled exception (or an
# unresolved inconsistency) to cause the @before_request hook to
# retrigger reconciliation on every subsequent request.
_reconciliation_done = True
# Initialize health monitor and run reconciliation on first request
@app.before_request
@@ -710,4 +726,6 @@ def check_health_monitor():
_threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
# threaded=True is Flask's default since 1.0 but stated explicitly so that
# long-lived /api/v3/stream/* SSE connections don't starve other requests.
app.run(host='0.0.0.0', port=5000, debug=True, threaded=True)

View File

@@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify, Response, send_from_directory
import json
import os
import re
import socket
import sys
import subprocess
import time
@@ -9,7 +10,7 @@ import hashlib
import uuid
import logging
import threading
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Tuple, Dict, Any, Type
@@ -1106,6 +1107,290 @@ def save_raw_secrets_config():
status_code=500
)
# ---------------------------------------------------------------------------
# Backup & Restore
# ---------------------------------------------------------------------------
_BACKUP_FILENAME_RE = re.compile(r'^ledmatrix-backup-[A-Za-z0-9_-]+-\d{8}_\d{6}\.zip$')
def _backup_exports_dir() -> Path:
"""Directory where user-downloadable backup ZIPs are stored."""
d = PROJECT_ROOT / 'config' / 'backups' / 'exports'
d.mkdir(parents=True, exist_ok=True)
return d
def _is_safe_backup_filename(name: str) -> bool:
"""Allow-list filter for backup filenames used in download/delete."""
return bool(_BACKUP_FILENAME_RE.match(name))
@api_v3.route('/backup/preview', methods=['GET'])
def backup_preview():
"""Return a summary of what a new backup would include."""
try:
from src import backup_manager
preview = backup_manager.preview_backup_contents(PROJECT_ROOT)
return jsonify({'status': 'success', 'data': preview})
except Exception:
logger.exception("[Backup] preview failed")
return jsonify({'status': 'error', 'message': 'Failed to compute backup preview'}), 500
@api_v3.route('/backup/export', methods=['POST'])
def backup_export():
"""Create a new backup ZIP and return its filename."""
try:
from src import backup_manager
zip_path = backup_manager.create_backup(PROJECT_ROOT, output_dir=_backup_exports_dir())
return jsonify({
'status': 'success',
'filename': zip_path.name,
'size': zip_path.stat().st_size,
'created_at': datetime.now(timezone.utc).isoformat(),
})
except Exception:
logger.exception("[Backup] export failed")
return jsonify({'status': 'error', 'message': 'Failed to create backup'}), 500
@api_v3.route('/backup/list', methods=['GET'])
def backup_list():
"""List backup ZIPs currently stored on disk."""
try:
exports = _backup_exports_dir()
entries = []
for path in sorted(exports.glob('ledmatrix-backup-*.zip'), reverse=True):
if not _is_safe_backup_filename(path.name):
continue
stat = path.stat()
entries.append({
'filename': path.name,
'size': stat.st_size,
'created_at': datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(),
})
return jsonify({'status': 'success', 'data': entries})
except Exception:
logger.exception("[Backup] list failed")
return jsonify({'status': 'error', 'message': 'Failed to list backups'}), 500
@api_v3.route('/backup/download/<path:filename>', methods=['GET'])
def backup_download(filename):
"""Stream a previously-created backup ZIP to the browser."""
try:
if not _is_safe_backup_filename(filename):
return jsonify({'status': 'error', 'message': 'Invalid backup filename'}), 400
exports = _backup_exports_dir()
target = exports / filename
if not target.exists():
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
return send_from_directory(
str(exports),
filename,
as_attachment=True,
mimetype='application/zip',
)
except Exception:
logger.exception("[Backup] download failed")
return jsonify({'status': 'error', 'message': 'Failed to download backup'}), 500
@api_v3.route('/backup/<path:filename>', methods=['DELETE'])
def backup_delete(filename):
"""Delete a stored backup ZIP."""
try:
if not _is_safe_backup_filename(filename):
return jsonify({'status': 'error', 'message': 'Invalid backup filename'}), 400
target = _backup_exports_dir() / filename
if not target.exists():
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
target.unlink()
return jsonify({'status': 'success', 'message': f'Deleted {filename}'})
except Exception:
logger.exception("[Backup] delete failed")
return jsonify({'status': 'error', 'message': 'Failed to delete backup'}), 500
def _save_uploaded_backup_to_temp() -> Tuple[Optional[Path], Optional[Tuple[Response, int]]]:
"""Shared upload handler for validate/restore endpoints. Returns
``(temp_path, None)`` on success or ``(None, error_response)`` on failure.
The caller is responsible for deleting the returned temp file."""
import tempfile as _tempfile
if 'backup_file' not in request.files:
return None, (jsonify({'status': 'error', 'message': 'No backup file provided'}), 400)
upload = request.files['backup_file']
if not upload.filename:
return None, (jsonify({'status': 'error', 'message': 'No file selected'}), 400)
is_valid, err = validate_file_upload(
upload.filename,
max_size_mb=200,
allowed_extensions=['.zip'],
)
if not is_valid:
return None, (jsonify({'status': 'error', 'message': err}), 400)
fd, tmp_name = _tempfile.mkstemp(prefix='ledmatrix_upload_', suffix='.zip')
os.close(fd)
tmp_path = Path(tmp_name)
max_bytes = 200 * 1024 * 1024
try:
written = 0
with open(tmp_path, 'wb') as fh:
while True:
chunk = upload.stream.read(65536)
if not chunk:
break
written += len(chunk)
if written > max_bytes:
fh.close()
tmp_path.unlink(missing_ok=True)
return None, (jsonify({'status': 'error', 'message': 'Backup file exceeds 200 MB limit'}), 413)
fh.write(chunk)
except Exception:
tmp_path.unlink(missing_ok=True)
logger.exception("[Backup] Failed to save uploaded backup")
return None, (jsonify({'status': 'error', 'message': 'Failed to read uploaded file'}), 500)
return tmp_path, None
@api_v3.route('/backup/validate', methods=['POST'])
def backup_validate():
"""Inspect an uploaded backup without applying it."""
tmp_path, err = _save_uploaded_backup_to_temp()
if err is not None:
return err
try:
from src import backup_manager
ok, error, manifest = backup_manager.validate_backup(tmp_path)
if not ok:
return jsonify({'status': 'error', 'message': error}), 400
return jsonify({'status': 'success', 'data': manifest})
except Exception:
logger.exception("[Backup] validate failed")
return jsonify({'status': 'error', 'message': 'Failed to validate backup'}), 500
finally:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
@api_v3.route('/backup/restore', methods=['POST'])
def backup_restore():
"""
Restore an uploaded backup into the running installation.
The request is multipart/form-data with:
- ``backup_file``: the ZIP upload
- ``options``: JSON string with RestoreOptions fields (all boolean)
"""
tmp_path, err = _save_uploaded_backup_to_temp()
if err is not None:
return err
try:
from src import backup_manager
# Parse options (all optional; default is "restore everything").
raw_opts = request.form.get('options') or '{}'
try:
opts_dict = json.loads(raw_opts)
except json.JSONDecodeError:
return jsonify({'status': 'error', 'message': 'Invalid options JSON'}), 400
if not isinstance(opts_dict, dict):
return jsonify({'status': 'error', 'message': 'options must be an object'}), 400
opts = backup_manager.RestoreOptions(
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),
restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)),
restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)),
restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)),
restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)),
reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)),
)
# Snapshot current config through the atomic manager so the pre-restore
# state is recoverable via the existing rollback_config() path.
if api_v3.config_manager and opts.restore_config:
try:
current = api_v3.config_manager.load_config()
snapshot_ok, snapshot_err = _save_config_atomic(api_v3.config_manager, current, create_backup=True)
if not snapshot_ok:
logger.warning("[Backup] Pre-restore snapshot failed: %s (continuing)", snapshot_err)
except Exception:
logger.warning("[Backup] Pre-restore snapshot failed (continuing)", exc_info=True)
result = backup_manager.restore_backup(tmp_path, PROJECT_ROOT, opts)
# Reinstall plugins via the store manager, one at a time.
if opts.reinstall_plugins and api_v3.plugin_store_manager and result.plugins_to_install:
installed_names = set()
if api_v3.plugin_manager:
try:
existing = api_v3.plugin_manager.get_all_plugin_info() or []
installed_names = {p.get('id') for p in existing if p.get('id')}
except Exception:
installed_names = set()
for entry in result.plugins_to_install:
plugin_id = entry.get('plugin_id')
if not plugin_id:
continue
if plugin_id in installed_names:
result.plugins_installed.append(plugin_id)
continue
try:
ok = api_v3.plugin_store_manager.install_plugin(plugin_id)
if ok:
if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id)
if api_v3.plugin_manager:
api_v3.plugin_manager.discover_plugins()
api_v3.plugin_manager.load_plugin(plugin_id)
if api_v3.plugin_state_manager:
api_v3.plugin_state_manager.set_plugin_installed(plugin_id)
result.plugins_installed.append(plugin_id)
else:
result.plugins_failed.append({'plugin_id': plugin_id, 'error': 'install returned False'})
except Exception as install_err:
logger.exception("[Backup] plugin reinstall failed for %s", plugin_id)
result.plugins_failed.append({'plugin_id': plugin_id, 'error': str(install_err)})
# Clear font catalog cache so restored fonts show up.
if any(r.startswith("fonts") for r in result.restored):
try:
from web_interface.cache import delete_cached
delete_cached('fonts_catalog')
except Exception:
logger.warning("[Backup] Failed to clear font cache", exc_info=True)
# Reload config_manager state so the UI picks up the new values
# without a full service restart.
if api_v3.config_manager and opts.restore_config:
try:
api_v3.config_manager.load_config(force_reload=True)
except TypeError:
try:
api_v3.config_manager.load_config()
except Exception:
logger.warning("[Backup] Could not reload config after restore", exc_info=True)
except Exception:
logger.warning("[Backup] Could not reload config after restore", exc_info=True)
return jsonify({
'status': 'success' if result.success else 'partial',
'data': result.to_dict(),
})
except Exception:
logger.exception("[Backup] restore failed")
return jsonify({'status': 'error', 'message': 'Failed to restore backup'}), 500
finally:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
@api_v3.route('/system/status', methods=['GET'])
def get_system_status():
"""Get system status"""
@@ -1783,9 +2068,23 @@ def get_installed_plugins():
import json
from pathlib import Path
# Re-discover plugins to ensure we have the latest list
# This handles cases where plugins are added/removed after app startup
api_v3.plugin_manager.discover_plugins()
# Re-discover plugins only if the plugins directory has actually
# changed since our last scan, or if the caller explicitly asked
# for a refresh. The previous unconditional ``discover_plugins()``
# call (plus a per-plugin manifest re-read) made this endpoint
# O(plugins) in disk I/O on every page refresh, which on an SD-card
# Pi4 with ~15 plugins was pegging the CPU and blocking the UI
# "connecting to display" spinner for minutes.
force_refresh = request.args.get('refresh', '').lower() in ('1', 'true', 'yes')
plugins_dir_path = Path(api_v3.plugin_manager.plugins_dir)
try:
current_mtime = plugins_dir_path.stat().st_mtime if plugins_dir_path.exists() else 0
except OSError:
current_mtime = 0
last_mtime = getattr(api_v3, '_installed_plugins_dir_mtime', None)
if force_refresh or last_mtime != current_mtime:
api_v3.plugin_manager.discover_plugins()
api_v3._installed_plugins_dir_mtime = current_mtime
# Get all installed plugin info from the plugin manager
all_plugin_info = api_v3.plugin_manager.get_all_plugin_info()
@@ -1798,17 +2097,10 @@ def get_installed_plugins():
for plugin_info in all_plugin_info:
plugin_id = plugin_info.get('id')
# Re-read manifest from disk to ensure we have the latest metadata
manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
if manifest_path.exists():
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
fresh_manifest = json.load(f)
# Update plugin_info with fresh manifest data
plugin_info.update(fresh_manifest)
except Exception as e:
# If we can't read the fresh manifest, use the cached one
logger.warning("[PluginStore] Could not read fresh manifest for %s: %s", plugin_id, e)
# Note: we intentionally do NOT re-read manifest.json here.
# discover_plugins() above already reparses manifests on change;
# re-reading on every request added ~1 syscall+json.loads per
# plugin per request for no benefit.
# Get enabled status from config (source of truth)
# Read from config file first, fall back to plugin instance if config doesn't have the key
@@ -2438,14 +2730,30 @@ def reconcile_plugin_state():
from src.plugin_system.state_reconciliation import StateReconciliation
# Pass the store manager so auto-repair of missing-on-disk plugins
# can actually run. Previously this endpoint silently degraded to
# MANUAL_FIX_REQUIRED because store_manager was omitted.
reconciler = StateReconciliation(
state_manager=api_v3.plugin_state_manager,
config_manager=api_v3.config_manager,
plugin_manager=api_v3.plugin_manager,
plugins_dir=Path(api_v3.plugin_manager.plugins_dir)
plugins_dir=Path(api_v3.plugin_manager.plugins_dir),
store_manager=api_v3.plugin_store_manager,
)
result = reconciler.reconcile_state()
# Allow the caller to force a retry of previously-unrecoverable
# plugins (e.g. after the registry has been updated or a typo fixed).
# Non-object JSON bodies (e.g. a bare string or array) must fall
# through to the default False instead of raising AttributeError,
# and string booleans like "false" must coerce correctly — hence
# the isinstance guard plus _coerce_to_bool.
force = False
if request.is_json:
payload = request.get_json(silent=True)
if isinstance(payload, dict):
force = _coerce_to_bool(payload.get('force', False))
result = reconciler.reconcile_state(force=force)
return success_response(
data={
@@ -2868,6 +3176,181 @@ def update_plugin():
status_code=500
)
def _snapshot_plugin_config(plugin_id: str):
"""Capture the plugin's current config and secrets entries for rollback.
Returns a tuple ``(main_entry, secrets_entry)`` where each element is
the plugin's dict from the respective file, or ``None`` if the plugin
was not present there. Used by the transactional uninstall path so we
can restore state if file removal fails after config cleanup has
already succeeded.
"""
main_entry = None
secrets_entry = None
# Narrow exception list: filesystem errors (FileNotFoundError is a
# subclass of OSError, IOError is an alias for OSError in Python 3)
# and ConfigError, which is what ``get_raw_file_content`` wraps all
# load failures in. Programmer errors (TypeError, AttributeError,
# etc.) are intentionally NOT caught — they should surface loudly.
try:
main_config = api_v3.config_manager.get_raw_file_content('main')
if plugin_id in main_config:
import copy as _copy
main_entry = _copy.deepcopy(main_config[plugin_id])
except (OSError, ConfigError) as e:
logger.warning("[PluginUninstall] Could not snapshot main config for %s: %s", plugin_id, e)
try:
import os as _os
if _os.path.exists(api_v3.config_manager.secrets_path):
secrets_config = api_v3.config_manager.get_raw_file_content('secrets')
if plugin_id in secrets_config:
import copy as _copy
secrets_entry = _copy.deepcopy(secrets_config[plugin_id])
except (OSError, ConfigError) as e:
logger.warning("[PluginUninstall] Could not snapshot secrets for %s: %s", plugin_id, e)
return (main_entry, secrets_entry)
def _restore_plugin_config(plugin_id: str, snapshot) -> None:
"""Best-effort restoration of a snapshot taken by ``_snapshot_plugin_config``.
Called on the unhappy path when ``cleanup_plugin_config`` already
succeeded but the subsequent file removal failed. If the restore
itself fails, we log loudly — the caller still sees the original
uninstall error and the user can reconcile manually.
"""
main_entry, secrets_entry = snapshot
if main_entry is not None:
try:
main_config = api_v3.config_manager.get_raw_file_content('main')
main_config[plugin_id] = main_entry
api_v3.config_manager.save_raw_file_content('main', main_config)
logger.warning("[PluginUninstall] Restored main config entry for %s after uninstall failure", plugin_id)
except Exception as e:
logger.error(
"[PluginUninstall] FAILED to restore main config entry for %s after uninstall failure: %s",
plugin_id, e, exc_info=True,
)
if secrets_entry is not None:
try:
import os as _os
if _os.path.exists(api_v3.config_manager.secrets_path):
secrets_config = api_v3.config_manager.get_raw_file_content('secrets')
else:
secrets_config = {}
secrets_config[plugin_id] = secrets_entry
api_v3.config_manager.save_raw_file_content('secrets', secrets_config)
logger.warning("[PluginUninstall] Restored secrets entry for %s after uninstall failure", plugin_id)
except Exception as e:
logger.error(
"[PluginUninstall] FAILED to restore secrets entry for %s after uninstall failure: %s",
plugin_id, e, exc_info=True,
)
def _do_transactional_uninstall(plugin_id: str, preserve_config: bool) -> None:
"""Run the full uninstall as a best-effort transaction.
Order:
1. Mark tombstone (so any reconciler racing with us cannot resurrect
the plugin mid-flight).
2. Snapshot existing config + secrets entries (for rollback).
3. Run ``cleanup_plugin_config``. If this raises, re-raise — files
have NOT been touched, so aborting here leaves a fully consistent
state: plugin is still installed and still in config.
4. Unload the plugin from the running plugin manager.
5. Call ``store_manager.uninstall_plugin``. If it returns False or
raises, RESTORE the snapshot (so config matches disk) and then
propagate the failure.
6. Invalidate schema cache and remove from the state manager only
after the file removal succeeds.
Raises on any failure so the caller can return an error to the user.
"""
if hasattr(api_v3.plugin_store_manager, 'mark_recently_uninstalled'):
api_v3.plugin_store_manager.mark_recently_uninstalled(plugin_id)
snapshot = _snapshot_plugin_config(plugin_id) if not preserve_config else (None, None)
# Step 1: config cleanup. If this fails, bail out early — the plugin
# files on disk are still intact and the caller will get a clear
# error.
if not preserve_config:
try:
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
except Exception as cleanup_err:
logger.error(
"[PluginUninstall] Config cleanup failed for %s; aborting uninstall (files untouched): %s",
plugin_id, cleanup_err, exc_info=True,
)
raise
# Remember whether the plugin was loaded *before* we touched runtime
# state — we need this so we can reload it on rollback if file
# removal fails after we've already unloaded it.
was_loaded = bool(
api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins
)
def _rollback(reason_err):
"""Undo both the config cleanup AND the unload."""
if not preserve_config:
_restore_plugin_config(plugin_id, snapshot)
if was_loaded and api_v3.plugin_manager:
try:
api_v3.plugin_manager.load_plugin(plugin_id)
except Exception as reload_err:
logger.error(
"[PluginUninstall] FAILED to reload %s after uninstall rollback: %s",
plugin_id, reload_err, exc_info=True,
)
# Step 2: unload if loaded. Also part of the rollback boundary — if
# unload itself raises, restore config and surface the error.
if was_loaded:
try:
api_v3.plugin_manager.unload_plugin(plugin_id)
except Exception as unload_err:
logger.error(
"[PluginUninstall] unload_plugin raised for %s; restoring config snapshot: %s",
plugin_id, unload_err, exc_info=True,
)
if not preserve_config:
_restore_plugin_config(plugin_id, snapshot)
# Plugin was never successfully unloaded, so no reload is
# needed here — runtime state is still what it was before.
raise
# Step 3: remove files. If this fails, roll back the config cleanup
# AND reload the plugin so the user doesn't end up with an orphaned
# install (files on disk + no config entry + plugin no longer
# loaded at runtime).
try:
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
except Exception as uninstall_err:
logger.error(
"[PluginUninstall] uninstall_plugin raised for %s; rolling back: %s",
plugin_id, uninstall_err, exc_info=True,
)
_rollback(uninstall_err)
raise
if not success:
logger.error(
"[PluginUninstall] uninstall_plugin returned False for %s; rolling back",
plugin_id,
)
_rollback(None)
raise RuntimeError(f"Failed to uninstall plugin {plugin_id}")
# Past this point the filesystem and config are both in the
# "uninstalled" state. Clean up the cheap in-memory bookkeeping.
if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id)
if api_v3.plugin_state_manager:
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
@api_v3.route('/plugins/uninstall', methods=['POST'])
def uninstall_plugin():
"""Uninstall plugin"""
@@ -2890,49 +3373,28 @@ def uninstall_plugin():
# Use operation queue if available
if api_v3.operation_queue:
def uninstall_callback(operation):
"""Callback to execute plugin uninstallation."""
# Unload the plugin first if it's loaded
if api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins:
api_v3.plugin_manager.unload_plugin(plugin_id)
# Uninstall the plugin
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
if not success:
error_msg = f'Failed to uninstall plugin {plugin_id}'
"""Callback to execute plugin uninstallation transactionally."""
try:
_do_transactional_uninstall(plugin_id, preserve_config)
except Exception as err:
error_msg = f'Failed to uninstall plugin {plugin_id}: {err}'
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"uninstall",
plugin_id=plugin_id,
status="failed",
error=error_msg
error=error_msg,
)
raise Exception(error_msg)
# Re-raise so the operation_queue marks this op as failed.
raise
# Invalidate schema cache
if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id)
# Clean up plugin configuration if not preserving
if not preserve_config:
try:
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
except Exception as cleanup_err:
logger.warning("[PluginUninstall] Failed to cleanup config for %s: %s", plugin_id, cleanup_err)
# Remove from state manager
if api_v3.plugin_state_manager:
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
# Record in history
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"uninstall",
plugin_id=plugin_id,
status="success",
details={"preserve_config": preserve_config}
details={"preserve_config": preserve_config},
)
return {'success': True, 'message': f'Plugin {plugin_id} uninstalled successfully'}
# Enqueue operation
@@ -2947,55 +3409,32 @@ def uninstall_plugin():
message=f'Plugin {plugin_id} uninstallation queued'
)
else:
# Fallback to direct uninstall
# Unload the plugin first if it's loaded
if api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins:
api_v3.plugin_manager.unload_plugin(plugin_id)
# Uninstall the plugin
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
if success:
# Invalidate schema cache
if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id)
# Clean up plugin configuration if not preserving
if not preserve_config:
try:
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
except Exception as cleanup_err:
logger.warning("[PluginUninstall] Failed to cleanup config for %s: %s", plugin_id, cleanup_err)
# Remove from state manager
if api_v3.plugin_state_manager:
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
# Record in history
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"uninstall",
plugin_id=plugin_id,
status="success",
details={"preserve_config": preserve_config}
)
return success_response(message=f'Plugin {plugin_id} uninstalled successfully')
else:
# Fallback to direct uninstall — same transactional helper.
try:
_do_transactional_uninstall(plugin_id, preserve_config)
except Exception as err:
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"uninstall",
plugin_id=plugin_id,
status="failed",
error=f'Failed to uninstall plugin {plugin_id}'
error=f'Failed to uninstall plugin {plugin_id}: {err}',
)
return error_response(
ErrorCode.PLUGIN_UNINSTALL_FAILED,
f'Failed to uninstall plugin {plugin_id}',
status_code=500
f'Failed to uninstall plugin {plugin_id}: {err}',
status_code=500,
)
if api_v3.operation_history:
api_v3.operation_history.record_operation(
"uninstall",
plugin_id=plugin_id,
status="success",
details={"preserve_config": preserve_config},
)
return success_response(message=f'Plugin {plugin_id} uninstalled successfully')
except Exception as e:
logger.exception("[PluginUninstall] Unhandled exception")
from src.web_interface.errors import WebInterfaceError
@@ -6280,18 +6719,146 @@ def upload_calendar_credentials():
logger.exception("[PluginConfig] upload_calendar_credentials failed")
return jsonify({'status': 'error', 'message': 'Failed to upload calendar credentials'}), 500
def _load_calendar_plugin_dir():
"""Resolve the calendar plugin's on-disk directory without requiring a running instance.
The web service and display service are separate processes — the web
process discovers plugins but does not instantiate them, so
plugin_manager.get_plugin('calendar') is typically None here.
"""
plugin_id = 'calendar'
if api_v3.plugin_manager:
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
if plugin_dir and Path(plugin_dir).exists():
return Path(plugin_dir)
fallback = PROJECT_ROOT / 'plugins' / plugin_id
return fallback if fallback.exists() else None
_GOOGLE_API_TIMEOUT_SECONDS = 15
def _load_calendar_credentials(token_path):
"""Load OAuth credentials from the plugin's token file.
The calendar plugin historically persists credentials with pickle
(``token.pickle``). pickle.load is only applied to this specific file,
which is owned by the same user as the web service, chmod 0600, and
located inside the plugin install directory — it is not user-supplied
input. We still constrain the unpickle to a reasonable size to reduce
blast radius. New installs may use a JSON token (``token.json``)
written via google-auth's safe serializer; prefer that when present.
"""
json_path = token_path.with_suffix('.json')
if json_path.exists():
from google.oauth2.credentials import Credentials
return Credentials.from_authorized_user_file(str(json_path))
# Fall back to the pickle token the plugin writes today.
# nosemgrep: python.lang.security.audit.avoid-pickle.avoid-pickle
import pickle # noqa: S403
try:
size = token_path.stat().st_size
except OSError as e:
raise RuntimeError(f'Cannot stat token file: {e}') from e
if size > 64 * 1024:
raise RuntimeError('Token file is unexpectedly large; refusing to load.')
with open(token_path, 'rb') as f:
return pickle.load(f) # noqa: S301 # trusted file, owner-only perms
def _list_google_calendars_from_disk():
"""List calendars using the plugin's stored OAuth token.
Returns (calendars, error_message). calendars is a list of raw Google
calendarList items on success; on failure calendars is None and
error_message describes the problem.
Refreshed credentials are intentionally not persisted back to disk
from this request path — the display service owns token.pickle and
concurrent writes across processes could corrupt it. If refresh is
needed, it happens only in memory for the duration of this request.
"""
try:
import google_auth_httplib2
import httplib2
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
except ImportError:
return None, 'Google API libraries not installed on this host.'
plugin_dir = _load_calendar_plugin_dir()
if plugin_dir is None:
return None, 'Calendar plugin directory not found.'
token_path = plugin_dir / 'token.pickle'
if not token_path.exists() and not (plugin_dir / 'token.json').exists():
return None, 'Not authenticated yet — complete the Google authentication step first.'
try:
creds = _load_calendar_credentials(token_path)
except Exception as e:
logger.exception('list_calendar_calendars: failed to load stored credentials')
return None, f'Failed to load stored authentication: {e}'
if not creds or not getattr(creds, 'valid', False):
if creds and getattr(creds, 'expired', False) and getattr(creds, 'refresh_token', None):
try:
# In-memory refresh only; do not write back to shared token file.
creds.refresh(Request(timeout=_GOOGLE_API_TIMEOUT_SECONDS))
except (socket.timeout, TimeoutError) as e:
logger.warning('list_calendar_calendars: token refresh timed out: %s', e)
return None, 'Token refresh timed out. Please try again.'
except Exception as e:
logger.exception('list_calendar_calendars: token refresh failed')
return None, f'Stored authentication expired and refresh failed: {e}. Re-run the Google authentication step.'
else:
return None, 'Stored authentication is invalid. Re-run the Google authentication step.'
try:
# Build an Http with an explicit socket timeout so API calls cannot
# hang the Flask worker on flaky connectivity.
authed_http = google_auth_httplib2.AuthorizedHttp(
creds, http=httplib2.Http(timeout=_GOOGLE_API_TIMEOUT_SECONDS)
)
service = build('calendar', 'v3', http=authed_http, cache_discovery=False)
items = []
page_token = None
while True:
response = service.calendarList().list(pageToken=page_token).execute(
num_retries=1
)
items.extend(response.get('items', []))
page_token = response.get('nextPageToken')
if not page_token:
break
return items, None
except (socket.timeout, TimeoutError) as e:
logger.warning('list_calendar_calendars: Google API call timed out: %s', e)
return None, 'Google Calendar request timed out. Please try again.'
except Exception as e:
logger.exception('list_calendar_calendars: Google API call failed')
return None, f'Google Calendar API call failed: {e}'
@api_v3.route('/plugins/calendar/list-calendars', methods=['GET'])
def list_calendar_calendars():
"""Return Google Calendars accessible with the currently authenticated credentials."""
if not api_v3.plugin_manager:
return jsonify({'status': 'error', 'message': 'Plugin manager not available'}), 500
plugin = api_v3.plugin_manager.get_plugin('calendar')
if not plugin:
return jsonify({'status': 'error', 'message': 'Calendar plugin is not running. Enable it and save config first.'}), 404
if not hasattr(plugin, 'get_calendars'):
return jsonify({'status': 'error', 'message': 'Installed plugin version does not support calendar listing — update the plugin.'}), 400
"""Return Google Calendars accessible with the currently authenticated credentials.
Reads credentials from the plugin directory directly so this works from the
web process (which does not instantiate plugins).
"""
# Prefer a live plugin instance if one happens to exist (e.g. local dev where
# web and display share a process); otherwise fall back to on-disk credentials.
plugin = api_v3.plugin_manager.get_plugin('calendar') if api_v3.plugin_manager else None
try:
raw = plugin.get_calendars()
if plugin is not None and hasattr(plugin, 'get_calendars'):
raw = plugin.get_calendars()
else:
raw, err = _list_google_calendars_from_disk()
if raw is None:
return jsonify({'status': 'error', 'message': err}), 400
import collections.abc
if not isinstance(raw, (list, tuple)):
logger.error('list_calendar_calendars: get_calendars() returned non-sequence type %r', type(raw))

View File

@@ -76,6 +76,8 @@ def load_partial(partial_name):
return _load_logs_partial()
elif partial_name == 'raw-json':
return _load_raw_json_partial()
elif partial_name == 'backup-restore':
return _load_backup_restore_partial()
elif partial_name == 'wifi':
return _load_wifi_partial()
elif partial_name == 'cache':
@@ -296,6 +298,13 @@ def _load_raw_json_partial():
except Exception as e:
return f"Error: {str(e)}", 500
def _load_backup_restore_partial():
"""Load backup & restore partial."""
try:
return render_template('v3/partials/backup_restore.html')
except Exception as e:
return f"Error: {str(e)}", 500
@pages_v3.route('/setup')
def captive_setup():
"""Lightweight captive portal setup page — self-contained, no frameworks."""

View File

@@ -120,7 +120,11 @@ def main():
# Run the web server with error handling for client disconnections
try:
app.run(host='0.0.0.0', port=5000, debug=False)
# threaded=True is Flask's default since 1.0, but set it explicitly
# so it's self-documenting: the two /api/v3/stream/* SSE endpoints
# hold long-lived connections and would starve other requests under
# a single-threaded server.
app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)
except (OSError, BrokenPipeError) as e:
# Suppress non-critical socket errors (client disconnections)
if isinstance(e, OSError) and e.errno in (113, 32, 104): # No route to host, Broken pipe, Connection reset

View File

@@ -221,6 +221,7 @@
}
notifyFn(`Upload error: ${error.message}`, 'error');
} finally {
const fileInput = document.getElementById(`${fieldId}_file_input`);
if (fileInput) fileInput.value = '';
}
};

View File

@@ -7161,6 +7161,13 @@ window.getSchemaProperty = getSchemaProperty;
window.escapeHtml = escapeHtml;
window.escapeAttribute = escapeAttribute;
// Expose GitHub install handlers. These must be assigned inside the IIFE —
// from outside the IIFE, `typeof attachInstallButtonHandler` evaluates to
// 'undefined' and the fallback path at the bottom of this file fires a
// [FALLBACK] attachInstallButtonHandler not available on window warning.
window.attachInstallButtonHandler = attachInstallButtonHandler;
window.setupGitHubInstallHandlers = setupGitHubInstallHandlers;
})(); // End IIFE
// Functions to handle array-of-objects
@@ -7390,16 +7397,8 @@ if (typeof loadInstalledPlugins !== 'undefined') {
if (typeof renderInstalledPlugins !== 'undefined') {
window.renderInstalledPlugins = renderInstalledPlugins;
}
// Expose GitHub install handlers for debugging and manual testing
if (typeof setupGitHubInstallHandlers !== 'undefined') {
window.setupGitHubInstallHandlers = setupGitHubInstallHandlers;
console.log('[GLOBAL] setupGitHubInstallHandlers exposed to window');
}
if (typeof attachInstallButtonHandler !== 'undefined') {
window.attachInstallButtonHandler = attachInstallButtonHandler;
console.log('[GLOBAL] attachInstallButtonHandler exposed to window');
}
// searchPluginStore is now exposed inside the IIFE after its definition
// 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) {

View File

@@ -786,56 +786,25 @@
})();
</script>
<!-- Alpine.js for reactive components -->
<!-- Use local file when in AP mode (192.168.4.x) to avoid CDN dependency -->
<!-- Alpine.js for reactive components.
Load the local copy first (always works, no CDN round-trip, no AP-mode
branch needed). `defer` on an HTML-parsed <script> is honored and runs
after DOM parse but before DOMContentLoaded, which is exactly what
Alpine wants — so no deferLoadingAlpine gymnastics are needed.
The inline rescue below only fires if the local file is missing. -->
<script defer src="{{ url_for('static', filename='v3/js/alpinejs.min.js') }}"></script>
<script>
(function() {
// Prevent Alpine from auto-initializing by setting deferLoadingAlpine before it loads
window.deferLoadingAlpine = function(callback) {
// Wait for DOM to be ready
function waitForReady() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', waitForReady);
return;
}
// app() is already defined in head, so we can initialize Alpine
if (callback && typeof callback === 'function') {
callback();
} else if (window.Alpine && typeof window.Alpine.start === 'function') {
// If callback not provided but Alpine is available, start it
try {
window.Alpine.start();
} catch (e) {
// Alpine may already be initialized, ignore
console.warn('Alpine start error (may already be initialized):', e);
}
}
}
waitForReady();
};
// Detect AP mode by IP address
const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.');
const alpineSrc = isAPMode ? '/static/v3/js/alpinejs.min.js' : 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js';
const alpineFallback = isAPMode ? 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js' : '/static/v3/js/alpinejs.min.js';
const script = document.createElement('script');
script.defer = true;
script.src = alpineSrc;
script.onerror = function() {
if (alpineSrc !== alpineFallback) {
const fallback = document.createElement('script');
fallback.defer = true;
fallback.src = alpineFallback;
document.head.appendChild(fallback);
}
};
document.head.appendChild(script);
})();
// Rescue: if the local Alpine didn't load for any reason, pull the CDN
// copy once on window load. This is a last-ditch fallback, not the
// primary path.
window.addEventListener('load', function() {
if (typeof window.Alpine === 'undefined') {
console.warn('[Alpine] Local file failed to load, falling back to CDN');
const s = document.createElement('script');
s.src = 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js';
document.head.appendChild(s);
}
});
</script>
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
@@ -995,6 +964,11 @@
class="nav-tab">
<i class="fas fa-file-code"></i>Config Editor
</button>
<button @click="activeTab = 'backup-restore'"
:class="activeTab === 'backup-restore' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-save"></i>Backup &amp; Restore
</button>
<button @click="activeTab = 'fonts'"
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
class="nav-tab">
@@ -1197,6 +1171,18 @@
</div>
</div>
<!-- Backup & Restore tab -->
<div x-show="activeTab === 'backup-restore'" x-transition>
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="revealed" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-32 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
<!-- Config Editor tab -->
<div x-show="activeTab === 'config-editor'" x-transition>
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="revealed" hx-swap="innerHTML">

View File

@@ -0,0 +1,358 @@
<div class="space-y-6" id="backup-restore-root">
<!-- Security warning -->
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-red-600"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Backup files contain secrets in plaintext</h3>
<div class="mt-1 text-sm text-red-700">
Your API keys (weather, Spotify, YouTube, GitHub, etc.) and any saved WiFi passwords
are stored inside the backup ZIP as plain text. Treat the file like a password —
store it somewhere private and delete it when you no longer need it.
</div>
</div>
</div>
</div>
<!-- Export card -->
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">Export backup</h2>
<p class="mt-1 text-sm text-gray-600">
Download a single ZIP with all of your settings so you can restore it later.
</p>
</div>
<button onclick="exportBackup()" id="export-backup-btn"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
<i class="fas fa-download mr-2"></i>
Download backup
</button>
</div>
</div>
<div id="export-preview" class="text-sm text-gray-600">
<div class="animate-pulse">Loading summary…</div>
</div>
</div>
<!-- Restore card -->
<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">Restore from backup</h2>
<p class="mt-1 text-sm text-gray-600">
Upload a backup ZIP exported from this or another LEDMatrix install.
You'll see a summary before anything is written to disk.
</p>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Backup file</label>
<input type="file" id="restore-file-input" accept=".zip"
class="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
</div>
<div>
<button onclick="validateRestoreFile()" id="validate-restore-btn"
class="inline-flex items-center px-3 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-check-circle mr-2"></i>
Inspect file
</button>
</div>
<div id="restore-preview" class="hidden bg-gray-50 border border-gray-200 rounded-md p-4">
<h3 class="text-sm font-medium text-gray-900 mb-2">Backup contents</h3>
<dl id="restore-preview-body" class="text-sm text-gray-700 space-y-1"></dl>
<h3 class="text-sm font-medium text-gray-900 mt-4 mb-2">Choose what to restore</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-gray-700">
<label class="flex items-center gap-2"><input type="checkbox" id="opt-config" checked> <span>Main configuration</span></label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-secrets" checked> <span>API keys (secrets)</span></label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-wifi" checked> <span>WiFi configuration</span></label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-fonts" checked> <span>User-uploaded fonts</span></label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-plugin-uploads" checked> <span>Plugin image uploads</span></label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-reinstall" checked> <span>Reinstall missing plugins</span></label>
</div>
<div class="mt-4 flex gap-2">
<button onclick="runRestore()" id="run-restore-btn"
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-upload mr-2"></i>
Restore now
</button>
<button onclick="clearRestore()"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
</div>
</div>
<div id="restore-result" class="hidden"></div>
</div>
</div>
<!-- History card -->
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">Backup history</h2>
<p class="mt-1 text-sm text-gray-600">Previously exported backups stored on this device.</p>
</div>
<button onclick="loadBackupList()"
class="inline-flex items-center px-3 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-sync-alt mr-2"></i>
Refresh
</button>
</div>
</div>
<div id="backup-history" class="text-sm text-gray-600">Loading…</div>
</div>
</div>
<script>
(function () {
let inspectedFile = null;
function notify(message, kind) {
if (typeof showNotification === 'function') {
showNotification(message, kind || 'info');
} else {
console.log('[backup]', kind || 'info', message);
}
}
function formatSize(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0, size = bytes;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
}
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
async function loadPreview() {
const el = document.getElementById('export-preview');
try {
const res = await fetch('/api/v3/backup/preview');
const payload = await res.json();
if (payload.status !== 'success') throw new Error(payload.message || 'Preview failed');
const d = payload.data || {};
el.innerHTML = `
<ul class="list-disc pl-5 space-y-1">
<li>Main config: <strong>${d.has_config ? 'yes' : 'no'}</strong></li>
<li>Secrets: <strong>${d.has_secrets ? 'yes' : 'no'}</strong></li>
<li>WiFi config: <strong>${d.has_wifi ? 'yes' : 'no'}</strong></li>
<li>User fonts: <strong>${(d.user_fonts || []).length}</strong> ${d.user_fonts && d.user_fonts.length ? '(' + d.user_fonts.map(escapeHtml).join(', ') + ')' : ''}</li>
<li>Plugin image uploads: <strong>${d.plugin_uploads || 0}</strong> file(s)</li>
<li>Installed plugins: <strong>${(d.plugins || []).length}</strong></li>
</ul>`;
} catch (err) {
el.textContent = 'Could not load preview: ' + err.message;
}
}
async function loadBackupList() {
const el = document.getElementById('backup-history');
el.textContent = 'Loading…';
try {
const res = await fetch('/api/v3/backup/list');
const payload = await res.json();
if (payload.status !== 'success') throw new Error(payload.message || 'List failed');
const entries = payload.data || [];
if (!entries.length) {
el.innerHTML = '<p>No backups have been created yet.</p>';
return;
}
el.innerHTML = `
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="text-left py-2">Filename</th>
<th class="text-left py-2">Size</th>
<th class="text-left py-2">Created</th>
<th></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
${entries.map(e => `
<tr>
<td class="py-2 font-mono text-xs">${escapeHtml(e.filename)}</td>
<td class="py-2">${formatSize(e.size)}</td>
<td class="py-2">${escapeHtml(e.created_at)}</td>
<td class="py-2 text-right space-x-2">
<a href="/api/v3/backup/download/${encodeURIComponent(e.filename)}"
class="text-blue-600 hover:underline">Download</a>
<button data-filename="${escapeHtml(e.filename)}"
class="text-red-600 hover:underline backup-delete-btn">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>`;
el.querySelectorAll('.backup-delete-btn').forEach(btn => {
btn.addEventListener('click', () => deleteBackup(btn.dataset.filename));
});
} catch (err) {
el.textContent = 'Could not load backups: ' + err.message;
}
}
async function exportBackup() {
const btn = document.getElementById('export-backup-btn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Creating…';
try {
const res = await fetch('/api/v3/backup/export', { method: 'POST' });
const payload = await res.json();
if (payload.status !== 'success') throw new Error(payload.message || 'Export failed');
notify('Backup created: ' + payload.filename, 'success');
// Trigger browser download immediately.
window.location.href = '/api/v3/backup/download/' + encodeURIComponent(payload.filename);
await loadBackupList();
} catch (err) {
notify('Export failed: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-download mr-2"></i>Download backup';
}
}
async function deleteBackup(filename) {
if (!confirm('Delete ' + filename + '?')) return;
try {
const res = await fetch('/api/v3/backup/' + encodeURIComponent(filename), { method: 'DELETE' });
const payload = await res.json();
if (payload.status !== 'success') throw new Error(payload.message || 'Delete failed');
notify('Backup deleted', 'success');
await loadBackupList();
} catch (err) {
notify('Delete failed: ' + err.message, 'error');
}
}
async function validateRestoreFile() {
const input = document.getElementById('restore-file-input');
if (!input.files || !input.files[0]) {
notify('Choose a backup file first', 'error');
return;
}
const file = input.files[0];
const fd = new FormData();
fd.append('backup_file', file);
try {
const res = await fetch('/api/v3/backup/validate', { method: 'POST', body: fd });
const payload = await res.json();
if (payload.status !== 'success') throw new Error(payload.message || 'Validation failed');
inspectedFile = file;
renderRestorePreview(payload.data);
} catch (err) {
notify('Invalid backup: ' + err.message, 'error');
}
}
function renderRestorePreview(manifest) {
const wrap = document.getElementById('restore-preview');
const body = document.getElementById('restore-preview-body');
const detected = manifest.detected_contents || [];
const plugins = manifest.plugins || [];
body.innerHTML = `
<div><strong>Created:</strong> ${escapeHtml(manifest.created_at || 'unknown')}</div>
<div><strong>Source host:</strong> ${escapeHtml(manifest.hostname || 'unknown')}</div>
<div><strong>LEDMatrix version:</strong> ${escapeHtml(manifest.ledmatrix_version || 'unknown')}</div>
<div><strong>Includes:</strong> ${detected.length ? detected.map(escapeHtml).join(', ') : '(nothing detected)'}</div>
<div><strong>Plugins referenced:</strong> ${plugins.length ? plugins.map(p => escapeHtml(p.plugin_id)).join(', ') : 'none'}</div>
`;
wrap.classList.remove('hidden');
}
function clearRestore() {
inspectedFile = null;
document.getElementById('restore-preview').classList.add('hidden');
document.getElementById('restore-result').classList.add('hidden');
document.getElementById('restore-file-input').value = '';
}
async function runRestore() {
if (!inspectedFile) {
notify('Inspect the file before restoring', 'error');
return;
}
if (!confirm('Restore from this backup? Current configuration will be overwritten.')) return;
const options = {
restore_config: document.getElementById('opt-config').checked,
restore_secrets: document.getElementById('opt-secrets').checked,
restore_wifi: document.getElementById('opt-wifi').checked,
restore_fonts: document.getElementById('opt-fonts').checked,
restore_plugin_uploads: document.getElementById('opt-plugin-uploads').checked,
reinstall_plugins: document.getElementById('opt-reinstall').checked,
};
const fd = new FormData();
fd.append('backup_file', inspectedFile);
fd.append('options', JSON.stringify(options));
const btn = document.getElementById('run-restore-btn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Restoring…';
try {
const res = await fetch('/api/v3/backup/restore', { method: 'POST', body: fd });
const payload = await res.json();
if (payload.status !== 'success') {
const msgs = (payload.data?.errors || []).join('; ');
throw new Error(payload.message || msgs || 'Restore had errors');
}
const data = payload.data || {};
const hasPartial = (data.plugins_failed || []).length > 0 || (data.errors || []).length > 0;
const result = document.getElementById('restore-result');
result.className = (hasPartial
? 'bg-yellow-50 border-yellow-200 text-yellow-800'
: 'bg-green-50 border-green-200 text-green-800') + ' border rounded-md p-4';
result.classList.remove('hidden');
result.innerHTML = `
<h3 class="font-medium mb-2">${hasPartial ? 'Restore complete with warnings' : 'Restore complete'}</h3>
<div><strong>Restored:</strong> ${(data.restored || []).map(escapeHtml).join(', ') || 'none'}</div>
<div><strong>Skipped:</strong> ${(data.skipped || []).map(escapeHtml).join(', ') || 'none'}</div>
<div><strong>Plugins installed:</strong> ${(data.plugins_installed || []).map(escapeHtml).join(', ') || 'none'}</div>
<div><strong>Plugins failed:</strong> ${(data.plugins_failed || []).map(p => escapeHtml(p.plugin_id + ' (' + p.error + ')')).join(', ') || 'none'}</div>
<div><strong>Errors:</strong> ${(data.errors || []).map(escapeHtml).join('; ') || 'none'}</div>
${((data.restored || []).length || (data.plugins_installed || []).length) ? '<p class="mt-2">Restart the display service to apply all changes.</p>' : ''}
`;
notify(hasPartial ? 'Restore complete with warnings' : 'Restore complete', hasPartial ? 'warning' : 'success');
} catch (err) {
notify('Restore failed: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-upload mr-2"></i>Restore now';
}
}
// Expose handlers to inline onclick attributes.
window.exportBackup = exportBackup;
window.loadBackupList = loadBackupList;
window.validateRestoreFile = validateRestoreFile;
window.clearRestore = clearRestore;
window.runRestore = runRestore;
// Clear inspection state whenever the user picks a new file.
document.getElementById('restore-file-input').addEventListener('change', function () {
inspectedFile = null;
document.getElementById('restore-preview').classList.add('hidden');
document.getElementById('restore-result').classList.add('hidden');
});
// Initial load.
loadPreview();
loadBackupList();
})();
</script>