mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-19 19:18:38 +00:00
Compare commits
1 Commits
327e87f735
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45bf5db2b1 |
16
README.md
16
README.md
@@ -1,10 +1,5 @@
|
||||
# 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)
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -132,15 +127,10 @@ 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. |
|
||||
|
||||
### Raspberry Pi
|
||||
- Raspberry Pi Zero's don't have enough processing power for this project.
|
||||
- **Raspberry Pi 3B, 4, or 5**
|
||||
- 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 3B or 4 (NOT RPi 5!)**
|
||||
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
||||
[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
|
||||
@@ -592,7 +582,7 @@ These settings control runtime behavior and GPIO timing:
|
||||
- **Critical setting**: Must match your Raspberry Pi model for stability
|
||||
- **Raspberry Pi 3**: Use 3
|
||||
- **Raspberry Pi 4**: Use 4
|
||||
- **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 5**: Use 5 (or higher if needed)
|
||||
- **Raspberry Pi Zero/1**: Use 1-2
|
||||
- Incorrect values can cause display corruption, flickering, or system instability
|
||||
- If you experience issues, try adjusting this value up or down by 1
|
||||
|
||||
@@ -36,17 +36,9 @@ if [ -r /proc/device-tree/model ]; then
|
||||
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
||||
echo "Detected device: $DEVICE_MODEL"
|
||||
else
|
||||
DEVICE_MODEL=""
|
||||
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
|
||||
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)
|
||||
echo ""
|
||||
echo "Checking operating system requirements..."
|
||||
@@ -791,28 +783,9 @@ CURRENT_STEP="Build and install rpi-rgb-led-matrix"
|
||||
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
||||
echo "-----------------------------------------------------"
|
||||
|
||||
# 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 already installed and not forcing rebuild, skip expensive build
|
||||
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
||||
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)."
|
||||
echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
||||
else
|
||||
# Ensure rpi-rgb-led-matrix submodule is initialized
|
||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||
@@ -879,17 +852,6 @@ except Exception as e:
|
||||
PY
|
||||
then
|
||||
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
|
||||
echo "✗ rpi-rgb-led-matrix import test failed"
|
||||
exit 1
|
||||
|
||||
@@ -110,10 +110,9 @@ class DisplayManager:
|
||||
options.rp1_rio = runtime_config.get('rp1_rio')
|
||||
else:
|
||||
logger.warning(
|
||||
"rp1_rio is set in config but the installed rgbmatrix library does "
|
||||
"not support it — the library was likely built without Pi 5 RP1 "
|
||||
"support (mmap to 0x3f000000 instead of RP1 chip). "
|
||||
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
||||
"rp1_rio is set in config but the current RGBMatrixOptions "
|
||||
"implementation does not support it (RGBMatrixEmulator or older "
|
||||
"library version) — value will be ignored"
|
||||
)
|
||||
|
||||
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}")
|
||||
|
||||
@@ -10,7 +10,6 @@ import json
|
||||
import stat
|
||||
import subprocess
|
||||
import shutil
|
||||
import threading
|
||||
import zipfile
|
||||
import tempfile
|
||||
import requests
|
||||
@@ -101,10 +100,6 @@ class PluginStoreManager:
|
||||
# handlers. Bumping the cached-entry timestamp on failure serves
|
||||
# the stale payload cheaply until the backoff expires.
|
||||
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
|
||||
self.plugins_dir.mkdir(exist_ok=True)
|
||||
@@ -580,50 +575,41 @@ class PluginStoreManager:
|
||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||
return self.registry_cache
|
||||
|
||||
with self._registry_fetch_lock:
|
||||
# Re-check inside the lock — a concurrent caller that was waiting
|
||||
# may have already populated the cache while we blocked.
|
||||
current_time = time.time()
|
||||
if (self.registry_cache and self.registry_cache_time and
|
||||
not force_refresh and
|
||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||
try:
|
||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
self.registry_cache = response.json()
|
||||
self.registry_cache_time = current_time
|
||||
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
|
||||
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
|
||||
|
||||
try:
|
||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
self.registry_cache = response.json()
|
||||
self.registry_cache_time = current_time
|
||||
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
|
||||
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
|
||||
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": []}
|
||||
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]:
|
||||
"""
|
||||
|
||||
@@ -716,41 +716,6 @@ def _run_startup_reconciliation() -> None:
|
||||
"manual 'Reconcile' action to resolve.",
|
||||
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:
|
||||
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
||||
finally:
|
||||
|
||||
@@ -2,17 +2,14 @@ from flask import Blueprint, request, jsonify, Response
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import hashlib
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1387,59 +1384,6 @@ def get_system_version():
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 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'])
|
||||
def execute_system_action():
|
||||
"""Execute system actions (start/stop/reboot/etc)"""
|
||||
@@ -2489,28 +2433,6 @@ def reconcile_plugin_state():
|
||||
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'])
|
||||
def get_plugin_config():
|
||||
"""Get plugin configuration"""
|
||||
|
||||
@@ -843,14 +843,6 @@ async function updateFontPreview() {
|
||||
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
|
||||
loadingText.textContent = 'Loading preview...';
|
||||
loadingText.style.display = 'block';
|
||||
|
||||
@@ -1,66 +1,3 @@
|
||||
<!-- 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="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
|
||||
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
|
||||
{% set description = prop.description if prop.description else '' %}
|
||||
{% 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') %}
|
||||
{% set field_type = prop.type if prop.type is string else (prop.type[0] if prop.type is iterable else 'string') %}
|
||||
|
||||
{# Handle nested objects - check for widget first #}
|
||||
{% if field_type == 'object' %}
|
||||
|
||||
Reference in New Issue
Block a user