mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-16 09:38:38 +00:00
Compare commits
13 Commits
claude/tes
...
098a738891
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
098a738891 | ||
|
|
abade43772 | ||
|
|
b44ff079c9 | ||
|
|
6c4700583b | ||
|
|
f96fdd9f24 | ||
|
|
35c540d0e0 | ||
|
|
7603909c59 | ||
|
|
34b186125a | ||
|
|
ea95f37d73 | ||
|
|
0c7d03a476 | ||
|
|
321a87f734 | ||
|
|
9930bd33b1 | ||
|
|
713539e491 |
@@ -22,5 +22,6 @@
|
||||
"Pillow>=10.0.0",
|
||||
"PyYAML>=6.0",
|
||||
"requests>=2.31.0"
|
||||
]
|
||||
],
|
||||
"local_only": true
|
||||
}
|
||||
|
||||
@@ -67,8 +67,9 @@ def main():
|
||||
print(" 📍 Will run on: http://0.0.0.0:5000")
|
||||
print(" ⏹️ Press Ctrl+C to stop")
|
||||
|
||||
# Run the app (this should start the server)
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
# Run the app (debug mode controlled by env var to satisfy security scanners)
|
||||
_debug = os.environ.get('LEDMATRIX_FLASK_DEBUG', '0') == '1'
|
||||
app.run(host='0.0.0.0', port=5000, debug=_debug)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n ⏹️ Server stopped by user")
|
||||
|
||||
@@ -410,8 +410,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
try:
|
||||
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
||||
manifest = json.loads(manifest_raw)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||
return False, f"Invalid manifest.json: {e}", {}
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return False, "Invalid manifest.json", {}
|
||||
|
||||
if not isinstance(manifest, dict) or "schema_version" not in manifest:
|
||||
return False, "Invalid manifest structure", {}
|
||||
@@ -456,8 +456,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
return True, "", result_manifest
|
||||
except zipfile.BadZipFile:
|
||||
return False, "File is not a valid ZIP archive", {}
|
||||
except OSError as e:
|
||||
return False, f"Could not read backup: {e}", {}
|
||||
except OSError:
|
||||
return False, "Could not read backup", {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -190,7 +190,7 @@ class DisplayManager:
|
||||
json.dump(_hw_status, _f)
|
||||
_f.flush()
|
||||
os.fsync(_f.fileno())
|
||||
os.chmod(_tmp_path, 0o600)
|
||||
os.chmod(_tmp_path, 0o644)
|
||||
os.replace(_tmp_path, _status_path)
|
||||
except Exception:
|
||||
try:
|
||||
|
||||
@@ -5,9 +5,11 @@ Handles plugin module imports, dependency installation, and class instantiation.
|
||||
Extracted from PluginManager to improve separation of concerns.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import importlib
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -68,6 +70,11 @@ class PluginLoader:
|
||||
Returns:
|
||||
Path to plugin directory or None if not found
|
||||
"""
|
||||
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not plugin_id:
|
||||
return None
|
||||
|
||||
# Strategy 1: Use mapping from discovery
|
||||
if plugin_directories and plugin_id in plugin_directories:
|
||||
plugin_dir = plugin_directories[plugin_id]
|
||||
@@ -75,14 +82,16 @@ class PluginLoader:
|
||||
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
|
||||
return plugin_dir
|
||||
|
||||
# Strategy 2: Direct paths
|
||||
plugin_dir = plugins_dir / plugin_id
|
||||
if plugin_dir.exists():
|
||||
return plugin_dir
|
||||
|
||||
plugin_dir = plugins_dir / f"ledmatrix-{plugin_id}"
|
||||
if plugin_dir.exists():
|
||||
return plugin_dir
|
||||
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir
|
||||
plugins_dir_resolved = plugins_dir.resolve()
|
||||
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
|
||||
_candidate = (plugins_dir_resolved / _candidate_name).resolve()
|
||||
try:
|
||||
_candidate.relative_to(plugins_dir_resolved)
|
||||
except ValueError:
|
||||
continue
|
||||
if _candidate.exists():
|
||||
return _candidate
|
||||
|
||||
# Strategy 3: Case-insensitive search
|
||||
normalized_id = plugin_id.lower()
|
||||
@@ -130,6 +139,7 @@ class PluginLoader:
|
||||
self,
|
||||
plugin_dir: Path,
|
||||
plugin_id: str,
|
||||
plugins_dir: Optional[Path] = None,
|
||||
timeout: int = 300
|
||||
) -> bool:
|
||||
"""
|
||||
@@ -138,25 +148,67 @@ class PluginLoader:
|
||||
Args:
|
||||
plugin_dir: Plugin directory path
|
||||
plugin_id: Plugin identifier
|
||||
plugins_dir: Trusted base plugins directory for path containment check
|
||||
timeout: Installation timeout in seconds
|
||||
|
||||
Returns:
|
||||
True if dependencies installed or not needed, False on error
|
||||
"""
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
if not requirements_file.exists():
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not plugin_id:
|
||||
return False
|
||||
|
||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||
|
||||
if plugins_dir is not None:
|
||||
# Validate plugin_dir is within the trusted plugins base directory.
|
||||
# os.path.realpath + startswith is the CodeQL-recognised sanitiser
|
||||
# pattern for path-injection (py/path-injection).
|
||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||
if not plugin_dir_real.startswith(plugins_dir_real + os.sep):
|
||||
self.logger.error(
|
||||
"Plugin dir for %s is outside the plugins directory, skipping deps",
|
||||
plugin_id,
|
||||
)
|
||||
return False
|
||||
elif not os.path.isdir(plugin_dir_real):
|
||||
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
|
||||
return False
|
||||
|
||||
requirements_file = os.path.join(plugin_dir_real, "requirements.txt")
|
||||
marker_file = os.path.join(plugin_dir_real, ".dependencies_installed")
|
||||
|
||||
if not os.path.isfile(requirements_file):
|
||||
return True # No dependencies needed
|
||||
|
||||
# Check if already installed
|
||||
marker_path = plugin_dir / ".dependencies_installed"
|
||||
if marker_path.exists():
|
||||
self.logger.debug("Dependencies already installed for %s", plugin_id)
|
||||
return True
|
||||
try:
|
||||
with open(requirements_file, 'rb') as fh:
|
||||
current_hash = hashlib.sha256(fh.read()).hexdigest()
|
||||
except OSError as e:
|
||||
self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
|
||||
return False
|
||||
|
||||
# Skip if requirements.txt hasn't changed since last install
|
||||
if os.path.isfile(marker_file):
|
||||
try:
|
||||
with open(marker_file, 'r', encoding='utf-8') as fh:
|
||||
stored_hash = fh.read().strip()
|
||||
except OSError as e:
|
||||
self.logger.warning(
|
||||
"Could not read dependency marker for %s (%s), will reinstall dependencies",
|
||||
plugin_id, e
|
||||
)
|
||||
else:
|
||||
if stored_hash == current_hash:
|
||||
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
|
||||
return True
|
||||
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
|
||||
|
||||
try:
|
||||
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", str(requirements_file)],
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
@@ -164,17 +216,37 @@ class PluginLoader:
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Mark as installed
|
||||
marker_path.touch()
|
||||
# Set proper file permissions after creating marker
|
||||
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||
try:
|
||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
||||
fh.write(current_hash)
|
||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
||||
except OSError as marker_err:
|
||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
||||
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
||||
return True
|
||||
else:
|
||||
stderr = result.stderr or ""
|
||||
# uninstall-no-record-file means the package is already present at the
|
||||
# system level (e.g. installed via dnf/apt without a pip RECORD file).
|
||||
# pip can't replace it, but it IS installed — write the marker so we
|
||||
# don't retry on every restart.
|
||||
if "uninstall-no-record-file" in stderr:
|
||||
self.logger.warning(
|
||||
"Dependencies for %s include system-managed packages (no pip RECORD). "
|
||||
"Assuming they are satisfied: %s",
|
||||
plugin_id, stderr.strip()
|
||||
)
|
||||
try:
|
||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
||||
fh.write(current_hash)
|
||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
||||
except OSError as marker_err:
|
||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
||||
return True
|
||||
self.logger.warning(
|
||||
"Dependency installation returned non-zero exit code for %s: %s",
|
||||
plugin_id,
|
||||
result.stderr
|
||||
stderr
|
||||
)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
@@ -349,9 +421,20 @@ class PluginLoader:
|
||||
Returns:
|
||||
Loaded module or None on error
|
||||
"""
|
||||
entry_file = plugin_dir / entry_point
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not plugin_id:
|
||||
raise PluginError("Invalid plugin ID")
|
||||
try:
|
||||
plugin_dir_resolved = plugin_dir.resolve(strict=True)
|
||||
except OSError:
|
||||
raise PluginError("Plugin directory not found", plugin_id=plugin_id)
|
||||
entry_file = (plugin_dir_resolved / entry_point).resolve()
|
||||
try:
|
||||
entry_file.relative_to(plugin_dir_resolved)
|
||||
except ValueError:
|
||||
raise PluginError("Invalid entry point path", plugin_id=plugin_id)
|
||||
if not entry_file.exists():
|
||||
error_msg = f"Entry point file not found: {entry_file} for plugin {plugin_id}"
|
||||
error_msg = f"Entry point file not found for plugin {plugin_id}"
|
||||
self.logger.error(error_msg)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||
|
||||
@@ -501,7 +584,8 @@ class PluginLoader:
|
||||
display_manager: Any,
|
||||
cache_manager: Any,
|
||||
plugin_manager: Any,
|
||||
install_deps: bool = True
|
||||
install_deps: bool = True,
|
||||
plugins_dir: Optional[Path] = None,
|
||||
) -> Tuple[Any, Any]:
|
||||
"""
|
||||
Complete plugin loading process.
|
||||
@@ -515,6 +599,7 @@ class PluginLoader:
|
||||
cache_manager: Cache manager instance
|
||||
plugin_manager: Plugin manager instance
|
||||
install_deps: Whether to install dependencies
|
||||
plugins_dir: Trusted base plugins directory forwarded to install_dependencies
|
||||
|
||||
Returns:
|
||||
Tuple of (plugin_instance, module)
|
||||
@@ -524,7 +609,12 @@ class PluginLoader:
|
||||
"""
|
||||
# Install dependencies if needed
|
||||
if install_deps:
|
||||
self.install_dependencies(plugin_dir, plugin_id)
|
||||
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
|
||||
raise PluginError(
|
||||
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
|
||||
plugin_id=plugin_id,
|
||||
context={'plugin_dir': str(plugin_dir)},
|
||||
)
|
||||
|
||||
# Load module
|
||||
entry_point = manifest.get('entry_point', 'manager.py')
|
||||
|
||||
@@ -350,7 +350,8 @@ class PluginManager:
|
||||
display_manager=self.display_manager,
|
||||
cache_manager=self.cache_manager,
|
||||
plugin_manager=self,
|
||||
install_deps=True
|
||||
install_deps=True,
|
||||
plugins_dir=self.plugins_dir,
|
||||
)
|
||||
|
||||
# Store module
|
||||
|
||||
@@ -185,13 +185,19 @@ class StateReconciliation:
|
||||
message=f"Reconciliation failed: {str(e)}"
|
||||
)
|
||||
|
||||
# Top-level config keys that are NOT plugins
|
||||
# Top-level config keys that are NOT plugins.
|
||||
# Includes both config.json structural keys and config_secrets.json top-level
|
||||
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
|
||||
_SYSTEM_CONFIG_KEYS = frozenset({
|
||||
'web_display_autostart', 'timezone', 'location', 'display',
|
||||
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
||||
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
||||
'dim_schedule', 'network', 'system', 'schedule',
|
||||
# Multi-display sync config (config.json structural key)
|
||||
'sync',
|
||||
# Secrets file top-level keys (merged in by load_config)
|
||||
'github', 'youtube',
|
||||
})
|
||||
|
||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
@@ -341,8 +347,8 @@ class StateReconciliation:
|
||||
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
||||
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
|
||||
fix_action=FixAction.AUTO_FIX,
|
||||
current_state={'enabled': config_enabled},
|
||||
expected_state={'enabled': state_mgr_enabled},
|
||||
current_state={'enabled': state_mgr_enabled},
|
||||
expected_state={'enabled': config_enabled},
|
||||
can_auto_fix=True
|
||||
))
|
||||
|
||||
@@ -365,15 +371,23 @@ class StateReconciliation:
|
||||
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
|
||||
|
||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
||||
# Sync enabled state from state manager to config
|
||||
expected_enabled = inconsistency.expected_state.get('enabled')
|
||||
config = self.config_manager.load_config()
|
||||
if inconsistency.plugin_id not in config:
|
||||
config[inconsistency.plugin_id] = {}
|
||||
config[inconsistency.plugin_id]['enabled'] = expected_enabled
|
||||
self.config_manager.save_config(config)
|
||||
self.logger.info(f"Fixed: Synced enabled state for {inconsistency.plugin_id}")
|
||||
return True
|
||||
# config.json is the user-editable source of truth for enabled state.
|
||||
# Bring the state manager in sync with config rather than the reverse,
|
||||
# so that manual config edits (or the state left behind after an
|
||||
# uninstall+reinstall cycle) don't silently override the user's intent.
|
||||
config_enabled = inconsistency.expected_state.get('enabled')
|
||||
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
|
||||
if success:
|
||||
self.logger.info(
|
||||
f"Fixed: Synced state manager enabled={config_enabled} for "
|
||||
f"{inconsistency.plugin_id} to match config"
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Failed to sync state manager enabled={config_enabled} for "
|
||||
f"{inconsistency.plugin_id}"
|
||||
)
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
||||
|
||||
@@ -5,6 +5,7 @@ Handles plugin discovery, installation, updates, and uninstallation
|
||||
from both the official registry and custom GitHub repositories.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import json
|
||||
import stat
|
||||
@@ -21,6 +22,8 @@ from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any, Tuple
|
||||
import logging
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from src.common.permission_utils import sudo_remove_directory
|
||||
|
||||
try:
|
||||
@@ -356,7 +359,8 @@ class PluginStoreManager:
|
||||
# Extract owner/repo from URL
|
||||
try:
|
||||
# Handle different URL formats
|
||||
if 'github.com' in repo_url:
|
||||
_parsed_url = urlparse(repo_url)
|
||||
if _parsed_url.hostname in ('github.com', 'www.github.com'):
|
||||
parts = repo_url.strip('/').split('/')
|
||||
if len(parts) >= 2:
|
||||
owner = parts[-2]
|
||||
@@ -520,7 +524,8 @@ class PluginStoreManager:
|
||||
registry_urls = []
|
||||
|
||||
# Extract owner/repo from URL
|
||||
if 'github.com' in repo_url:
|
||||
_parsed_repo_url = urlparse(repo_url)
|
||||
if _parsed_repo_url.hostname in ('github.com', 'www.github.com'):
|
||||
parts = repo_url.split('/')
|
||||
if len(parts) >= 2:
|
||||
owner = parts[-2]
|
||||
@@ -775,7 +780,8 @@ class PluginStoreManager:
|
||||
try:
|
||||
# Convert repo URL to raw content URL
|
||||
# https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json
|
||||
if 'github.com' in repo_url:
|
||||
_parsed_manifest_url = urlparse(repo_url)
|
||||
if _parsed_manifest_url.hostname in ('github.com', 'www.github.com'):
|
||||
# Handle different URL formats
|
||||
repo_url = repo_url.rstrip('/')
|
||||
if repo_url.endswith('.git'):
|
||||
@@ -1750,6 +1756,12 @@ class PluginStoreManager:
|
||||
timeout=300
|
||||
)
|
||||
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
|
||||
# Write hash marker so plugin_loader skips redundant pip run on next startup
|
||||
try:
|
||||
current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest()
|
||||
(plugin_path / ".dependencies_installed").write_text(current_hash, encoding='utf-8')
|
||||
except OSError as marker_err:
|
||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_path.name, marker_err)
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
|
||||
@@ -151,6 +151,18 @@ class WiFiManager:
|
||||
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
||||
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
||||
|
||||
# Once per process: remove a stale force-AP flag left by a prior crash.
|
||||
# Guard with a class-level flag so the nmcli AP-state check only runs
|
||||
# once even though WiFiManager is instantiated per-request.
|
||||
if not WiFiManager._startup_cleanup_done:
|
||||
WiFiManager._startup_cleanup_done = True
|
||||
if self._FORCE_AP_FLAG_PATH.exists() and not self._is_ap_mode_active():
|
||||
try:
|
||||
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
|
||||
logger.debug("Removed stale force-AP flag on startup (AP not active)")
|
||||
except OSError as exc:
|
||||
logger.warning(f"Could not remove stale force-AP flag: {exc}")
|
||||
|
||||
def _show_led_message(self, message: str, duration: int = 5):
|
||||
"""
|
||||
Show a WiFi status message on the LED display.
|
||||
@@ -474,7 +486,10 @@ class WiFiManager:
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if '/' in line:
|
||||
ip_address = line.split('/')[0].strip()
|
||||
# nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix";
|
||||
# bare "x.x.x.x/prefix" is also accepted defensively.
|
||||
_, sep, rest = line.partition(':')
|
||||
ip_address = (rest if sep else line).split('/')[0].strip()
|
||||
break
|
||||
|
||||
# Final fallback: Get signal strength by matching SSID in WiFi list
|
||||
@@ -500,6 +515,13 @@ class WiFiManager:
|
||||
|
||||
# Check if AP mode is active
|
||||
ap_active = self._is_ap_mode_active()
|
||||
# wlan0 shows as "connected" in AP mode; clear client-station fields so
|
||||
# callers don't mistake the AP for an outbound WiFi connection.
|
||||
if ap_active and wifi_connected:
|
||||
wifi_connected = False
|
||||
ssid = None
|
||||
ip_address = None
|
||||
logger.debug(f"{wlan_device} is in AP mode — overriding wifi_connected to False")
|
||||
|
||||
return WiFiStatus(
|
||||
connected=wifi_connected,
|
||||
@@ -690,6 +712,10 @@ class WiFiManager:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
|
||||
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
|
||||
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
|
||||
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
|
||||
_startup_cleanup_done: bool = False
|
||||
|
||||
def _validate_ap_config(self) -> Tuple[str, int]:
|
||||
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
||||
@@ -1367,7 +1393,7 @@ class WiFiManager:
|
||||
logger.error(f"Failed to restore original connection: {original_ssid}")
|
||||
# Trigger AP mode as last resort
|
||||
self._show_led_message("Enabling AP mode...", duration=5)
|
||||
ap_success, ap_msg = self.enable_ap_mode()
|
||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||
if ap_success:
|
||||
logger.info("AP mode enabled as failsafe")
|
||||
return False, "Connection failed and restoration failed. AP mode enabled."
|
||||
@@ -1379,7 +1405,7 @@ class WiFiManager:
|
||||
elif not success:
|
||||
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
|
||||
self._show_led_message("Enabling AP mode...", duration=5)
|
||||
ap_success, ap_msg = self.enable_ap_mode()
|
||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||
if ap_success:
|
||||
logger.info("AP mode enabled as failsafe")
|
||||
return False, "Connection failed. AP mode enabled."
|
||||
@@ -1400,7 +1426,7 @@ class WiFiManager:
|
||||
logger.error(f"Failed to restore after exception: {restore_error}")
|
||||
# Last resort: enable AP mode
|
||||
try:
|
||||
self.enable_ap_mode()
|
||||
self.enable_ap_mode(force=True)
|
||||
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
|
||||
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
|
||||
return False, str(e)
|
||||
@@ -1464,26 +1490,29 @@ class WiFiManager:
|
||||
# Show LED message
|
||||
self._show_led_message(f"Connecting to {ssid}...", duration=10)
|
||||
|
||||
# First, check if connection already exists and try to activate it
|
||||
# NetworkManager connection names might not match SSID exactly, so search by SSID
|
||||
check_result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
# Find existing NM connection for this SSID.
|
||||
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show',
|
||||
# so list all wifi connections then query each one's SSID individually.
|
||||
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
|
||||
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
|
||||
existing_conn_name = None
|
||||
if check_result.returncode == 0:
|
||||
for line in check_result.stdout.strip().split('\n'):
|
||||
if ':' in line:
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2:
|
||||
conn_name = parts[0].strip()
|
||||
conn_ssid = parts[1].strip() if len(parts) > 1 else ""
|
||||
if conn_ssid == ssid:
|
||||
existing_conn_name = conn_name
|
||||
break
|
||||
if list_result.returncode == 0:
|
||||
for line in list_result.stdout.strip().split('\n'):
|
||||
if ':' not in line:
|
||||
continue
|
||||
parts = line.split(':')
|
||||
if len(parts) < 2 or parts[1].strip() != '802-11-wireless':
|
||||
continue
|
||||
conn_name = parts[0].strip()
|
||||
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
|
||||
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
|
||||
existing_conn_name = conn_name
|
||||
break
|
||||
|
||||
# Also try direct lookup by SSID (in case connection name matches SSID)
|
||||
if not existing_conn_name:
|
||||
@@ -1855,7 +1884,7 @@ class WiFiManager:
|
||||
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
|
||||
return False
|
||||
|
||||
def enable_ap_mode(self) -> Tuple[bool, str]:
|
||||
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
Enable access point mode
|
||||
|
||||
@@ -1877,20 +1906,29 @@ class WiFiManager:
|
||||
if not self._ensure_wifi_radio_enabled():
|
||||
return False, "WiFi radio is disabled and could not be enabled"
|
||||
|
||||
# Check if WiFi is connected
|
||||
# Check if WiFi is connected (skip when force=True)
|
||||
status = self.get_wifi_status()
|
||||
if status.connected:
|
||||
if not force and status.connected:
|
||||
return False, "Cannot enable AP mode while WiFi is connected"
|
||||
|
||||
# Check if Ethernet is connected
|
||||
if self._is_ethernet_connected():
|
||||
# Check if Ethernet is connected (skip when force=True)
|
||||
if not force and self._is_ethernet_connected():
|
||||
return False, "Cannot enable AP mode while Ethernet is connected"
|
||||
|
||||
if force:
|
||||
logger.debug(f"enable_ap_mode: force=True — WiFi/Ethernet guards bypassed; will create {self._FORCE_AP_FLAG_PATH}")
|
||||
|
||||
# Try hostapd/dnsmasq first (captive portal mode)
|
||||
if self.has_hostapd and self.has_dnsmasq:
|
||||
result = self._enable_ap_mode_hostapd()
|
||||
if result[0]:
|
||||
self._ap_enabled_at = time.time()
|
||||
if force:
|
||||
try:
|
||||
self._FORCE_AP_FLAG_PATH.touch()
|
||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
||||
except OSError as exc:
|
||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
||||
return result
|
||||
|
||||
# Fallback to nmcli hotspot (simpler, no captive portal)
|
||||
@@ -1900,6 +1938,12 @@ class WiFiManager:
|
||||
result = self._enable_ap_mode_nmcli_hotspot()
|
||||
if result[0]:
|
||||
self._ap_enabled_at = time.time()
|
||||
if force:
|
||||
try:
|
||||
self._FORCE_AP_FLAG_PATH.touch()
|
||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
||||
except OSError as exc:
|
||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
||||
return result
|
||||
|
||||
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
||||
@@ -2091,8 +2135,14 @@ class WiFiManager:
|
||||
self._clear_led_message()
|
||||
return False, "AP started but captive-portal redirect setup failed"
|
||||
|
||||
# Verify the AP is actually running
|
||||
status = self._get_ap_status_nmcli()
|
||||
# Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation)
|
||||
status = {}
|
||||
for _attempt in range(5):
|
||||
status = self._get_ap_status_nmcli()
|
||||
if status.get('active'):
|
||||
break
|
||||
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
|
||||
time.sleep(2)
|
||||
if status.get('active'):
|
||||
ip = status.get('ip', '192.168.4.1')
|
||||
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
||||
@@ -2290,6 +2340,7 @@ class WiFiManager:
|
||||
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
|
||||
|
||||
self._ap_enabled_at = None
|
||||
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
|
||||
logger.info("AP mode disabled successfully")
|
||||
return True, "AP mode disabled"
|
||||
except Exception as e:
|
||||
@@ -2478,22 +2529,29 @@ address=/detectportal.firefox.com/192.168.4.1
|
||||
else:
|
||||
logger.warning(f"Failed to enable AP mode: {message}")
|
||||
elif not should_have_ap and ap_active:
|
||||
# Should not have AP but do - disable AP mode
|
||||
# Always disable if WiFi or Ethernet connects, regardless of auto_enable setting
|
||||
if status.connected or ethernet_connected:
|
||||
# Should not have AP but do - check if it was manually force-enabled
|
||||
force_active = self._FORCE_AP_FLAG_PATH.exists()
|
||||
if status.connected:
|
||||
# WiFi connected: always disable AP (user successfully configured WiFi)
|
||||
success, message = self.disable_ap_mode()
|
||||
if success:
|
||||
if status.connected:
|
||||
logger.info("Auto-disabled AP mode (WiFi connected)")
|
||||
elif ethernet_connected:
|
||||
logger.info("Auto-disabled AP mode (Ethernet connected)")
|
||||
self._disconnected_checks = 0 # Reset counter
|
||||
logger.info("Auto-disabled AP mode (WiFi connected)")
|
||||
self._disconnected_checks = 0
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed to auto-disable AP mode: {message}")
|
||||
elif ethernet_connected and not force_active:
|
||||
# Ethernet connected, AP not manually forced: auto-disable
|
||||
success, message = self.disable_ap_mode()
|
||||
if success:
|
||||
logger.info("Auto-disabled AP mode (Ethernet connected)")
|
||||
self._disconnected_checks = 0
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed to auto-disable AP mode: {message}")
|
||||
elif ethernet_connected and force_active:
|
||||
logger.debug("AP mode is force-active; Ethernet connected but auto-disable suppressed")
|
||||
elif not auto_enable:
|
||||
# AP is active but auto_enable is disabled - this means it was manually enabled
|
||||
# Don't disable it automatically, let it stay active
|
||||
logger.debug("AP mode is active (manually enabled), keeping active")
|
||||
|
||||
# Idle-timeout check: disable AP if no client has connected within the window.
|
||||
|
||||
@@ -2,8 +2,11 @@ from flask import Flask, request, redirect, url_for, jsonify, Response, send_fro
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
@@ -22,6 +25,9 @@ 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')
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.urandom(24)
|
||||
@@ -204,24 +210,12 @@ def serve_plugin_asset(plugin_id, filename):
|
||||
# Use send_from_directory to serve the file
|
||||
return send_from_directory(str(assets_dir), filename, mimetype=content_type)
|
||||
|
||||
except Exception as e:
|
||||
# Log the exception with full traceback server-side
|
||||
import traceback
|
||||
except Exception:
|
||||
app.logger.exception('Error serving plugin asset file')
|
||||
|
||||
# Return generic error message to client (avoid leaking internal details)
|
||||
# Only include detailed error information when in debug mode
|
||||
if app.debug:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
'traceback': traceback.format_exc()
|
||||
}), 500
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Internal server error'
|
||||
}), 500
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Internal server error'
|
||||
}), 500
|
||||
|
||||
# Prime psutil CPU measurement once at startup so interval=None returns a real value
|
||||
try:
|
||||
@@ -342,35 +336,25 @@ def not_found_error(error):
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
"""Handle 500 errors."""
|
||||
import traceback
|
||||
error_details = traceback.format_exc()
|
||||
|
||||
# Log the error
|
||||
import logging
|
||||
logger = logging.getLogger('web_interface')
|
||||
logger.error(f"Internal server error: {error}", exc_info=True)
|
||||
|
||||
# Return user-friendly error (hide internal details in production)
|
||||
logger.error("Internal server error", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_code': 'INTERNAL_ERROR',
|
||||
'message': 'An internal error occurred',
|
||||
'details': error_details if app.debug else None
|
||||
'message': 'An internal error occurred; see logs for details',
|
||||
}), 500
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_exception(error):
|
||||
"""Handle all unhandled exceptions."""
|
||||
import traceback
|
||||
import logging
|
||||
logger = logging.getLogger('web_interface')
|
||||
logger.error(f"Unhandled exception: {error}", exc_info=True)
|
||||
|
||||
logger.error("Unhandled exception", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_code': 'UNKNOWN_ERROR',
|
||||
'message': str(error) if app.debug else 'An error occurred',
|
||||
'details': traceback.format_exc() if app.debug else None
|
||||
'message': 'An error occurred; see logs for details',
|
||||
}), 500
|
||||
|
||||
# Captive portal redirect middleware
|
||||
@@ -435,13 +419,53 @@ def add_security_headers(response):
|
||||
|
||||
return response
|
||||
|
||||
# SSE helper function
|
||||
def sse_response(generator_func):
|
||||
"""Helper to create SSE responses"""
|
||||
def generate():
|
||||
for data in generator_func():
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
return Response(generate(), mimetype='text/event-stream')
|
||||
class _StreamBroadcaster:
|
||||
"""Fan-out broadcaster: one background generator thread pushes to all SSE clients.
|
||||
|
||||
This means N browser tabs share one generator instead of each running their own,
|
||||
keeping PIL encodes / subprocess forks constant regardless of how many tabs are open.
|
||||
"""
|
||||
|
||||
def __init__(self, generator_factory):
|
||||
self._generator_factory = generator_factory
|
||||
self._clients: set = set()
|
||||
self._lock = threading.Lock()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def subscribe(self) -> queue.Queue:
|
||||
q: queue.Queue = queue.Queue(maxsize=5)
|
||||
with self._lock:
|
||||
self._clients.add(q)
|
||||
if not (self._thread and self._thread.is_alive()):
|
||||
self._thread = threading.Thread(target=self._broadcast, daemon=True)
|
||||
self._thread.start()
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: queue.Queue) -> None:
|
||||
with self._lock:
|
||||
self._clients.discard(q)
|
||||
|
||||
def _broadcast(self):
|
||||
for data in self._generator_factory():
|
||||
with self._lock:
|
||||
if not self._clients:
|
||||
# No subscribers — exit so the thread doesn't spin indefinitely.
|
||||
# subscribe() will restart it when a new client arrives.
|
||||
break
|
||||
for q in self._clients:
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
except queue.Full:
|
||||
# Client is reading too slowly; drop the oldest item and
|
||||
# deliver the latest so the queue never stalls the client.
|
||||
try:
|
||||
q.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
# System status generator for SSE
|
||||
def system_status_generator():
|
||||
@@ -472,12 +496,13 @@ def system_status_generator():
|
||||
# Check if display service is running (cached to avoid per-client subprocess forks)
|
||||
now = time.time()
|
||||
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
|
||||
try:
|
||||
result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'],
|
||||
capture_output=True, text=True, timeout=2)
|
||||
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
if _SYSTEMCTL:
|
||||
try:
|
||||
result = subprocess.run([_SYSTEMCTL, 'is-active', 'ledmatrix'],
|
||||
capture_output=True, text=True, timeout=2)
|
||||
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
app.logger.warning("systemctl status check failed: %s", e)
|
||||
_ledmatrix_service_cache['timestamp'] = now
|
||||
service_active = _ledmatrix_service_cache['active']
|
||||
|
||||
@@ -492,7 +517,8 @@ def system_status_generator():
|
||||
}
|
||||
yield status
|
||||
except Exception as e:
|
||||
yield {'error': str(e)}
|
||||
app.logger.error("SSE generator error", exc_info=True)
|
||||
yield {'error': 'An error occurred; see server logs'}
|
||||
time.sleep(10) # Update every 10 seconds (reduced frequency for better performance)
|
||||
|
||||
# Display preview generator for SSE
|
||||
@@ -555,7 +581,8 @@ def display_preview_generator():
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
yield {'error': str(e)}
|
||||
app.logger.error("SSE generator error", exc_info=True)
|
||||
yield {'error': 'An error occurred; see server logs'}
|
||||
|
||||
time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s
|
||||
|
||||
@@ -567,8 +594,13 @@ def logs_generator():
|
||||
# Get recent logs from journalctl (simplified version)
|
||||
# Note: User should be in systemd-journal group to read logs without sudo
|
||||
try:
|
||||
if not _JOURNALCTL:
|
||||
yield {'timestamp': time.time(), 'logs': 'journalctl not found; cannot read logs'}
|
||||
time.sleep(60)
|
||||
continue
|
||||
result = subprocess.run(
|
||||
['journalctl', '-u', 'ledmatrix.service', '-n', '50', '--no-pager'],
|
||||
[_JOURNALCTL, '-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
|
||||
'-n', '50', '--no-pager', '--output=short-iso'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
|
||||
@@ -584,7 +616,7 @@ def logs_generator():
|
||||
# No logs available
|
||||
logs_data = {
|
||||
'timestamp': time.time(),
|
||||
'logs': 'No logs available from ledmatrix service'
|
||||
'logs': 'No logs available from ledmatrix or ledmatrix-web service'
|
||||
}
|
||||
yield logs_data
|
||||
else:
|
||||
@@ -598,36 +630,68 @@ def logs_generator():
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout - just skip this update
|
||||
pass
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
app.logger.error("Error running journalctl", exc_info=True)
|
||||
error_data = {
|
||||
'timestamp': time.time(),
|
||||
'logs': f'Error running journalctl: {str(e)}'
|
||||
'logs': 'Error running journalctl; see server logs'
|
||||
}
|
||||
yield error_data
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
app.logger.error("Unexpected error in logs generator", exc_info=True)
|
||||
error_data = {
|
||||
'timestamp': time.time(),
|
||||
'logs': f'Unexpected error in logs generator: {str(e)}'
|
||||
'logs': 'Unexpected error in logs generator; see server logs'
|
||||
}
|
||||
yield error_data
|
||||
|
||||
time.sleep(5) # Update every 5 seconds (reduced frequency for better performance)
|
||||
|
||||
# One broadcaster per stream — shared across all SSE clients
|
||||
_stats_broadcaster = _StreamBroadcaster(system_status_generator)
|
||||
_display_broadcaster = _StreamBroadcaster(display_preview_generator)
|
||||
_logs_broadcaster = _StreamBroadcaster(logs_generator)
|
||||
|
||||
|
||||
def _sse_stream(broadcaster: _StreamBroadcaster) -> Response:
|
||||
"""Return a streaming SSE response backed by a shared broadcaster."""
|
||||
q = broadcaster.subscribe()
|
||||
|
||||
def generate():
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data = q.get(timeout=30)
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
except queue.Empty:
|
||||
# Send an SSE comment heartbeat to keep the connection alive
|
||||
# through proxies that close idle connections.
|
||||
yield ": heartbeat\n\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
finally:
|
||||
broadcaster.unsubscribe(q)
|
||||
|
||||
return Response(generate(), mimetype='text/event-stream')
|
||||
|
||||
|
||||
# SSE endpoints
|
||||
@app.route('/api/v3/stream/stats')
|
||||
def stream_stats():
|
||||
return sse_response(system_status_generator)
|
||||
return _sse_stream(_stats_broadcaster)
|
||||
|
||||
@app.route('/api/v3/stream/display')
|
||||
def stream_display():
|
||||
return sse_response(display_preview_generator)
|
||||
return _sse_stream(_display_broadcaster)
|
||||
|
||||
@app.route('/api/v3/stream/logs')
|
||||
def stream_logs():
|
||||
return sse_response(logs_generator)
|
||||
return _sse_stream(_logs_broadcaster)
|
||||
|
||||
# Exempt SSE streams from CSRF and add rate limiting
|
||||
# Exempt SSE streams from CSRF and apply a generous rate limit.
|
||||
# SSE connections are long-lived HTTP requests, not repeated API calls, so the
|
||||
# tight "20 per minute" default would be exhausted quickly on reconnects.
|
||||
if csrf:
|
||||
csrf.exempt(stream_stats)
|
||||
csrf.exempt(stream_display)
|
||||
@@ -635,9 +699,9 @@ if csrf:
|
||||
# Note: api_v3 blueprint is exempted above after registration
|
||||
|
||||
if limiter:
|
||||
limiter.limit("20 per minute")(stream_stats)
|
||||
limiter.limit("20 per minute")(stream_display)
|
||||
limiter.limit("20 per minute")(stream_logs)
|
||||
limiter.limit("200 per minute")(stream_stats)
|
||||
limiter.limit("200 per minute")(stream_display)
|
||||
limiter.limit("200 per minute")(stream_logs)
|
||||
|
||||
# Main route - redirect to v3 interface as default
|
||||
@app.route('/')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@ from flask import Blueprint, render_template, flash
|
||||
from markupsafe import escape
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from src.web_interface.secret_helpers import mask_secret_fields
|
||||
|
||||
@@ -84,10 +86,11 @@ def load_partial(partial_name):
|
||||
elif partial_name == 'operation-history':
|
||||
return _load_operation_history_partial()
|
||||
else:
|
||||
return f"Partial '{partial_name}' not found", 404
|
||||
return "Partial not found", 404
|
||||
|
||||
except Exception as e:
|
||||
return f"Error loading partial '{partial_name}': {str(e)}", 500
|
||||
logger.error("Error loading partial %s", partial_name, exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
|
||||
@pages_v3.route('/partials/plugin-config/<plugin_id>')
|
||||
@@ -95,8 +98,9 @@ def load_plugin_config_partial(plugin_id):
|
||||
"""Load plugin configuration partial via HTMX - server-side rendered form"""
|
||||
try:
|
||||
return _load_plugin_config_partial(plugin_id)
|
||||
except Exception as e:
|
||||
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
|
||||
except Exception:
|
||||
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
||||
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
||||
|
||||
def _load_overview_partial():
|
||||
"""Load overview partial with system stats"""
|
||||
@@ -107,7 +111,8 @@ def _load_overview_partial():
|
||||
return render_template('v3/partials/overview.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_general_partial():
|
||||
"""Load general settings partial"""
|
||||
@@ -117,7 +122,8 @@ def _load_general_partial():
|
||||
return render_template('v3/partials/general.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_display_partial():
|
||||
"""Load display settings partial"""
|
||||
@@ -127,7 +133,8 @@ def _load_display_partial():
|
||||
return render_template('v3/partials/display.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_durations_partial():
|
||||
"""Load display durations partial"""
|
||||
@@ -137,7 +144,8 @@ def _load_durations_partial():
|
||||
return render_template('v3/partials/durations.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_schedule_partial():
|
||||
"""Load schedule settings partial"""
|
||||
@@ -153,7 +161,8 @@ def _load_schedule_partial():
|
||||
dim_schedule_config=dim_schedule_config,
|
||||
normal_brightness=normal_brightness)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
|
||||
def _load_weather_partial():
|
||||
@@ -164,7 +173,8 @@ def _load_weather_partial():
|
||||
return render_template('v3/partials/weather.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_stocks_partial():
|
||||
"""Load stocks configuration partial"""
|
||||
@@ -174,7 +184,8 @@ def _load_stocks_partial():
|
||||
return render_template('v3/partials/stocks.html',
|
||||
main_config=main_config)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_plugins_partial():
|
||||
"""Load plugins management partial"""
|
||||
@@ -208,7 +219,7 @@ def _load_plugins_partial():
|
||||
plugin_info.update(fresh_manifest)
|
||||
except Exception as e:
|
||||
# If we can't read the fresh manifest, use the cached one
|
||||
print(f"Warning: Could not read fresh manifest for {plugin_id}: {e}")
|
||||
logger.warning("Could not read fresh manifest for plugin: %s", plugin_id)
|
||||
|
||||
# Get enabled status from config (source of truth)
|
||||
# Read from config file first, fall back to plugin instance if config doesn't have the key
|
||||
@@ -256,12 +267,13 @@ def _load_plugins_partial():
|
||||
'branch': branch
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error loading plugin data: {e}")
|
||||
logger.error("Error loading plugin data", exc_info=True)
|
||||
|
||||
return render_template('v3/partials/plugins.html',
|
||||
plugins=plugins_data)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_fonts_partial():
|
||||
"""Load fonts management partial"""
|
||||
@@ -271,14 +283,16 @@ def _load_fonts_partial():
|
||||
return render_template('v3/partials/fonts.html',
|
||||
fonts=fonts_data)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_logs_partial():
|
||||
"""Load logs viewer partial"""
|
||||
try:
|
||||
return render_template('v3/partials/logs.html')
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_raw_json_partial():
|
||||
"""Load raw JSON editor partial"""
|
||||
@@ -295,14 +309,16 @@ def _load_raw_json_partial():
|
||||
main_config_path=pages_v3.config_manager.get_config_path(),
|
||||
secrets_config_path=pages_v3.config_manager.get_secrets_path())
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_backup_restore_partial():
|
||||
"""Load backup & restore partial."""
|
||||
try:
|
||||
return render_template('v3/partials/backup_restore.html')
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
@pages_v3.route('/setup')
|
||||
def captive_setup():
|
||||
@@ -314,21 +330,24 @@ def _load_wifi_partial():
|
||||
try:
|
||||
return render_template('v3/partials/wifi.html')
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_cache_partial():
|
||||
"""Load cache management partial"""
|
||||
try:
|
||||
return render_template('v3/partials/cache.html')
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_operation_history_partial():
|
||||
"""Load operation history partial"""
|
||||
try:
|
||||
return render_template('v3/partials/operation_history.html')
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
|
||||
def _load_plugin_config_partial(plugin_id):
|
||||
@@ -336,6 +355,11 @@ def _load_plugin_config_partial(plugin_id):
|
||||
Load plugin configuration partial - server-side rendered form.
|
||||
This replaces the client-side generateConfigForm() JavaScript.
|
||||
"""
|
||||
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._\-:]*$', plugin_id):
|
||||
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
||||
|
||||
try:
|
||||
if not pages_v3.plugin_manager:
|
||||
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
|
||||
@@ -344,6 +368,14 @@ def _load_plugin_config_partial(plugin_id):
|
||||
if plugin_id.startswith('starlark:'):
|
||||
return _load_starlark_config_partial(plugin_id[len('starlark:'):])
|
||||
|
||||
# Resolve and validate all plugin paths against the plugins base directory
|
||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||
_plugin_dir = (_plugins_base / plugin_id).resolve()
|
||||
try:
|
||||
_plugin_dir.relative_to(_plugins_base)
|
||||
except ValueError:
|
||||
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
||||
|
||||
# Try to get plugin info first
|
||||
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
||||
|
||||
@@ -353,7 +385,7 @@ def _load_plugin_config_partial(plugin_id):
|
||||
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
||||
|
||||
if not plugin_info:
|
||||
return f'<div class="text-red-500 p-4">Plugin "{escape(plugin_id)}" not found</div>', 404
|
||||
return '<div class="text-red-500 p-4">Plugin not found</div>', 404
|
||||
|
||||
# Get plugin instance (may be None if not loaded)
|
||||
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
||||
@@ -365,59 +397,56 @@ def _load_plugin_config_partial(plugin_id):
|
||||
config = full_config.get(plugin_id, {})
|
||||
|
||||
# Load uploaded images from metadata file if images field exists in schema
|
||||
# This ensures uploaded images appear even if config hasn't been saved yet
|
||||
schema_path_temp = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
|
||||
schema_path_temp = _plugin_dir / "config_schema.json"
|
||||
if schema_path_temp.exists():
|
||||
try:
|
||||
with open(schema_path_temp, 'r', encoding='utf-8') as f:
|
||||
temp_schema = json.load(f)
|
||||
# Check if schema has an images field with x-widget: file-upload
|
||||
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
|
||||
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
|
||||
# Load metadata file
|
||||
# Get PROJECT_ROOT relative to this file
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
metadata_file = project_root / 'assets' / 'plugins' / plugin_id / 'uploads' / '.metadata.json'
|
||||
if metadata_file.exists():
|
||||
_assets_base = (Path(__file__).parent.parent.parent / 'assets' / 'plugins').resolve()
|
||||
metadata_file = (_assets_base / plugin_id / 'uploads' / '.metadata.json').resolve()
|
||||
try:
|
||||
metadata_file.relative_to(_assets_base)
|
||||
except ValueError:
|
||||
metadata_file = None
|
||||
if metadata_file and metadata_file.exists():
|
||||
try:
|
||||
with open(metadata_file, 'r', encoding='utf-8') as mf:
|
||||
metadata = json.load(mf)
|
||||
# Convert metadata dict to list of image objects
|
||||
images_from_metadata = list(metadata.values())
|
||||
# Only use metadata images if config doesn't have images or config images is empty
|
||||
if not config.get('images') or len(config.get('images', [])) == 0:
|
||||
config['images'] = images_from_metadata
|
||||
else:
|
||||
# Merge: add metadata images that aren't already in config
|
||||
config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')}
|
||||
new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids]
|
||||
if new_images:
|
||||
config['images'] = config.get('images', []) + new_images
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load metadata for {plugin_id}: {e}")
|
||||
logger.warning("Could not load plugin upload metadata: %s", e)
|
||||
except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below
|
||||
logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e)
|
||||
|
||||
# Get plugin schema
|
||||
schema = {}
|
||||
schema_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
|
||||
schema_path = _plugin_dir / "config_schema.json"
|
||||
if schema_path.exists():
|
||||
try:
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load schema for {plugin_id}: {e}")
|
||||
logger.warning("Could not load schema for plugin: %s", e)
|
||||
|
||||
# Get web UI actions from plugin manifest
|
||||
web_ui_actions = []
|
||||
manifest_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
||||
manifest_path = _plugin_dir / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
web_ui_actions = manifest.get('web_ui_actions', [])
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load manifest for {plugin_id}: {e}")
|
||||
logger.warning("Could not load manifest for plugin: %s", e)
|
||||
|
||||
# Mask secret fields before rendering template (fail closed — never leak secrets)
|
||||
schema_properties = schema.get('properties') if isinstance(schema, dict) else None
|
||||
@@ -453,20 +482,24 @@ def _load_plugin_config_partial(plugin_id):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
|
||||
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
||||
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
||||
|
||||
|
||||
def _load_starlark_config_partial(app_id):
|
||||
"""Load configuration partial for a Starlark app."""
|
||||
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
||||
app_id = os.path.basename(app_id or '')
|
||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_\-]*$', app_id):
|
||||
return '<div class="text-red-500 p-4">Invalid app ID</div>', 400
|
||||
|
||||
try:
|
||||
starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None
|
||||
|
||||
if starlark_plugin and hasattr(starlark_plugin, 'apps'):
|
||||
app = starlark_plugin.apps.get(app_id)
|
||||
if not app:
|
||||
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
|
||||
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
||||
return render_template(
|
||||
'v3/partials/starlark_config.html',
|
||||
app_id=app_id,
|
||||
@@ -482,36 +515,45 @@ def _load_starlark_config_partial(app_id):
|
||||
)
|
||||
|
||||
# Standalone: read from manifest file
|
||||
manifest_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / 'manifest.json'
|
||||
starlark_base = (Path(__file__).resolve().parent.parent.parent / 'starlark-apps').resolve()
|
||||
manifest_file = starlark_base / 'manifest.json'
|
||||
if not manifest_file.exists():
|
||||
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
|
||||
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
||||
|
||||
with open(manifest_file, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
app_data = manifest.get('apps', {}).get(app_id)
|
||||
if not app_data:
|
||||
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
|
||||
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
||||
|
||||
# Load schema from schema.json if it exists
|
||||
# Load schema from schema.json if it exists — validate path stays within starlark_base
|
||||
schema = None
|
||||
schema_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'schema.json'
|
||||
if schema_file.exists():
|
||||
schema_file = (starlark_base / app_id / 'schema.json').resolve()
|
||||
try:
|
||||
schema_file.relative_to(starlark_base)
|
||||
except ValueError:
|
||||
schema_file = None
|
||||
if schema_file and schema_file.exists():
|
||||
try:
|
||||
with open(schema_file, 'r') as f:
|
||||
schema = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"[Pages V3] Could not load schema for {app_id}: {e}", exc_info=True)
|
||||
logger.warning("Could not load starlark schema for app: %s", e)
|
||||
|
||||
# Load config from config.json if it exists
|
||||
# Load config from config.json if it exists — validate path stays within starlark_base
|
||||
config = {}
|
||||
config_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'config.json'
|
||||
if config_file.exists():
|
||||
config_file = (starlark_base / app_id / 'config.json').resolve()
|
||||
try:
|
||||
config_file.relative_to(starlark_base)
|
||||
except ValueError:
|
||||
config_file = None
|
||||
if config_file and config_file.exists():
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"[Pages V3] Could not load config for {app_id}: {e}", exc_info=True)
|
||||
logger.warning("Could not load starlark config for app: %s", e)
|
||||
|
||||
return render_template(
|
||||
'v3/partials/starlark_config.html',
|
||||
@@ -528,5 +570,5 @@ def _load_starlark_config_partial(app_id):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[Pages V3] Error loading starlark config for {app_id}")
|
||||
return f'<div class="text-red-500 p-4">Error loading starlark config: {str(e)}</div>', 500
|
||||
logger.error("[Pages V3] Error loading starlark config for app", exc_info=True)
|
||||
return '<div class="text-red-500 p-4">Error loading starlark config; see logs for details</div>', 500
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* global showNotification, updateSystemStats, htmx */
|
||||
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx */
|
||||
// LED Matrix v3 JavaScript
|
||||
// Additional helpers for HTMX and Alpine.js integration
|
||||
|
||||
@@ -51,7 +51,8 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
}
|
||||
});
|
||||
|
||||
// SSE reconnection helper
|
||||
// SSE reconnection helper — closes and reopens both SSE streams,
|
||||
// reattaching the open/error handlers defined in base.html.
|
||||
window.reconnectSSE = function() {
|
||||
if (window.statsSource) {
|
||||
window.statsSource.close();
|
||||
@@ -60,14 +61,18 @@ window.reconnectSSE = function() {
|
||||
const data = JSON.parse(event.data);
|
||||
if (typeof updateSystemStats === 'function') updateSystemStats(data);
|
||||
};
|
||||
if (window._statsOpenHandler) window.statsSource.addEventListener('open', window._statsOpenHandler);
|
||||
if (window._statsErrorHandler) window.statsSource.addEventListener('error', window._statsErrorHandler);
|
||||
}
|
||||
|
||||
if (window.displaySource) {
|
||||
window.displaySource.close();
|
||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
||||
window.displaySource.onmessage = function() {
|
||||
// Handle display updates
|
||||
window.displaySource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
|
||||
};
|
||||
if (window._displayErrorHandler) window.displaySource.addEventListener('error', window._displayErrorHandler);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -51,8 +51,10 @@
|
||||
sanitizeValue(value) {
|
||||
// Base implementation - widgets should override for specific needs
|
||||
if (typeof value === 'string') {
|
||||
// Basic XSS prevention
|
||||
return value.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
// Strip all HTML tags via the DOM parser to prevent XSS
|
||||
const div = document.createElement('div');
|
||||
div.textContent = value;
|
||||
return div.textContent;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
783
web_interface/static/v3/js/widgets/json-file-manager.js
Normal file
783
web_interface/static/v3/js/widgets/json-file-manager.js
Normal file
@@ -0,0 +1,783 @@
|
||||
/**
|
||||
* JsonFileManager — reusable JSON file management widget for LEDMatrix plugins.
|
||||
*
|
||||
* Usage via config_schema.json:
|
||||
* "file_manager": {
|
||||
* "type": "null",
|
||||
* "title": "Data Files",
|
||||
* "x-widget": "json-file-manager",
|
||||
* "x-widget-config": {
|
||||
* "actions": {
|
||||
* "list": "list-files", // required
|
||||
* "get": "get-file", // required for editing
|
||||
* "save": "save-file", // required for editing
|
||||
* "upload": "upload-file", // optional
|
||||
* "delete": "delete-file", // optional
|
||||
* "create": "create-file", // optional
|
||||
* "toggle": "toggle-category" // optional
|
||||
* },
|
||||
* "upload_hint": "Hint text under the drop zone",
|
||||
* "directory_label": "of_the_day/",
|
||||
* "create_fields": [
|
||||
* { "key": "category_name", "label": "Category Name",
|
||||
* "placeholder": "my_words", "pattern": "^[a-z0-9_]+$",
|
||||
* "hint": "Used as filename" },
|
||||
* { "key": "display_name", "label": "Display Name",
|
||||
* "placeholder": "My Words" }
|
||||
* ],
|
||||
* "toggle_key": "category_name"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* No CDN dependencies. Works on all modern browsers.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
class JsonFileManager {
|
||||
constructor(container, config, pluginId) {
|
||||
// Prevent duplicate instances on the same container
|
||||
if (container._jfmInstance) {
|
||||
container._jfmInstance._destroy();
|
||||
}
|
||||
container._jfmInstance = this;
|
||||
|
||||
this.el = container;
|
||||
this.pluginId = pluginId;
|
||||
this.actions = config.actions || {};
|
||||
this.uploadHint = config.upload_hint || '';
|
||||
this.dirLabel = config.directory_label || '';
|
||||
this.createFields = config.create_fields || [];
|
||||
this.toggleKey = config.toggle_key || null;
|
||||
|
||||
// Unique prefix for all DOM IDs in this instance
|
||||
this._uid = 'jfm_' + Array.from(crypto.getRandomValues(new Uint8Array(4)), b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// Mutable state
|
||||
this._editFile = null;
|
||||
this._deleteFile = null;
|
||||
this._keyHandler = this._onKey.bind(this);
|
||||
|
||||
this._inject();
|
||||
this._bind();
|
||||
this._loadList();
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
_destroy() {
|
||||
document.removeEventListener('keydown', this._keyHandler);
|
||||
this.el._jfmInstance = null;
|
||||
}
|
||||
|
||||
// ── DOM Injection ────────────────────────────────────────────────────
|
||||
|
||||
_inject() {
|
||||
const u = this._uid;
|
||||
const hasUpload = !!this.actions.upload;
|
||||
const hasCreate = !!this.actions.create;
|
||||
const hasDelete = !!this.actions.delete;
|
||||
|
||||
this.el.innerHTML = this._css(u) + `
|
||||
<div id="${u}" class="jfm">
|
||||
|
||||
<div class="jfm-header">
|
||||
<div class="jfm-header-left">
|
||||
<span class="jfm-title">Data Files</span>
|
||||
${this.dirLabel ? `<code class="jfm-dir">${this._esc(this.dirLabel)}</code>` : ''}
|
||||
</div>
|
||||
<div class="jfm-header-right">
|
||||
${hasCreate ? `<button type="button" class="jfm-btn jfm-btn-primary jfm-btn-sm" data-jfm="open-create">+ New File</button>` : ''}
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="refresh" title="Refresh file list">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="${u}-list" class="jfm-list">
|
||||
<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>
|
||||
</div>
|
||||
|
||||
${hasUpload ? `
|
||||
<div class="jfm-upload-wrap">
|
||||
<input type="file" accept=".json" id="${u}-fileinput" tabindex="-1">
|
||||
<div class="jfm-dropzone" id="${u}-dropzone" data-jfm="open-picker" role="button" tabindex="0"
|
||||
aria-label="Upload JSON file">
|
||||
<span class="jfm-drop-icon">📁</span>
|
||||
<p class="jfm-drop-primary">Drop a JSON file here, or click to browse</p>
|
||||
${this.uploadHint ? `<p class="jfm-drop-hint">${this._esc(this.uploadHint)}</p>` : ''}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- ── Edit modal ─────────────────────────────────────── -->
|
||||
<div class="jfm-modal" id="${u}-edit-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal-box jfm-modal-wide">
|
||||
<div class="jfm-modal-head">
|
||||
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
|
||||
<div class="jfm-modal-tools">
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="${u}-edit-err" class="jfm-err-bar" hidden></div>
|
||||
<textarea id="${u}-editor" class="jfm-editor"
|
||||
spellcheck="false" autocomplete="off"
|
||||
autocorrect="off" autocapitalize="off"
|
||||
aria-label="JSON editor"></textarea>
|
||||
<div class="jfm-modal-foot">
|
||||
<span id="${u}-charcount" class="jfm-stat"></span>
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="save" id="${u}-save-btn">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Delete modal ───────────────────────────────────── -->
|
||||
${hasDelete ? `
|
||||
<div class="jfm-modal" id="${u}-del-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal-box">
|
||||
<div class="jfm-modal-head">
|
||||
<span class="jfm-modal-title">Delete file</span>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="jfm-modal-body">
|
||||
<p>Delete <strong id="${u}-del-name"></strong>?</p>
|
||||
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
|
||||
</div>
|
||||
<div class="jfm-modal-foot">
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del">Cancel</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- ── Create modal ───────────────────────────────────── -->
|
||||
${hasCreate ? `
|
||||
<div class="jfm-modal" id="${u}-create-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal-box">
|
||||
<div class="jfm-modal-head">
|
||||
<span class="jfm-modal-title">Create new file</span>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="jfm-modal-body">
|
||||
${this.createFields.map(f => `
|
||||
<div class="jfm-field">
|
||||
<label for="${u}-cf-${this._esc(f.key)}">${this._esc(f.label)}</label>
|
||||
<input type="text" id="${u}-cf-${this._esc(f.key)}"
|
||||
placeholder="${this._esc(f.placeholder || '')}"
|
||||
${f.pattern ? `pattern="${this._esc(f.pattern)}"` : ''}>
|
||||
${f.hint ? `<span class="jfm-hint">${this._esc(f.hint)}</span>` : ''}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="jfm-modal-foot">
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-create">Cancel</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="do-create" id="${u}-create-btn">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
</div>`; // end #${u}
|
||||
|
||||
// Cache frequently-used elements
|
||||
this._root = document.getElementById(u);
|
||||
this._listEl = document.getElementById(`${u}-list`);
|
||||
this._editorEl = document.getElementById(`${u}-editor`);
|
||||
this._editModal = document.getElementById(`${u}-edit-modal`);
|
||||
this._delModal = document.getElementById(`${u}-del-modal`);
|
||||
this._createModal = document.getElementById(`${u}-create-modal`);
|
||||
this._dropzone = document.getElementById(`${u}-dropzone`);
|
||||
this._fileInput = document.getElementById(`${u}-fileinput`);
|
||||
}
|
||||
|
||||
_css(u) {
|
||||
return `<style>
|
||||
#${u}{font-family:inherit;color:#111827;}
|
||||
#${u} *{box-sizing:border-box;}
|
||||
|
||||
/* Header */
|
||||
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;}
|
||||
#${u} .jfm-header-left{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}
|
||||
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:#111827;}
|
||||
#${u} .jfm-dir{font-size:.75rem;color:#6b7280;background:#f3f4f6;padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
|
||||
#${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
|
||||
|
||||
/* Buttons */
|
||||
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid #d1d5db;background:#fff;color:#374151;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
|
||||
#${u} .jfm-btn:hover:not(:disabled){background:#f9fafb;border-color:#9ca3af;}
|
||||
#${u} .jfm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;}
|
||||
#${u} .jfm-btn:disabled{opacity:.5;cursor:not-allowed;}
|
||||
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;}
|
||||
#${u} .jfm-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff;}
|
||||
#${u} .jfm-btn-primary:hover:not(:disabled){background:#2563eb;border-color:#2563eb;}
|
||||
#${u} .jfm-btn-danger{background:#ef4444;border-color:#ef4444;color:#fff;}
|
||||
#${u} .jfm-btn-danger:hover:not(:disabled){background:#dc2626;border-color:#dc2626;}
|
||||
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:#6b7280;}
|
||||
#${u} .jfm-btn-ghost:hover:not(:disabled){background:#f3f4f6;color:#374151;}
|
||||
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
|
||||
#${u} .jfm-close-btn:hover{background:#f3f4f6;color:#374151;}
|
||||
|
||||
/* File list */
|
||||
#${u} .jfm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.625rem;margin-bottom:1rem;min-height:5rem;}
|
||||
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:#6b7280;font-size:.875rem;}
|
||||
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:#9ca3af;}
|
||||
#${u} .jfm-empty-icon{font-size:2.25rem;margin-bottom:.625rem;}
|
||||
#${u} .jfm-empty-title{font-weight:600;color:#374151;margin:0 0 .25rem;}
|
||||
#${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
|
||||
|
||||
/* File cards */
|
||||
#${u} .jfm-card{border:1px solid #e5e7eb;border-radius:.5rem;padding:.875rem;background:#fff;display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
|
||||
#${u} .jfm-card:hover{border-color:#93c5fd;box-shadow:0 2px 8px rgba(59,130,246,.1);}
|
||||
#${u} .jfm-card.jfm-off{opacity:.6;}
|
||||
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
|
||||
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:#111827;flex:1;}
|
||||
#${u} .jfm-card-meta{font-size:.75rem;color:#6b7280;display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
|
||||
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid #f3f4f6;margin-top:.125rem;}
|
||||
#${u} .jfm-card-actions .jfm-btn{flex:1;justify-content:center;}
|
||||
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
|
||||
|
||||
/* Toggle */
|
||||
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:#6b7280;white-space:nowrap;flex-shrink:0;}
|
||||
#${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
|
||||
|
||||
/* Upload zone */
|
||||
#${u} .jfm-upload-wrap{margin-top:.25rem;}
|
||||
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;}
|
||||
#${u} .jfm-dropzone{border:2px dashed #d1d5db;border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#f9fafb;user-select:none;}
|
||||
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:#3b82f6;background:#eff6ff;border-style:solid;outline:none;}
|
||||
#${u} .jfm-drop-icon{font-size:1.75rem;display:block;margin-bottom:.375rem;}
|
||||
#${u} .jfm-drop-primary{font-size:.875rem;color:#374151;margin:0 0 .25rem;}
|
||||
#${u} .jfm-drop-hint{font-size:.75rem;color:#9ca3af;margin:0;}
|
||||
|
||||
/* Modals */
|
||||
#${u} .jfm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(1px);}
|
||||
#${u} .jfm-modal[hidden]{display:none;}
|
||||
#${u} .jfm-modal-box{background:#fff;border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
|
||||
#${u} .jfm-modal-wide{max-width:880px;}
|
||||
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid #e5e7eb;flex-shrink:0;gap:.5rem;}
|
||||
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
#${u} .jfm-modal-tools{display:flex;gap:.25rem;align-items:center;flex-shrink:0;}
|
||||
#${u} .jfm-modal-body{padding:1.125rem;overflow-y:auto;flex:1;}
|
||||
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid #e5e7eb;flex-shrink:0;background:#f9fafb;border-radius:0 0 .5rem .5rem;}
|
||||
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:#9ca3af;font-variant-numeric:tabular-nums;}
|
||||
|
||||
/* JSON editor */
|
||||
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:#1e293b;background:#fafafa;tab-size:2;}
|
||||
#${u} .jfm-err-bar{background:#fef2f2;border-bottom:1px solid #fecaca;color:#991b1b;font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
|
||||
#${u} .jfm-err-bar[hidden]{display:none;}
|
||||
|
||||
/* Create form */
|
||||
#${u} .jfm-field{margin-bottom:.875rem;}
|
||||
#${u} .jfm-field:last-child{margin-bottom:0;}
|
||||
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:#374151;margin-bottom:.3125rem;}
|
||||
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid #d1d5db;border-radius:.375rem;font-size:.875rem;color:#111827;background:#fff;}
|
||||
#${u} .jfm-field input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12);}
|
||||
#${u} .jfm-hint{display:block;font-size:.75rem;color:#9ca3af;margin-top:.25rem;}
|
||||
#${u} .jfm-muted{font-size:.875rem;color:#6b7280;margin-top:.375rem;}
|
||||
|
||||
/* Spinner */
|
||||
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
|
||||
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
|
||||
</style>`;
|
||||
}
|
||||
|
||||
// ── Event Binding ────────────────────────────────────────────────────
|
||||
|
||||
_bind() {
|
||||
// Delegated clicks on the widget root
|
||||
this._root.addEventListener('click', this._onClick.bind(this));
|
||||
this._root.addEventListener('change', this._onChange.bind(this));
|
||||
|
||||
// Drag-and-drop on the dropzone
|
||||
if (this._dropzone) {
|
||||
this._dropzone.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
this._dropzone.classList.add('jfm-over');
|
||||
});
|
||||
this._dropzone.addEventListener('dragleave', () => {
|
||||
this._dropzone.classList.remove('jfm-over');
|
||||
});
|
||||
this._dropzone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
this._dropzone.classList.remove('jfm-over');
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file) this._uploadFile(file);
|
||||
});
|
||||
// Keyboard activation of drop zone
|
||||
this._dropzone.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this._fileInput?.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Modal backdrop clicks
|
||||
[this._editModal, this._delModal, this._createModal].forEach(m => {
|
||||
if (m) m.addEventListener('click', e => { if (e.target === m) this._closeAll(); });
|
||||
});
|
||||
|
||||
// Editor: char count + Tab indent
|
||||
if (this._editorEl) {
|
||||
this._editorEl.addEventListener('input', () => this._updateStat());
|
||||
this._editorEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const s = this._editorEl.selectionStart;
|
||||
const end = this._editorEl.selectionEnd;
|
||||
const v = this._editorEl.value;
|
||||
this._editorEl.value = v.slice(0, s) + ' ' + v.slice(end);
|
||||
this._editorEl.selectionStart = this._editorEl.selectionEnd = s + 2;
|
||||
this._updateStat();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Global keyboard shortcuts
|
||||
document.addEventListener('keydown', this._keyHandler);
|
||||
}
|
||||
|
||||
_onKey(e) {
|
||||
const editOpen = this._editModal && !this._editModal.hidden;
|
||||
const delOpen = this._delModal && !this._delModal.hidden;
|
||||
const createOpen = this._createModal && !this._createModal.hidden;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (editOpen) { this._closeEdit(); return; }
|
||||
if (delOpen) { this._closeDel(); return; }
|
||||
if (createOpen) { this._closeCreate(); return; }
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
|
||||
e.preventDefault();
|
||||
this._doSave();
|
||||
}
|
||||
}
|
||||
|
||||
_onClick(e) {
|
||||
const btn = e.target.closest('[data-jfm]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.jfm;
|
||||
|
||||
switch (action) {
|
||||
case 'refresh': this._loadList(); break;
|
||||
case 'open-picker': this._fileInput?.click(); break;
|
||||
case 'open-create': this._openCreate(); break;
|
||||
case 'close-edit': this._closeEdit(); break;
|
||||
case 'close-del': this._closeDel(); break;
|
||||
case 'close-create': this._closeCreate(); break;
|
||||
case 'fmt': this._formatJson(); break;
|
||||
case 'validate': this._validateJson(); break;
|
||||
case 'save': this._doSave(); break;
|
||||
case 'confirm-del': this._doDelete(); break;
|
||||
case 'do-create': this._doCreate(); break;
|
||||
case 'edit-file': {
|
||||
const card = btn.closest('[data-jfm-file]');
|
||||
if (card) this._openEdit(card.dataset.jfmFile);
|
||||
break;
|
||||
}
|
||||
case 'del-file': {
|
||||
const card = btn.closest('[data-jfm-file]');
|
||||
if (card) this._openDel(card.dataset.jfmFile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onChange(e) {
|
||||
// Toggle checkbox
|
||||
if (e.target.classList.contains('jfm-toggle-cb')) {
|
||||
const catName = e.target.dataset.cat;
|
||||
const enabled = e.target.checked;
|
||||
this._doToggle(catName, enabled, e.target);
|
||||
}
|
||||
// File input
|
||||
if (e.target === this._fileInput) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) this._uploadFile(file);
|
||||
e.target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── API helper ───────────────────────────────────────────────────────
|
||||
|
||||
async _api(actionKey, params) {
|
||||
const actionId = Object.prototype.hasOwnProperty.call(this.actions, actionKey) ? this.actions[actionKey] : undefined;
|
||||
if (!actionId) throw new Error(`Action "${actionKey}" not configured`);
|
||||
const body = { plugin_id: this.pluginId, action_id: actionId };
|
||||
if (params !== undefined) body.params = params;
|
||||
const r = await fetch('/api/v3/plugins/action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!r.ok) throw new Error('Server error ' + r.status);
|
||||
const ct = r.headers.get('content-type') || '';
|
||||
if (!ct.includes('application/json')) {
|
||||
const txt = await r.text();
|
||||
throw new Error('Unexpected response: ' + txt.slice(0, 120));
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// ── File List ────────────────────────────────────────────────────────
|
||||
|
||||
async _loadList() {
|
||||
this._listEl.innerHTML = `<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>`;
|
||||
try {
|
||||
const data = await this._api('list');
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
|
||||
this._renderList(data.files || []);
|
||||
} catch (err) {
|
||||
this._listEl.innerHTML = `
|
||||
<div class="jfm-empty">
|
||||
<div class="jfm-empty-icon">⚠</div>
|
||||
<p class="jfm-empty-title">Failed to load files</p>
|
||||
<p class="jfm-empty-sub">${this._esc(err.message)}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
_renderList(files) {
|
||||
if (!files.length) {
|
||||
this._listEl.innerHTML = `
|
||||
<div class="jfm-empty">
|
||||
<div class="jfm-empty-icon">📁</div>
|
||||
<p class="jfm-empty-title">No files yet</p>
|
||||
<p class="jfm-empty-sub">Upload or create a JSON file to get started</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
this._listEl.innerHTML = files.map(f => this._card(f)).join('');
|
||||
}
|
||||
|
||||
_card(f) {
|
||||
const enabled = f.enabled !== false;
|
||||
const displayName = this._esc(f.display_name || f.filename);
|
||||
const filename = this._esc(f.filename);
|
||||
const catName = this.toggleKey ? this._esc(f[this.toggleKey] || '') : '';
|
||||
const showToggle = !!(this.actions.toggle && this.toggleKey && f[this.toggleKey]);
|
||||
const hasEdit = !!this.actions.get && !!this.actions.save;
|
||||
const hasDelete = !!this.actions.delete;
|
||||
|
||||
return `
|
||||
<div class="jfm-card${enabled ? '' : ' jfm-off'}" data-jfm-file="${filename}">
|
||||
<div class="jfm-card-top">
|
||||
<span class="jfm-card-name" title="${filename}">${displayName}</span>
|
||||
${showToggle ? `
|
||||
<label class="jfm-toggle" title="${enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}">
|
||||
<input type="checkbox" class="jfm-toggle-cb" data-cat="${catName}" ${enabled ? 'checked' : ''}>
|
||||
<span>${enabled ? 'On' : 'Off'}</span>
|
||||
</label>` : ''}
|
||||
</div>
|
||||
<div class="jfm-card-meta">
|
||||
<span>📄 ${filename}</span>
|
||||
<span>📊 ${f.entry_count ?? 0} entries · ${this._fmtSize(f.size || 0)}</span>
|
||||
<span>🕑 ${this._fmtDate(f.modified)}</span>
|
||||
</div>
|
||||
<div class="jfm-card-actions">
|
||||
${hasEdit ? `<button type="button" class="jfm-btn jfm-btn-sm" data-jfm="edit-file">✎ Edit</button>` : ''}
|
||||
${hasDelete ? `<button type="button" class="jfm-btn jfm-btn-danger jfm-btn-sm jfm-del" data-jfm="del-file" title="Delete file">🗑</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Edit flow ────────────────────────────────────────────────────────
|
||||
|
||||
async _openEdit(filename) {
|
||||
this._editFile = filename;
|
||||
document.getElementById(`${this._uid}-edit-title`).textContent = `Edit: ${filename}`;
|
||||
this._clearErr();
|
||||
this._editorEl.value = 'Loading…';
|
||||
this._updateStat();
|
||||
this._editModal.hidden = false;
|
||||
|
||||
try {
|
||||
const data = await this._api('get', { filename });
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
|
||||
this._editorEl.value = JSON.stringify(data.content, null, 2);
|
||||
this._updateStat();
|
||||
this._editorEl.focus();
|
||||
this._editorEl.setSelectionRange(0, 0);
|
||||
this._editorEl.scrollTop = 0;
|
||||
} catch (err) {
|
||||
this._showErr('Failed to load file: ' + err.message);
|
||||
this._editorEl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
_closeEdit() {
|
||||
if (this._editModal) this._editModal.hidden = true;
|
||||
this._editFile = null;
|
||||
this._clearErr();
|
||||
}
|
||||
|
||||
_formatJson() {
|
||||
try {
|
||||
const parsed = JSON.parse(this._editorEl.value);
|
||||
this._editorEl.value = JSON.stringify(parsed, null, 2);
|
||||
this._updateStat();
|
||||
this._clearErr();
|
||||
} catch (err) {
|
||||
this._showErr('Invalid JSON — ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
_validateJson() {
|
||||
try {
|
||||
const parsed = JSON.parse(this._editorEl.value);
|
||||
const n = (typeof parsed === 'object' && parsed !== null) ? Object.keys(parsed).length : '?';
|
||||
this._clearErr();
|
||||
this._notify(`Valid JSON — ${n} top-level keys`, 'success');
|
||||
} catch (err) {
|
||||
this._showErr('Invalid JSON — ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async _doSave() {
|
||||
if (!this._editFile) return;
|
||||
let contentStr;
|
||||
try {
|
||||
const parsed = JSON.parse(this._editorEl.value);
|
||||
contentStr = JSON.stringify(parsed, null, 2);
|
||||
} catch (err) {
|
||||
this._showErr('Cannot save — fix JSON first: ' + err.message);
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById(`${this._uid}-save-btn`);
|
||||
this._busy(btn, 'Saving…');
|
||||
try {
|
||||
const data = await this._api('save', { filename: this._editFile, content: contentStr });
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Save failed');
|
||||
this._notify('File saved', 'success');
|
||||
this._closeEdit();
|
||||
this._loadList();
|
||||
} catch (err) {
|
||||
this._showErr('Save failed: ' + err.message);
|
||||
} finally {
|
||||
this._idle(btn, 'Save');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete flow ──────────────────────────────────────────────────────
|
||||
|
||||
_openDel(filename) {
|
||||
this._deleteFile = filename;
|
||||
const el = document.getElementById(`${this._uid}-del-name`);
|
||||
if (el) el.textContent = filename;
|
||||
if (this._delModal) this._delModal.hidden = false;
|
||||
}
|
||||
|
||||
_closeDel() {
|
||||
if (this._delModal) this._delModal.hidden = true;
|
||||
this._deleteFile = null;
|
||||
}
|
||||
|
||||
async _doDelete() {
|
||||
if (!this._deleteFile) return;
|
||||
const btn = document.getElementById(`${this._uid}-del-btn`);
|
||||
this._busy(btn, 'Deleting…');
|
||||
try {
|
||||
const data = await this._api('delete', { filename: this._deleteFile });
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Delete failed');
|
||||
this._notify('File deleted', 'success');
|
||||
this._closeDel();
|
||||
this._loadList();
|
||||
} catch (err) {
|
||||
this._notify('Delete failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
this._idle(btn, 'Delete');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create flow ──────────────────────────────────────────────────────
|
||||
|
||||
_openCreate() {
|
||||
if (!this._createModal) return;
|
||||
this.createFields.forEach(f => {
|
||||
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
this._createModal.hidden = false;
|
||||
const first = this.createFields[0];
|
||||
if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
|
||||
}
|
||||
|
||||
_closeCreate() {
|
||||
if (this._createModal) this._createModal.hidden = true;
|
||||
}
|
||||
|
||||
async _doCreate() {
|
||||
const params = {};
|
||||
for (const f of this.createFields) {
|
||||
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
|
||||
const val = (el?.value || '').trim();
|
||||
// display_name may be blank — auto-derived from category_name below
|
||||
if (!val && f.key !== 'display_name') {
|
||||
this._notify(`"${f.label}" is required`, 'error');
|
||||
el?.focus();
|
||||
return;
|
||||
}
|
||||
if (f.pattern && val && el && el.validity.patternMismatch) {
|
||||
this._notify(`"${f.label}" format is invalid`, 'error');
|
||||
el?.focus();
|
||||
return;
|
||||
}
|
||||
if (val) params[f.key] = val;
|
||||
}
|
||||
// Auto-derive display_name from category_name when left blank
|
||||
if (!params.display_name && params.category_name) {
|
||||
params.display_name = params.category_name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
const btn = document.getElementById(`${this._uid}-create-btn`);
|
||||
this._busy(btn, 'Creating…');
|
||||
try {
|
||||
const data = await this._api('create', params);
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Create failed');
|
||||
this._notify('File created', 'success');
|
||||
this._closeCreate();
|
||||
this._loadList();
|
||||
} catch (err) {
|
||||
this._notify('Create failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
this._idle(btn, 'Create');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload ───────────────────────────────────────────────────────────
|
||||
|
||||
async _uploadFile(file) {
|
||||
if (!file.name.endsWith('.json')) {
|
||||
this._notify('Please select a .json file', 'error');
|
||||
return;
|
||||
}
|
||||
let content;
|
||||
try {
|
||||
content = await file.text();
|
||||
JSON.parse(content); // client-side validation
|
||||
} catch (err) {
|
||||
this._notify('Invalid JSON: ' + err.message, 'error');
|
||||
return;
|
||||
}
|
||||
if (this._dropzone) this._dropzone.style.opacity = '.5';
|
||||
try {
|
||||
const data = await this._api('upload', { filename: file.name, content });
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Upload failed');
|
||||
this._notify(`"${file.name}" uploaded`, 'success');
|
||||
this._loadList();
|
||||
} catch (err) {
|
||||
this._notify('Upload failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
if (this._dropzone) this._dropzone.style.opacity = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toggle ───────────────────────────────────────────────────────────
|
||||
|
||||
async _doToggle(catName, enabled, checkbox) {
|
||||
checkbox.disabled = true;
|
||||
try {
|
||||
const params = { enabled };
|
||||
if (this.toggleKey) params[this.toggleKey] = catName;
|
||||
const data = await this._api('toggle', params);
|
||||
if (data.status !== 'success') throw new Error(data.message || 'Toggle failed');
|
||||
this._notify(enabled ? 'Category enabled' : 'Category disabled', 'success');
|
||||
this._loadList();
|
||||
} catch (err) {
|
||||
this._notify('Toggle failed: ' + err.message, 'error');
|
||||
checkbox.checked = !enabled; // revert
|
||||
checkbox.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
_closeAll() {
|
||||
this._closeEdit();
|
||||
this._closeDel();
|
||||
this._closeCreate();
|
||||
}
|
||||
|
||||
_updateStat() {
|
||||
const v = this._editorEl?.value || '';
|
||||
const lines = v ? v.split('\n').length : 0;
|
||||
const el = document.getElementById(`${this._uid}-charcount`);
|
||||
if (el) el.textContent = `${lines.toLocaleString()} lines · ${v.length.toLocaleString()} chars`;
|
||||
}
|
||||
|
||||
_showErr(msg) {
|
||||
const el = document.getElementById(`${this._uid}-edit-err`);
|
||||
if (el) { el.textContent = msg; el.hidden = false; }
|
||||
}
|
||||
|
||||
_clearErr() {
|
||||
const el = document.getElementById(`${this._uid}-edit-err`);
|
||||
if (el) { el.textContent = ''; el.hidden = true; }
|
||||
}
|
||||
|
||||
_notify(msg, type) {
|
||||
if (typeof window.showNotification === 'function') {
|
||||
window.showNotification(msg, type || 'info');
|
||||
} else {
|
||||
console.info(`[JsonFileManager] ${type || 'info'}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
_busy(btn, label) {
|
||||
if (!btn) return;
|
||||
btn._jfmOrigText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '';
|
||||
const spin = document.createElement('span');
|
||||
spin.className = 'jfm-spin';
|
||||
btn.appendChild(spin);
|
||||
btn.appendChild(document.createTextNode(' ' + label));
|
||||
}
|
||||
|
||||
_idle(btn, label) {
|
||||
if (!btn) return;
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn._jfmOrigText !== undefined ? btn._jfmOrigText : label;
|
||||
delete btn._jfmOrigText;
|
||||
}
|
||||
|
||||
_esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(str ?? '');
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
_fmtSize(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const i = Math.min(Math.floor(Math.log2(bytes + 1) / 10), 2);
|
||||
const unit = ['B', 'KB', 'MB'][i];
|
||||
const val = bytes / Math.pow(1024, i);
|
||||
return (i ? val.toFixed(1) : val) + ' ' + unit;
|
||||
}
|
||||
|
||||
_fmtDate(str) {
|
||||
if (!str) return '—';
|
||||
try {
|
||||
return new Date(str).toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric', year: 'numeric'
|
||||
});
|
||||
} catch { return str; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Widget registry integration ──────────────────────────────────────────
|
||||
|
||||
window.JsonFileManager = JsonFileManager;
|
||||
|
||||
if (typeof window.LEDMatrixWidgets !== 'undefined') {
|
||||
window.LEDMatrixWidgets.register('json-file-manager', {
|
||||
name: 'JSON File Manager',
|
||||
version: '1.0.0',
|
||||
render(container, config, _value, options) {
|
||||
new JsonFileManager(container, config || {}, options?.pluginId || '');
|
||||
},
|
||||
getValue() { return null; },
|
||||
setValue() {}
|
||||
});
|
||||
console.log('[JsonFileManager] Registered with LEDMatrixWidgets');
|
||||
} else {
|
||||
console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)');
|
||||
}
|
||||
})();
|
||||
@@ -1442,9 +1442,14 @@ function renderInstalledPlugins(plugins) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper function to escape attributes for use in HTML
|
||||
// Helper function to escape values for use in HTML attributes
|
||||
const escapeAttr = (text) => {
|
||||
return (text || '').replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
return (text || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
};
|
||||
|
||||
// Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping)
|
||||
@@ -3441,6 +3446,28 @@ function generateFieldHtml(key, prop, value, prefix = '') {
|
||||
html += `<option value="${option}" ${selected}>${option}</option>`;
|
||||
});
|
||||
html += `</select>`;
|
||||
} else if (prop['x-widget'] === 'json-file-manager') {
|
||||
// Reusable JSON file manager widget (no CDN, keyboard shortcuts, configurable actions)
|
||||
const widgetConfig = prop['x-widget-config'] || {};
|
||||
const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || '';
|
||||
const safeFieldId = (fullKey || 'file_manager').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
html += `<div id="${safeFieldId}_jfm_mount"></div>`;
|
||||
|
||||
setTimeout(() => {
|
||||
const mount = document.getElementById(`${safeFieldId}_jfm_mount`);
|
||||
if (!mount) return;
|
||||
// Destroy the previous instance for this mount only — leave other instances intact
|
||||
window.__jfmInstances = window.__jfmInstances || {};
|
||||
const prev = window.__jfmInstances[safeFieldId];
|
||||
if (prev?._destroy) prev._destroy();
|
||||
if (typeof JsonFileManager !== 'undefined') {
|
||||
window.__jfmInstances[safeFieldId] = new JsonFileManager(mount, widgetConfig, pluginId);
|
||||
} else {
|
||||
window.__jfmInstances[safeFieldId] = null;
|
||||
mount.innerHTML = '<p style="color:#dc2626;font-size:.875rem;">json-file-manager widget not loaded. Check base.html includes json-file-manager.js.</p>';
|
||||
}
|
||||
}, 150);
|
||||
} else if (prop['x-widget'] === 'custom-html') {
|
||||
// Custom HTML widget - load HTML from plugin directory
|
||||
const htmlFile = prop['x-html-file'];
|
||||
@@ -4507,6 +4534,8 @@ function syncFormToJson() {
|
||||
// Deep merge with existing config to preserve nested structures
|
||||
function deepMerge(target, source) {
|
||||
for (const key in source) {
|
||||
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) {
|
||||
target[key] = {};
|
||||
@@ -7473,17 +7502,28 @@ setTimeout(function() {
|
||||
console.log('installed-plugins-grid not found yet, will retry via event listeners');
|
||||
}
|
||||
|
||||
// Also try to attach install button handler after a delay (fallback)
|
||||
// Also try to attach install button handler after a delay (fallback).
|
||||
// Only run if the install button element is already in the DOM (i.e. the
|
||||
// plugins partial has been loaded); otherwise the htmx:afterSettle listener
|
||||
// below handles it when the tab is first visited.
|
||||
setTimeout(() => {
|
||||
if (typeof window.attachInstallButtonHandler === 'function') {
|
||||
console.log('[FALLBACK] Attempting to attach install button handler...');
|
||||
if (typeof window.attachInstallButtonHandler === 'function' &&
|
||||
document.getElementById('install-plugin-from-url')) {
|
||||
window.attachInstallButtonHandler();
|
||||
} else {
|
||||
console.warn('[FALLBACK] attachInstallButtonHandler not available on window');
|
||||
}
|
||||
}, 500);
|
||||
}, 200);
|
||||
|
||||
// Re-run install button wiring after HTMX settles the plugins tab content.
|
||||
// Guard with element check so it only fires when the plugins partial is in the DOM,
|
||||
// preventing spurious warnings on other tab loads.
|
||||
document.addEventListener('htmx:afterSettle', function() {
|
||||
if (document.getElementById('install-plugin-from-url') &&
|
||||
typeof window.attachInstallButtonHandler === 'function') {
|
||||
window.attachInstallButtonHandler();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Starlark Apps Integration ──────────────────────────────────────────────
|
||||
|
||||
(function() {
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
setTimeout(function() {
|
||||
if (typeof htmx !== 'undefined') {
|
||||
console.log('HTMX loaded from fallback');
|
||||
window.dispatchEvent(new Event('htmx:ready'));
|
||||
// Load extensions after core loads
|
||||
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
||||
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
||||
@@ -152,6 +153,7 @@
|
||||
}
|
||||
} else {
|
||||
console.log('HTMX loaded successfully');
|
||||
window.dispatchEvent(new Event('htmx:ready'));
|
||||
// Load extensions after core loads
|
||||
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
||||
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
||||
@@ -349,6 +351,20 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set data-loaded on tab containers after HTMX settles their content,
|
||||
// preventing repeated re-fetches on every tab switch.
|
||||
// Scoped to elements with hx-trigger="revealed" (tab containers only) so
|
||||
// modals and plugin config panels that legitimately reload are unaffected.
|
||||
document.body.addEventListener('htmx:afterSettle', function(event) {
|
||||
if (event.detail && event.detail.target) {
|
||||
var target = event.detail.target;
|
||||
var trigger = target.getAttribute('hx-trigger') || '';
|
||||
if (trigger.includes('revealed')) {
|
||||
target.setAttribute('data-loaded', 'true');
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupScriptExecution);
|
||||
@@ -411,6 +427,9 @@
|
||||
.then(html => {
|
||||
clearTimeout(timeout);
|
||||
content.innerHTML = html;
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.process(content);
|
||||
}
|
||||
// Trigger full initialization chain
|
||||
if (window.pluginManager) {
|
||||
window.pluginManager.initialized = false;
|
||||
@@ -430,7 +449,7 @@
|
||||
}
|
||||
|
||||
// Fallback if HTMX doesn't load within 5 seconds
|
||||
setTimeout(() => {
|
||||
var _pluginsFallbackTimer = setTimeout(() => {
|
||||
if (typeof htmx === 'undefined') {
|
||||
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
|
||||
// Load plugins tab content directly regardless of active tab,
|
||||
@@ -438,6 +457,7 @@
|
||||
loadPluginsDirect();
|
||||
}
|
||||
}, 5000);
|
||||
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true });
|
||||
</script>
|
||||
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
|
||||
<script>
|
||||
@@ -1030,6 +1050,9 @@
|
||||
.then(html => {
|
||||
overviewContent.innerHTML = html;
|
||||
overviewContent.setAttribute('data-loaded', 'true');
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.process(overviewContent);
|
||||
}
|
||||
// Re-initialize Alpine.js for the new content
|
||||
if (window.Alpine) {
|
||||
window.Alpine.initTree(overviewContent);
|
||||
@@ -1058,7 +1081,7 @@
|
||||
});
|
||||
|
||||
// Also try direct load if HTMX doesn't load within 5 seconds
|
||||
setTimeout(() => {
|
||||
var _overviewFallbackTimer = setTimeout(() => {
|
||||
if (typeof htmx === 'undefined') {
|
||||
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
|
||||
const appElement = document.querySelector('[x-data="app()"]');
|
||||
@@ -1070,6 +1093,7 @@
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
|
||||
</script>
|
||||
|
||||
<!-- General tab -->
|
||||
@@ -1346,34 +1370,64 @@
|
||||
|
||||
<!-- SSE connection for real-time updates -->
|
||||
<script>
|
||||
// Connect to SSE streams
|
||||
const statsSource = new EventSource('/api/v3/stream/stats');
|
||||
const displaySource = new EventSource('/api/v3/stream/display');
|
||||
// Assign to window so reconnectSSE() in app.js can reach them.
|
||||
window.statsSource = new EventSource('/api/v3/stream/stats');
|
||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
||||
|
||||
statsSource.onmessage = function(event) {
|
||||
window.statsSource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
updateSystemStats(data);
|
||||
};
|
||||
|
||||
displaySource.onmessage = function(event) {
|
||||
window.displaySource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
updateDisplayPreview(data);
|
||||
};
|
||||
|
||||
// Connection status
|
||||
statsSource.addEventListener('open', function() {
|
||||
document.getElementById('connection-status').innerHTML = `
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span class="text-gray-600">Connected</span>
|
||||
`;
|
||||
});
|
||||
function _setConnectionStatus(connected, reconnecting) {
|
||||
const el = document.getElementById('connection-status');
|
||||
if (!el) return;
|
||||
if (connected) {
|
||||
el.innerHTML = `
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span class="text-gray-600">Connected</span>
|
||||
`;
|
||||
} else if (reconnecting) {
|
||||
el.innerHTML = `
|
||||
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
<span class="text-gray-600">Reconnecting…</span>
|
||||
`;
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
<span class="text-gray-600" title="Connection lost — try refreshing the page">Disconnected</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
statsSource.addEventListener('error', function() {
|
||||
document.getElementById('connection-status').innerHTML = `
|
||||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
<span class="text-gray-600">Disconnected</span>
|
||||
`;
|
||||
});
|
||||
var _statsErrorCount = 0;
|
||||
|
||||
// Named on window so reconnectSSE() in app.js can reattach them after
|
||||
// replacing the EventSource instances.
|
||||
window._statsOpenHandler = function() {
|
||||
_statsErrorCount = 0;
|
||||
_setConnectionStatus(true, false);
|
||||
};
|
||||
window._statsErrorHandler = function() {
|
||||
_statsErrorCount++;
|
||||
// EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED
|
||||
var reconnecting = window.statsSource.readyState === EventSource.CONNECTING;
|
||||
_setConnectionStatus(false, reconnecting && _statsErrorCount <= 3);
|
||||
};
|
||||
window._displayErrorHandler = function() {
|
||||
// Display stream errors don't change the status badge but log to console
|
||||
// so failures aren't completely silent.
|
||||
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
|
||||
};
|
||||
|
||||
window.statsSource.addEventListener('open', window._statsOpenHandler);
|
||||
window.statsSource.addEventListener('error', window._statsErrorHandler);
|
||||
window.displaySource.addEventListener('error', window._displayErrorHandler);
|
||||
|
||||
function updateSystemStats(data) {
|
||||
// Update CPU in header
|
||||
@@ -1816,13 +1870,18 @@
|
||||
htmx.trigger(contentEl, 'revealed');
|
||||
}
|
||||
} else {
|
||||
// HTMX not available, use direct fetch
|
||||
console.warn('HTMX not available, using direct fetch for tab:', tab);
|
||||
if (tab === 'overview' && typeof loadOverviewDirect === 'function') {
|
||||
loadOverviewDirect();
|
||||
} else if (tab === 'wifi' && typeof loadWifiDirect === 'function') {
|
||||
loadWifiDirect();
|
||||
// HTMX is still loading asynchronously — retry when it signals ready,
|
||||
// or fall back to direct fetch if it fails to load entirely.
|
||||
const self = this;
|
||||
function onReady() { window.removeEventListener('htmx-load-failed', onFailed); self.loadTabContent(tab); }
|
||||
function onFailed() {
|
||||
window.removeEventListener('htmx:ready', onReady);
|
||||
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
|
||||
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
|
||||
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
|
||||
}
|
||||
window.addEventListener('htmx:ready', onReady, { once: true });
|
||||
window.addEventListener('htmx-load-failed', onFailed, { once: true });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4566,6 +4625,9 @@
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
|
||||
|
||||
<!-- Reusable JSON file manager widget (used by of-the-day and others via x-widget: json-file-manager) -->
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/json-file-manager.js') }}" defer></script>
|
||||
|
||||
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "start_display"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||
<i class="fas fa-play mr-2"></i>
|
||||
Start Display
|
||||
@@ -82,7 +82,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "stop_display"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||
<i class="fas fa-stop mr-2"></i>
|
||||
Stop Display
|
||||
@@ -91,7 +91,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "git_pull"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Update Code
|
||||
@@ -101,7 +101,7 @@
|
||||
hx-vals='{"action": "reboot_system"}'
|
||||
hx-confirm="Are you sure you want to reboot the system?"
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
|
||||
<i class="fas fa-power-off mr-2"></i>
|
||||
Reboot System
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "start_display"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||
<i class="fas fa-play mr-2"></i>
|
||||
Start Display
|
||||
@@ -160,7 +160,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "stop_display"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||
<i class="fas fa-stop mr-2"></i>
|
||||
Stop Display
|
||||
@@ -170,7 +170,7 @@
|
||||
hx-vals='{"action": "git_pull"}'
|
||||
hx-confirm="This will stash any local changes and update the code. Continue?"
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Update Code
|
||||
@@ -180,7 +180,7 @@
|
||||
hx-vals='{"action": "reboot_system"}'
|
||||
hx-confirm="Are you sure you want to reboot the system?"
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
|
||||
<i class="fas fa-power-off mr-2"></i>
|
||||
Reboot System
|
||||
@@ -190,7 +190,7 @@
|
||||
hx-vals='{"action": "shutdown_system"}'
|
||||
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System shutting down...', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
|
||||
<i class="fas fa-power-off mr-2"></i>
|
||||
Shutdown System
|
||||
@@ -199,7 +199,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "restart_display_service"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display service restarted', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-redo mr-2"></i>
|
||||
Restart Display Service
|
||||
@@ -208,7 +208,7 @@
|
||||
<button hx-post="/api/v3/system/action"
|
||||
hx-vals='{"action": "restart_web_service"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Web service restarted', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-redo mr-2"></i>
|
||||
Restart Web Service
|
||||
|
||||
Reference in New Issue
Block a user