mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-20 03:28:37 +00:00
Compare commits
16 Commits
chore/remo
...
8652aacf37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8652aacf37 | ||
|
|
76507014ce | ||
|
|
53806da8c5 | ||
|
|
3d4de89fd5 | ||
|
|
505fed70e3 | ||
|
|
c8d2eaeb85 | ||
|
|
745ba8101e | ||
|
|
ddc53ff1e0 | ||
|
|
2cd3dbabe5 | ||
|
|
f4e7fea7bb | ||
|
|
a5c7ef20ec | ||
|
|
327e87f735 | ||
|
|
b5426da2a7 | ||
|
|
302ab1da4f | ||
|
|
9cd2bd14ce | ||
|
|
53ee184bc5 |
16
README.md
16
README.md
@@ -1,5 +1,10 @@
|
|||||||
# LEDMatrix
|
# LEDMatrix
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://discord.gg/RdrC37rEag)
|
||||||
|
[](https://github.com/ChuckBuilds/ledmatrix)
|
||||||
[](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
[](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||||
|
|
||||||
|
|
||||||
## Welcome to LEDMatrix!
|
## Welcome to LEDMatrix!
|
||||||
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
||||||
|
|
||||||
@@ -127,10 +132,15 @@ The system supports live, recent, and upcoming game information for multiple spo
|
|||||||
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
|
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
|
||||||
|
|
||||||
### Raspberry Pi
|
### Raspberry Pi
|
||||||
- Raspberry Pi Zero's don't have enough processing power for this project and the Pi 5 is unsupported due to new GPIO output.
|
- Raspberry Pi Zero's don't have enough processing power for this project.
|
||||||
- **Raspberry Pi 3B or 4 (NOT RPi 5!)**
|
- **Raspberry Pi 3B, 4, or 5**
|
||||||
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
||||||
[Amazon Affiliate Link – Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
|
[Amazon Affiliate Link – Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
|
||||||
|
- **Pi 5 users**: the installer automatically detects Pi 5 and builds the `rpi-rgb-led-matrix` library with RP1 support. If you previously installed on a Pi 4 and migrated the SD card, or if you see `mmap` errors in the logs, force a fresh library build:
|
||||||
|
```bash
|
||||||
|
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
|
||||||
|
```
|
||||||
|
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
|
||||||
|
|
||||||
|
|
||||||
### RGB Matrix Bonnet / HAT
|
### RGB Matrix Bonnet / HAT
|
||||||
@@ -582,7 +592,7 @@ These settings control runtime behavior and GPIO timing:
|
|||||||
- **Critical setting**: Must match your Raspberry Pi model for stability
|
- **Critical setting**: Must match your Raspberry Pi model for stability
|
||||||
- **Raspberry Pi 3**: Use 3
|
- **Raspberry Pi 3**: Use 3
|
||||||
- **Raspberry Pi 4**: Use 4
|
- **Raspberry Pi 4**: Use 4
|
||||||
- **Raspberry Pi 5**: Use 5 (or higher if needed)
|
- **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
||||||
- **Raspberry Pi Zero/1**: Use 1-2
|
- **Raspberry Pi Zero/1**: Use 1-2
|
||||||
- Incorrect values can cause display corruption, flickering, or system instability
|
- Incorrect values can cause display corruption, flickering, or system instability
|
||||||
- If you experience issues, try adjusting this value up or down by 1
|
- If you experience issues, try adjusting this value up or down by 1
|
||||||
|
|||||||
@@ -36,9 +36,17 @@ if [ -r /proc/device-tree/model ]; then
|
|||||||
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
||||||
echo "Detected device: $DEVICE_MODEL"
|
echo "Detected device: $DEVICE_MODEL"
|
||||||
else
|
else
|
||||||
|
DEVICE_MODEL=""
|
||||||
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
|
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Detect Pi 5 for hardware-specific install decisions (RP1 library verification)
|
||||||
|
IS_PI5=0
|
||||||
|
if echo "${DEVICE_MODEL:-}" | grep -qi "Raspberry Pi 5"; then
|
||||||
|
IS_PI5=1
|
||||||
|
echo "Raspberry Pi 5 detected — will verify RP1 library support."
|
||||||
|
fi
|
||||||
|
|
||||||
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
|
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
|
||||||
echo ""
|
echo ""
|
||||||
echo "Checking operating system requirements..."
|
echo "Checking operating system requirements..."
|
||||||
@@ -783,9 +791,28 @@ CURRENT_STEP="Build and install rpi-rgb-led-matrix"
|
|||||||
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
||||||
echo "-----------------------------------------------------"
|
echo "-----------------------------------------------------"
|
||||||
|
|
||||||
# If already installed and not forcing rebuild, skip expensive build
|
# On Pi 5, also check that the installed library has rp1_rio support.
|
||||||
|
# A library built before Pi 5 support was added imports fine but maps to the
|
||||||
|
# Pi 3 peripheral bus address (0x3f000000) instead of the RP1 chip at runtime.
|
||||||
|
_HAS_RP1=0
|
||||||
|
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
|
||||||
|
_HAS_RP1=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
_SKIP_BUILD=0
|
||||||
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
||||||
echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
if [ "$IS_PI5" = "1" ] && [ "$_HAS_RP1" = "0" ]; then
|
||||||
|
echo "⚠ Pi 5 detected: installed rgbmatrix lacks rp1_rio support (older build)."
|
||||||
|
echo " Forcing rebuild to get Pi 5 RP1 support..."
|
||||||
|
else
|
||||||
|
_SKIP_BUILD=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$_SKIP_BUILD" = "1" ]; then
|
||||||
|
_skip_suffix=""
|
||||||
|
if [ "$IS_PI5" = "1" ]; then _skip_suffix=" with Pi 5 RP1 support"; fi
|
||||||
|
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
||||||
else
|
else
|
||||||
# Ensure rpi-rgb-led-matrix submodule is initialized
|
# Ensure rpi-rgb-led-matrix submodule is initialized
|
||||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||||
@@ -852,6 +879,17 @@ except Exception as e:
|
|||||||
PY
|
PY
|
||||||
then
|
then
|
||||||
echo "✓ rpi-rgb-led-matrix installed and verified"
|
echo "✓ rpi-rgb-led-matrix installed and verified"
|
||||||
|
# Pi 5: confirm the freshly-built library has rp1_rio support
|
||||||
|
if [ "$IS_PI5" = "1" ]; then
|
||||||
|
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
|
||||||
|
echo "✓ Pi 5 RP1 (rp1_rio) support confirmed"
|
||||||
|
else
|
||||||
|
echo "⚠ rp1_rio not found after rebuild — the submodule may be an older version."
|
||||||
|
echo " Try updating the submodule and rebuilding:"
|
||||||
|
echo " git submodule update --remote rpi-rgb-led-matrix-master"
|
||||||
|
echo " sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "✗ rpi-rgb-led-matrix import test failed"
|
echo "✗ rpi-rgb-led-matrix import test failed"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -110,9 +110,10 @@ class DisplayManager:
|
|||||||
options.rp1_rio = runtime_config.get('rp1_rio')
|
options.rp1_rio = runtime_config.get('rp1_rio')
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"rp1_rio is set in config but the current RGBMatrixOptions "
|
"rp1_rio is set in config but the installed rgbmatrix library does "
|
||||||
"implementation does not support it (RGBMatrixEmulator or older "
|
"not support it — the library was likely built without Pi 5 RP1 "
|
||||||
"library version) — value will be ignored"
|
"support (mmap to 0x3f000000 instead of RP1 chip). "
|
||||||
|
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
|
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
|
||||||
@@ -189,7 +190,7 @@ class DisplayManager:
|
|||||||
json.dump(_hw_status, _f)
|
json.dump(_hw_status, _f)
|
||||||
_f.flush()
|
_f.flush()
|
||||||
os.fsync(_f.fileno())
|
os.fsync(_f.fileno())
|
||||||
os.chmod(_tmp_path, 0o600)
|
os.chmod(_tmp_path, 0o644)
|
||||||
os.replace(_tmp_path, _status_path)
|
os.replace(_tmp_path, _status_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -171,10 +171,24 @@ class PluginLoader:
|
|||||||
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
||||||
return True
|
return True
|
||||||
else:
|
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()
|
||||||
|
)
|
||||||
|
marker_path.touch()
|
||||||
|
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||||
|
return True
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Dependency installation returned non-zero exit code for %s: %s",
|
"Dependency installation returned non-zero exit code for %s: %s",
|
||||||
plugin_id,
|
plugin_id,
|
||||||
result.stderr
|
stderr
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import json
|
|||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import shutil
|
import shutil
|
||||||
|
import threading
|
||||||
import zipfile
|
import zipfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import requests
|
import requests
|
||||||
@@ -100,6 +101,10 @@ class PluginStoreManager:
|
|||||||
# handlers. Bumping the cached-entry timestamp on failure serves
|
# handlers. Bumping the cached-entry timestamp on failure serves
|
||||||
# the stale payload cheaply until the backoff expires.
|
# the stale payload cheaply until the backoff expires.
|
||||||
self._failure_backoff_seconds = 60
|
self._failure_backoff_seconds = 60
|
||||||
|
# Prevents concurrent callers from each firing a network request when
|
||||||
|
# the registry cache expires. Only one thread fetches; others wait and
|
||||||
|
# then get the result from the warm cache (double-checked locking).
|
||||||
|
self._registry_fetch_lock = threading.Lock()
|
||||||
|
|
||||||
# Ensure plugins directory exists
|
# Ensure plugins directory exists
|
||||||
self.plugins_dir.mkdir(exist_ok=True)
|
self.plugins_dir.mkdir(exist_ok=True)
|
||||||
@@ -575,41 +580,50 @@ class PluginStoreManager:
|
|||||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||||
return self.registry_cache
|
return self.registry_cache
|
||||||
|
|
||||||
try:
|
with self._registry_fetch_lock:
|
||||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
# Re-check inside the lock — a concurrent caller that was waiting
|
||||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
# may have already populated the cache while we blocked.
|
||||||
response.raise_for_status()
|
current_time = time.time()
|
||||||
self.registry_cache = response.json()
|
if (self.registry_cache and self.registry_cache_time and
|
||||||
self.registry_cache_time = current_time
|
not force_refresh and
|
||||||
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
|
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||||
return self.registry_cache
|
|
||||||
except requests.RequestException as e:
|
|
||||||
self.logger.error(f"Error fetching registry: {e}")
|
|
||||||
if raise_on_failure:
|
|
||||||
raise
|
|
||||||
# Prefer stale cache over an empty list so the plugin list UI
|
|
||||||
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
|
|
||||||
# registry_cache_time into a short backoff window so the next
|
|
||||||
# request serves the stale payload cheaply instead of
|
|
||||||
# re-hitting the network on every request (matches the
|
|
||||||
# pattern used by github_cache / commit_info_cache).
|
|
||||||
if self.registry_cache:
|
|
||||||
self.logger.warning("Falling back to stale registry cache")
|
|
||||||
self.registry_cache_time = (
|
|
||||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
|
||||||
)
|
|
||||||
return self.registry_cache
|
return self.registry_cache
|
||||||
return {"plugins": []}
|
|
||||||
except json.JSONDecodeError as e:
|
try:
|
||||||
self.logger.error(f"Error parsing registry JSON: {e}")
|
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||||
if raise_on_failure:
|
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||||
raise
|
response.raise_for_status()
|
||||||
if self.registry_cache:
|
self.registry_cache = response.json()
|
||||||
self.registry_cache_time = (
|
self.registry_cache_time = current_time
|
||||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
|
||||||
)
|
|
||||||
return self.registry_cache
|
return self.registry_cache
|
||||||
return {"plugins": []}
|
except requests.RequestException as e:
|
||||||
|
self.logger.error(f"Error fetching registry: {e}")
|
||||||
|
if raise_on_failure:
|
||||||
|
raise
|
||||||
|
# Prefer stale cache over an empty list so the plugin list UI
|
||||||
|
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
|
||||||
|
# registry_cache_time into a short backoff window so the next
|
||||||
|
# request serves the stale payload cheaply instead of
|
||||||
|
# re-hitting the network on every request (matches the
|
||||||
|
# pattern used by github_cache / commit_info_cache).
|
||||||
|
if self.registry_cache:
|
||||||
|
self.logger.warning("Falling back to stale registry cache")
|
||||||
|
self.registry_cache_time = (
|
||||||
|
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||||
|
)
|
||||||
|
return self.registry_cache
|
||||||
|
return {"plugins": []}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
self.logger.error(f"Error parsing registry JSON: {e}")
|
||||||
|
if raise_on_failure:
|
||||||
|
raise
|
||||||
|
if self.registry_cache:
|
||||||
|
self.registry_cache_time = (
|
||||||
|
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||||
|
)
|
||||||
|
return self.registry_cache
|
||||||
|
return {"plugins": []}
|
||||||
|
|
||||||
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
|
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -716,6 +716,41 @@ def _run_startup_reconciliation() -> None:
|
|||||||
"manual 'Reconcile' action to resolve.",
|
"manual 'Reconcile' action to resolve.",
|
||||||
len(result.inconsistencies_manual),
|
len(result.inconsistencies_manual),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Write status file so the web UI can surface unresolved issues as a
|
||||||
|
# banner without the user having to read journalctl. Mirrors the
|
||||||
|
# hw_status pattern (/tmp/led_matrix_hw_status.json).
|
||||||
|
import json as _json, tempfile as _tempfile, os as _os
|
||||||
|
_recon_status = {
|
||||||
|
"done": True,
|
||||||
|
"successful": result.reconciliation_successful,
|
||||||
|
"fixed_count": len(result.inconsistencies_fixed),
|
||||||
|
"unresolved": [
|
||||||
|
{
|
||||||
|
"plugin_id": inc.plugin_id,
|
||||||
|
"type": inc.inconsistency_type.value,
|
||||||
|
"description": inc.description,
|
||||||
|
}
|
||||||
|
for inc in result.inconsistencies_manual
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_recon_path = _os.path.join(_tempfile.gettempdir(), "ledmatrix_reconciliation.json")
|
||||||
|
_tmp = None
|
||||||
|
try:
|
||||||
|
if not _os.path.islink(_recon_path):
|
||||||
|
_fd, _tmp = _tempfile.mkstemp(dir=_tempfile.gettempdir(), prefix=".led_recon_")
|
||||||
|
with _os.fdopen(_fd, "w") as _f:
|
||||||
|
_json.dump(_recon_status, _f)
|
||||||
|
_os.replace(_tmp, _recon_path)
|
||||||
|
_tmp = None # Rename succeeded; nothing to clean up
|
||||||
|
except (OSError, ValueError, TypeError) as _e:
|
||||||
|
_logger.warning("[Reconciliation] Could not write status file: %s", _e)
|
||||||
|
finally:
|
||||||
|
if _tmp is not None and _os.path.exists(_tmp):
|
||||||
|
try:
|
||||||
|
_os.unlink(_tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ from flask import Blueprint, request, jsonify, Response
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import stat
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import hashlib
|
import hashlib
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1382,7 +1385,61 @@ def get_system_version():
|
|||||||
version = get_git_version()
|
version = get_git_version()
|
||||||
return jsonify({'status': 'success', 'data': {'version': version}})
|
return jsonify({'status': 'success', 'data': {'version': version}})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
logger.error("get_system_version failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Unable to retrieve version'}), 500
|
||||||
|
|
||||||
|
_update_check_cache: Dict[str, Any] = {'result': None, 'ts': 0.0}
|
||||||
|
_UPDATE_CHECK_TTL = 300 # 5 minutes — avoids a git fetch on every page load
|
||||||
|
|
||||||
|
@api_v3.route('/system/check-update', methods=['GET'])
|
||||||
|
def check_for_update():
|
||||||
|
"""Check whether a newer LEDMatrix commit is available on origin/main."""
|
||||||
|
now = time.time()
|
||||||
|
if _update_check_cache['result'] and now - _update_check_cache['ts'] < _UPDATE_CHECK_TTL:
|
||||||
|
return jsonify(_update_check_cache['result'])
|
||||||
|
|
||||||
|
_safe: Dict[str, Any] = {'update_available': False, 'remote_sha': 'unknown', 'commits_behind': 0}
|
||||||
|
try:
|
||||||
|
cwd = str(PROJECT_ROOT)
|
||||||
|
fetch_result = subprocess.run(
|
||||||
|
['git', 'fetch', 'origin', 'main', '--quiet'],
|
||||||
|
capture_output=True, timeout=10, cwd=cwd,
|
||||||
|
)
|
||||||
|
if fetch_result.returncode != 0:
|
||||||
|
logger.warning("check-update: git fetch failed (rc=%d): %s",
|
||||||
|
fetch_result.returncode,
|
||||||
|
fetch_result.stderr.decode(errors='replace').strip())
|
||||||
|
_update_check_cache['result'] = _safe
|
||||||
|
_update_check_cache['ts'] = now
|
||||||
|
return jsonify(_safe)
|
||||||
|
local = subprocess.run(
|
||||||
|
['git', 'rev-parse', 'HEAD'],
|
||||||
|
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||||
|
).stdout.strip()
|
||||||
|
remote = subprocess.run(
|
||||||
|
['git', 'rev-parse', 'origin/main'],
|
||||||
|
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
if not local or not remote:
|
||||||
|
return jsonify(_safe)
|
||||||
|
|
||||||
|
if local == remote:
|
||||||
|
result: Dict[str, Any] = {'update_available': False, 'remote_sha': remote, 'commits_behind': 0}
|
||||||
|
else:
|
||||||
|
count_str = subprocess.run(
|
||||||
|
['git', 'rev-list', 'HEAD..origin/main', '--count'],
|
||||||
|
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||||
|
).stdout.strip()
|
||||||
|
count = int(count_str) if count_str.isdigit() else 0
|
||||||
|
result = {'update_available': count > 0, 'remote_sha': remote, 'commits_behind': count}
|
||||||
|
|
||||||
|
_update_check_cache['result'] = result
|
||||||
|
_update_check_cache['ts'] = now
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("check-update failed: %s", e)
|
||||||
|
return jsonify(_safe)
|
||||||
|
|
||||||
@api_v3.route('/system/action', methods=['POST'])
|
@api_v3.route('/system/action', methods=['POST'])
|
||||||
def execute_system_action():
|
def execute_system_action():
|
||||||
@@ -1529,11 +1586,8 @@ def execute_system_action():
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
||||||
error_details = traceback.format_exc()
|
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
|
||||||
print(f"Error in execute_system_action: {str(e)}")
|
|
||||||
print(error_details)
|
|
||||||
return jsonify({'status': 'error', 'message': str(e), 'details': error_details}), 500
|
|
||||||
|
|
||||||
@api_v3.route('/hardware/status', methods=['GET'])
|
@api_v3.route('/hardware/status', methods=['GET'])
|
||||||
def get_hardware_status():
|
def get_hardware_status():
|
||||||
@@ -1545,9 +1599,12 @@ def get_hardware_status():
|
|||||||
return jsonify({"status": "success", "data": hw_data})
|
return jsonify({"status": "success", "data": hw_data})
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return jsonify({"status": "success", "data": {"ok": None, "error": "Display service not yet started"}})
|
return jsonify({"status": "success", "data": {"ok": None, "error": "Display service not yet started"}})
|
||||||
except (json.JSONDecodeError, PermissionError):
|
except PermissionError:
|
||||||
logger.error("Failed to read hardware status file", exc_info=True)
|
logger.warning("Permission denied reading hardware status file; display service may be running as a different user")
|
||||||
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
|
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status temporarily unavailable"}})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("Failed to parse hardware status file", exc_info=True)
|
||||||
|
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status file corrupted"}})
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error("Unexpected error reading hardware status", exc_info=True)
|
logger.error("Unexpected error reading hardware status", exc_info=True)
|
||||||
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
|
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
|
||||||
@@ -2433,6 +2490,28 @@ def reconcile_plugin_state():
|
|||||||
status_code=500
|
status_code=500
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api_v3.route('/plugins/reconciliation-status', methods=['GET'])
|
||||||
|
def get_reconciliation_status():
|
||||||
|
"""Return the result of the last startup reconciliation from /tmp status file."""
|
||||||
|
_recon_path = os.path.join(tempfile.gettempdir(), "ledmatrix_reconciliation.json")
|
||||||
|
try:
|
||||||
|
st = os.lstat(_recon_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
||||||
|
if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode):
|
||||||
|
logger.warning("[Reconciliation] Status file is not a regular file: %s", _recon_path)
|
||||||
|
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
||||||
|
try:
|
||||||
|
with open(_recon_path) as _f:
|
||||||
|
data = json.load(_f)
|
||||||
|
return jsonify({'status': 'success', 'data': data})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.exception("[Reconciliation] Failed to parse status file: %s", _recon_path)
|
||||||
|
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
||||||
|
except PermissionError:
|
||||||
|
logger.exception("[Reconciliation] Permission denied reading status file: %s", _recon_path)
|
||||||
|
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
||||||
|
|
||||||
@api_v3.route('/plugins/config', methods=['GET'])
|
@api_v3.route('/plugins/config', methods=['GET'])
|
||||||
def get_plugin_config():
|
def get_plugin_config():
|
||||||
"""Get plugin configuration"""
|
"""Get plugin configuration"""
|
||||||
@@ -6987,3 +7066,187 @@ def clear_old_errors():
|
|||||||
details=str(e),
|
details=str(e),
|
||||||
status_code=500
|
status_code=500
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backup / Restore
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_BACKUP_EXPORT_DIR = PROJECT_ROOT / "config" / "backups" / "exports"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_backup_path(filename: str) -> Path:
|
||||||
|
"""Resolve a filename to an absolute path inside the export dir,
|
||||||
|
rejecting any traversal attempts. Returns None if unsafe."""
|
||||||
|
if not filename or '/' in filename or '\\' in filename or filename.startswith('.'):
|
||||||
|
return None
|
||||||
|
path = (_BACKUP_EXPORT_DIR / filename).resolve()
|
||||||
|
try:
|
||||||
|
path.relative_to(_BACKUP_EXPORT_DIR.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/backup/preview', methods=['GET'])
|
||||||
|
def backup_preview():
|
||||||
|
"""Return a summary of what a new backup would include."""
|
||||||
|
try:
|
||||||
|
from src.backup_manager import preview_backup_contents
|
||||||
|
data = preview_backup_contents(PROJECT_ROOT)
|
||||||
|
return jsonify({'status': 'success', 'data': data})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_preview failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/backup/list', methods=['GET'])
|
||||||
|
def backup_list():
|
||||||
|
"""List backup ZIPs stored in the export directory."""
|
||||||
|
try:
|
||||||
|
_BACKUP_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
entries = []
|
||||||
|
for p in sorted(_BACKUP_EXPORT_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||||
|
if not p.is_file() or p.suffix != '.zip':
|
||||||
|
continue
|
||||||
|
st = p.stat()
|
||||||
|
entries.append({
|
||||||
|
'filename': p.name,
|
||||||
|
'size': st.st_size,
|
||||||
|
'created_at': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
})
|
||||||
|
return jsonify({'status': 'success', 'data': entries})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_list failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/backup/export', methods=['POST'])
|
||||||
|
def backup_export():
|
||||||
|
"""Create a new backup ZIP and return its filename."""
|
||||||
|
try:
|
||||||
|
from src.backup_manager import create_backup
|
||||||
|
zip_path = create_backup(PROJECT_ROOT, output_dir=_BACKUP_EXPORT_DIR)
|
||||||
|
return jsonify({'status': 'success', 'filename': zip_path.name})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_export failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/backup/validate', methods=['POST'])
|
||||||
|
def backup_validate():
|
||||||
|
"""Validate an uploaded backup ZIP and return its manifest."""
|
||||||
|
try:
|
||||||
|
from src.backup_manager import validate_backup
|
||||||
|
if 'backup_file' not in request.files:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
|
||||||
|
f = request.files['backup_file']
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
f.save(tmp_path)
|
||||||
|
try:
|
||||||
|
ok, err_msg, manifest = validate_backup(Path(tmp_path))
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not ok:
|
||||||
|
logger.warning("Backup validation failed: %s", err_msg)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid or corrupted backup file'}), 400
|
||||||
|
return jsonify({'status': 'success', 'data': manifest})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_validate failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/backup/restore', methods=['POST'])
|
||||||
|
def backup_restore():
|
||||||
|
"""Restore a backup ZIP with optional RestoreOptions."""
|
||||||
|
try:
|
||||||
|
from src.backup_manager import restore_backup, RestoreOptions
|
||||||
|
if 'backup_file' not in request.files:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
|
||||||
|
f = request.files['backup_file']
|
||||||
|
options_raw = request.form.get('options', '{}')
|
||||||
|
try:
|
||||||
|
opts_dict = json.loads(options_raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
opts_dict = {}
|
||||||
|
options = RestoreOptions(
|
||||||
|
restore_config=bool(opts_dict.get('restore_config', True)),
|
||||||
|
restore_secrets=bool(opts_dict.get('restore_secrets', True)),
|
||||||
|
restore_wifi=bool(opts_dict.get('restore_wifi', True)),
|
||||||
|
restore_fonts=bool(opts_dict.get('restore_fonts', True)),
|
||||||
|
restore_plugin_uploads=bool(opts_dict.get('restore_plugin_uploads', True)),
|
||||||
|
reinstall_plugins=bool(opts_dict.get('reinstall_plugins', True)),
|
||||||
|
)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
f.save(tmp_path)
|
||||||
|
try:
|
||||||
|
result = restore_backup(Path(tmp_path), PROJECT_ROOT, options)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Reinstall plugins if requested and store manager available
|
||||||
|
if options.reinstall_plugins and result.plugins_to_install:
|
||||||
|
psm = getattr(api_v3, 'plugin_store_manager', None) or plugin_store_manager
|
||||||
|
for plug in result.plugins_to_install:
|
||||||
|
pid = plug.get('plugin_id')
|
||||||
|
if not pid:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if psm and hasattr(psm, 'install_plugin'):
|
||||||
|
ok = psm.install_plugin(pid)
|
||||||
|
if ok:
|
||||||
|
result.plugins_installed.append(pid)
|
||||||
|
else:
|
||||||
|
result.plugins_failed.append({'plugin_id': pid, 'error': 'install_plugin returned False'})
|
||||||
|
else:
|
||||||
|
result.plugins_failed.append({'plugin_id': pid, 'error': 'Store manager unavailable'})
|
||||||
|
except Exception as pe:
|
||||||
|
result.plugins_failed.append({'plugin_id': pid, 'error': str(pe)})
|
||||||
|
|
||||||
|
data = result.to_dict()
|
||||||
|
if not result.success:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Restore had errors', 'data': data}), 500
|
||||||
|
return jsonify({'status': 'success', 'data': data})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_restore failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/backup/download/<path:filename>', methods=['GET'])
|
||||||
|
def backup_download(filename):
|
||||||
|
"""Stream a backup ZIP to the browser."""
|
||||||
|
from flask import send_from_directory
|
||||||
|
if _safe_backup_path(filename) is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||||
|
try:
|
||||||
|
# send_from_directory uses werkzeug safe_join internally — CodeQL-recognized sanitizer.
|
||||||
|
return send_from_directory(_BACKUP_EXPORT_DIR, filename, as_attachment=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/backup/<path:filename>', methods=['DELETE'])
|
||||||
|
def backup_delete(filename):
|
||||||
|
"""Delete a stored backup ZIP."""
|
||||||
|
safe = _safe_backup_path(filename)
|
||||||
|
if safe is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||||
|
# Enumerate the export directory and match by name so the unlink target is
|
||||||
|
# a filesystem-derived path rather than one constructed from user input.
|
||||||
|
try:
|
||||||
|
for entry in _BACKUP_EXPORT_DIR.iterdir():
|
||||||
|
if entry.is_file() and entry.name == safe.name:
|
||||||
|
entry.unlink()
|
||||||
|
return jsonify({'status': 'success'})
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("backup_delete failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||||
@@ -7473,17 +7473,28 @@ setTimeout(function() {
|
|||||||
console.log('installed-plugins-grid not found yet, will retry via event listeners');
|
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(() => {
|
setTimeout(() => {
|
||||||
if (typeof window.attachInstallButtonHandler === 'function') {
|
if (typeof window.attachInstallButtonHandler === 'function' &&
|
||||||
console.log('[FALLBACK] Attempting to attach install button handler...');
|
document.getElementById('install-plugin-from-url')) {
|
||||||
window.attachInstallButtonHandler();
|
window.attachInstallButtonHandler();
|
||||||
} else {
|
|
||||||
console.warn('[FALLBACK] attachInstallButtonHandler not available on window');
|
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
}, 200);
|
}, 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 ──────────────────────────────────────────────
|
// ─── Starlark Apps Integration ──────────────────────────────────────────────
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
|
|||||||
@@ -136,6 +136,7 @@
|
|||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
if (typeof htmx !== 'undefined') {
|
if (typeof htmx !== 'undefined') {
|
||||||
console.log('HTMX loaded from fallback');
|
console.log('HTMX loaded from fallback');
|
||||||
|
window.dispatchEvent(new Event('htmx:ready'));
|
||||||
// Load extensions after core loads
|
// Load extensions after core loads
|
||||||
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
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');
|
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 {
|
} else {
|
||||||
console.log('HTMX loaded successfully');
|
console.log('HTMX loaded successfully');
|
||||||
|
window.dispatchEvent(new Event('htmx:ready'));
|
||||||
// Load extensions after core loads
|
// Load extensions after core loads
|
||||||
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
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');
|
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 {
|
} else {
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', setupScriptExecution);
|
document.addEventListener('DOMContentLoaded', setupScriptExecution);
|
||||||
@@ -411,6 +427,9 @@
|
|||||||
.then(html => {
|
.then(html => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
content.innerHTML = html;
|
content.innerHTML = html;
|
||||||
|
if (typeof htmx !== 'undefined') {
|
||||||
|
htmx.process(content);
|
||||||
|
}
|
||||||
// Trigger full initialization chain
|
// Trigger full initialization chain
|
||||||
if (window.pluginManager) {
|
if (window.pluginManager) {
|
||||||
window.pluginManager.initialized = false;
|
window.pluginManager.initialized = false;
|
||||||
@@ -430,7 +449,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback if HTMX doesn't load within 5 seconds
|
// Fallback if HTMX doesn't load within 5 seconds
|
||||||
setTimeout(() => {
|
var _pluginsFallbackTimer = setTimeout(() => {
|
||||||
if (typeof htmx === 'undefined') {
|
if (typeof htmx === 'undefined') {
|
||||||
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
|
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
|
||||||
// Load plugins tab content directly regardless of active tab,
|
// Load plugins tab content directly regardless of active tab,
|
||||||
@@ -438,6 +457,7 @@
|
|||||||
loadPluginsDirect();
|
loadPluginsDirect();
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true });
|
||||||
</script>
|
</script>
|
||||||
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
|
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
|
||||||
<script>
|
<script>
|
||||||
@@ -1030,6 +1050,9 @@
|
|||||||
.then(html => {
|
.then(html => {
|
||||||
overviewContent.innerHTML = html;
|
overviewContent.innerHTML = html;
|
||||||
overviewContent.setAttribute('data-loaded', 'true');
|
overviewContent.setAttribute('data-loaded', 'true');
|
||||||
|
if (typeof htmx !== 'undefined') {
|
||||||
|
htmx.process(overviewContent);
|
||||||
|
}
|
||||||
// Re-initialize Alpine.js for the new content
|
// Re-initialize Alpine.js for the new content
|
||||||
if (window.Alpine) {
|
if (window.Alpine) {
|
||||||
window.Alpine.initTree(overviewContent);
|
window.Alpine.initTree(overviewContent);
|
||||||
@@ -1058,7 +1081,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Also try direct load if HTMX doesn't load within 5 seconds
|
// Also try direct load if HTMX doesn't load within 5 seconds
|
||||||
setTimeout(() => {
|
var _overviewFallbackTimer = setTimeout(() => {
|
||||||
if (typeof htmx === 'undefined') {
|
if (typeof htmx === 'undefined') {
|
||||||
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
|
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
|
||||||
const appElement = document.querySelector('[x-data="app()"]');
|
const appElement = document.querySelector('[x-data="app()"]');
|
||||||
@@ -1070,6 +1093,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- General tab -->
|
<!-- General tab -->
|
||||||
@@ -1816,13 +1840,18 @@
|
|||||||
htmx.trigger(contentEl, 'revealed');
|
htmx.trigger(contentEl, 'revealed');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// HTMX not available, use direct fetch
|
// HTMX is still loading asynchronously — retry when it signals ready,
|
||||||
console.warn('HTMX not available, using direct fetch for tab:', tab);
|
// or fall back to direct fetch if it fails to load entirely.
|
||||||
if (tab === 'overview' && typeof loadOverviewDirect === 'function') {
|
const self = this;
|
||||||
loadOverviewDirect();
|
function onReady() { window.removeEventListener('htmx-load-failed', onFailed); self.loadTabContent(tab); }
|
||||||
} else if (tab === 'wifi' && typeof loadWifiDirect === 'function') {
|
function onFailed() {
|
||||||
loadWifiDirect();
|
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 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "start_display"}'
|
hx-vals='{"action": "start_display"}'
|
||||||
hx-swap="none"
|
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">
|
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>
|
<i class="fas fa-play mr-2"></i>
|
||||||
Start Display
|
Start Display
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "stop_display"}'
|
hx-vals='{"action": "stop_display"}'
|
||||||
hx-swap="none"
|
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">
|
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>
|
<i class="fas fa-stop mr-2"></i>
|
||||||
Stop Display
|
Stop Display
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "git_pull"}'
|
hx-vals='{"action": "git_pull"}'
|
||||||
hx-swap="none"
|
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">
|
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>
|
<i class="fas fa-download mr-2"></i>
|
||||||
Update Code
|
Update Code
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
hx-vals='{"action": "reboot_system"}'
|
hx-vals='{"action": "reboot_system"}'
|
||||||
hx-confirm="Are you sure you want to reboot the system?"
|
hx-confirm="Are you sure you want to reboot the system?"
|
||||||
hx-swap="none"
|
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">
|
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>
|
<i class="fas fa-power-off mr-2"></i>
|
||||||
Reboot System
|
Reboot System
|
||||||
|
|||||||
@@ -843,6 +843,14 @@ async function updateFontPreview() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BDF bitmap fonts cannot be rendered server-side — skip the API call
|
||||||
|
if (family.toLowerCase().endsWith('.bdf')) {
|
||||||
|
previewImage.style.display = 'none';
|
||||||
|
loadingText.style.display = 'block';
|
||||||
|
loadingText.textContent = 'Preview not available for BDF bitmap fonts';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
loadingText.textContent = 'Loading preview...';
|
loadingText.textContent = 'Loading preview...';
|
||||||
loadingText.style.display = 'block';
|
loadingText.style.display = 'block';
|
||||||
|
|||||||
@@ -1,3 +1,66 @@
|
|||||||
|
<!-- Reconciliation warning banner: shown when startup reconciliation found stale plugin config entries -->
|
||||||
|
<div id="reconciliation-banner" class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-4 flex items-start" style="display:none !important" role="alert">
|
||||||
|
<div class="flex-shrink-0 mr-3 mt-0.5">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-yellow-800">Plugin Config Warning</p>
|
||||||
|
<p class="text-sm text-yellow-700 mt-1" id="reconciliation-banner-text"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="window.dismissReconciliationBanner()" class="ml-4 flex-shrink-0 text-yellow-500 hover:text-yellow-700" aria-label="Dismiss">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var DISMISS_KEY = 'ledmatrix-recon-dismissed';
|
||||||
|
var _recon_timer = null;
|
||||||
|
|
||||||
|
function checkReconciliation() {
|
||||||
|
fetch('/api/v3/plugins/reconciliation-status')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (resp) {
|
||||||
|
var d = resp.data || {};
|
||||||
|
if (!d.done) {
|
||||||
|
// Reconciliation still running — poll again shortly
|
||||||
|
_recon_timer = setTimeout(checkReconciliation, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_recon_timer = null;
|
||||||
|
if (!d.unresolved || d.unresolved.length === 0) return;
|
||||||
|
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
|
||||||
|
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
|
||||||
|
var ids = d.unresolved.map(function (i) { return i.plugin_id; }).join(', ');
|
||||||
|
document.getElementById('reconciliation-banner-text').textContent =
|
||||||
|
'Stale plugin config entries found: ' + ids +
|
||||||
|
'. Remove them from config.json or reinstall via the Plugin Store.';
|
||||||
|
var banner = document.getElementById('reconciliation-banner');
|
||||||
|
banner.dataset.dismissKey = key;
|
||||||
|
banner.style.setProperty('display', 'flex', 'important');
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
checkReconciliation();
|
||||||
|
|
||||||
|
window.dismissReconciliationBanner = function () {
|
||||||
|
var banner = document.getElementById('reconciliation-banner');
|
||||||
|
banner.style.setProperty('display', 'none', 'important');
|
||||||
|
if (_recon_timer !== null) {
|
||||||
|
clearTimeout(_recon_timer);
|
||||||
|
_recon_timer = null;
|
||||||
|
}
|
||||||
|
// Persist dismissal immediately so the banner won't reappear on reload
|
||||||
|
// even if the background sync fetch below fails.
|
||||||
|
var key = banner.dataset.dismissKey;
|
||||||
|
if (key) {
|
||||||
|
try { sessionStorage.setItem(DISMISS_KEY, key); } catch (e) {}
|
||||||
|
}
|
||||||
|
// Background sync only — do not rely on this for DISMISS_KEY or hiding.
|
||||||
|
fetch('/api/v3/plugins/reconciliation-status').catch(function () {});
|
||||||
|
};
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
|
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
|
||||||
@@ -88,7 +151,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "start_display"}'
|
hx-vals='{"action": "start_display"}'
|
||||||
hx-swap="none"
|
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">
|
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>
|
<i class="fas fa-play mr-2"></i>
|
||||||
Start Display
|
Start Display
|
||||||
@@ -97,7 +160,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "stop_display"}'
|
hx-vals='{"action": "stop_display"}'
|
||||||
hx-swap="none"
|
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">
|
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>
|
<i class="fas fa-stop mr-2"></i>
|
||||||
Stop Display
|
Stop Display
|
||||||
@@ -107,7 +170,7 @@
|
|||||||
hx-vals='{"action": "git_pull"}'
|
hx-vals='{"action": "git_pull"}'
|
||||||
hx-confirm="This will stash any local changes and update the code. Continue?"
|
hx-confirm="This will stash any local changes and update the code. Continue?"
|
||||||
hx-swap="none"
|
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">
|
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>
|
<i class="fas fa-download mr-2"></i>
|
||||||
Update Code
|
Update Code
|
||||||
@@ -117,7 +180,7 @@
|
|||||||
hx-vals='{"action": "reboot_system"}'
|
hx-vals='{"action": "reboot_system"}'
|
||||||
hx-confirm="Are you sure you want to reboot the system?"
|
hx-confirm="Are you sure you want to reboot the system?"
|
||||||
hx-swap="none"
|
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">
|
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>
|
<i class="fas fa-power-off mr-2"></i>
|
||||||
Reboot System
|
Reboot System
|
||||||
@@ -127,7 +190,7 @@
|
|||||||
hx-vals='{"action": "shutdown_system"}'
|
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-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
|
||||||
hx-swap="none"
|
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">
|
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>
|
<i class="fas fa-power-off mr-2"></i>
|
||||||
Shutdown System
|
Shutdown System
|
||||||
@@ -136,7 +199,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "restart_display_service"}'
|
hx-vals='{"action": "restart_display_service"}'
|
||||||
hx-swap="none"
|
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">
|
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>
|
<i class="fas fa-redo mr-2"></i>
|
||||||
Restart Display Service
|
Restart Display Service
|
||||||
@@ -145,7 +208,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "restart_web_service"}'
|
hx-vals='{"action": "restart_web_service"}'
|
||||||
hx-swap="none"
|
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">
|
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>
|
<i class="fas fa-redo mr-2"></i>
|
||||||
Restart Web Service
|
Restart Web Service
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
|
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
|
||||||
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
|
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
|
||||||
{% set description = prop.description if prop.description else '' %}
|
{% set description = prop.description if prop.description else '' %}
|
||||||
{% set field_type = prop.type if prop.type is string else (prop.type[0] if prop.type is iterable else 'string') %}
|
{% set _pt = prop.get('type') %}
|
||||||
|
{% set field_type = _pt if (_pt is string) else ((_pt | first) if (_pt and _pt is iterable and _pt is not string) else 'string') %}
|
||||||
|
|
||||||
{# Handle nested objects - check for widget first #}
|
{# Handle nested objects - check for widget first #}
|
||||||
{% if field_type == 'object' %}
|
{% if field_type == 'object' %}
|
||||||
|
|||||||
Reference in New Issue
Block a user