3 Commits

Author SHA1 Message Date
Chuck
1ac6499b0c fix(web-ui): dedup registry fetches, surface reconciliation warnings, add check-update endpoint
Story 1 — src/plugin_system/store_manager.py:
Add threading.Lock (_registry_fetch_lock) to fetch_registry(). The outer cache
check remains the hot path (no lock). When the cache is cold, only one thread
hits the network; concurrent callers block on the lock then get the result from
the warm cache (double-checked locking). Eliminates duplicate GitHub requests
on every page load when the 15-minute cache expires.

Story 2 — web_interface/app.py + api_v3.py + overview.html:
_run_startup_reconciliation() now writes /tmp/ledmatrix_reconciliation.json
(atomic tempfile+replace, mirrors hw_status pattern) so the result survives
the background thread. New GET /api/v3/plugins/reconciliation-status reads
that file. Overview page gains a dismissible yellow banner that shows stale
plugin_id values (e.g. sync, github, youtube) and tells the user to remove
them or reinstall from the Plugin Store. Banner is suppressed for the session
after dismiss using sessionStorage keyed on the plugin_id list.

Story 3 — web_interface/blueprints/api_v3.py:
Add GET /api/v3/system/check-update. Does git fetch origin main then compares
local HEAD vs origin/main to compute update_available, remote_sha, and
commits_behind. Result is cached for 5 minutes so it doesn't run git on every
page load. Falls back to {update_available: false} on any error. Eliminates
the 404 logged on every page load.

Story 4 (Pi 5 rgbmatrix rebuild) was already fixed in PR #341.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:51:15 -04:00
Chuck
e00d75bbb5 Disable schedule and update timezone and location (#338)
Updated schedule settings to disable all days and changed timezone and location.

Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-05-19 18:57:09 -04:00
Chuck
33f76b4895 feat(pi5): RP1 backend UI, gpio slowdown guidance, and hardware init error banner (#337)
* feat(pi5): expose RP1 backend selector, fix gpio defaults, surface init failures in web UI

- Add rp1_rio select (PIO/RIO) to Display Settings hardware config section;
  saved via /api/v3/config/main with 0-or-1 validation — previously the key
  existed in config.json but was not editable from the UI
- Update gpio_slowdown help text with per-model guidance (Pi 3: 3, Pi 4: 4,
  Pi 5: 4–5) and raise max from 5 → 10 to match full library range
- Fix gpio_slowdown Python fallback default from 2 → 3 (only affects edge case
  where the runtime config section is absent; explicit config values are unchanged)
- display_manager writes /tmp/led_matrix_hw_status.json at startup: ok/error;
  Display Settings page fetches it and shows a yellow warning banner when the
  matrix failed to initialize, including Pi 5 remediation steps
- Add GET /api/v3/hardware/status endpoint that reads the status file
- Improve fallback error log to include Pi 5 rebuild hint

Pi 3/4 users: rp1_rio=0 is set in config but silently ignored by the library
on non-RP1 hardware; all other changes are additive or tighten defaults only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(pi5): correct gpio_slowdown guidance — Pi 5 PIO default is 1, not 4-5

The upstream library defaults gpio_slowdown to 1 for Pi 5 (IsPi4() ? 2 : 1).
In PIO mode the value is a pixel-clock divisor, so 4-5 was unnecessarily
conservative advice. Updated help text and error log to reflect the actual
range (1-3 typical for Pi 5 PIO; inverted effect in RIO mode).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(security): atomic hw-status write, narrow bare excepts, urllib3 CVE floor

- display_manager: replace open()+bare-except with tempfile.mkstemp→fsync→
  chmod(0o600)→os.replace; adds symlink guard and logs errors via logger
  instead of swallowing them silently; pull json/tempfile to module imports
- display_manager cleanup(): narrow broad `except Exception: pass` to
  (OSError, RuntimeError, ValueError, MemoryError) with debug log
- api_v3 get_hardware_status(): catch json.JSONDecodeError and PermissionError
  explicitly; log full traceback server-side; return generic "Unable to read
  hardware status" to client instead of leaking str(e)
- march-madness/requirements.txt: bump urllib3 floor 2.2.2→2.6.3 (CVE fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(template): apply |int filter to rp1_rio comparisons in display.html

Without |int, a string-typed value (e.g. from a hand-edited config.json)
causes both selected tests to fail and the select renders with no option
pre-selected. Matches the existing pattern used for multiplexing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:42:19 -04:00
8 changed files with 298 additions and 60 deletions

View File

@@ -1,43 +1,43 @@
{
"web_display_autostart": true,
"schedule": {
"enabled": true,
"enabled": false,
"mode": "per-day",
"start_time": "07:00",
"end_time": "23:00",
"days": {
"monday": {
"enabled": true,
"enabled": false,
"start_time": "07:00",
"end_time": "23:00"
},
"tuesday": {
"enabled": true,
"enabled": false,
"start_time": "07:00",
"end_time": "23:00"
},
"wednesday": {
"enabled": true,
"enabled": false,
"start_time": "07:00",
"end_time": "23:00"
},
"thursday": {
"enabled": true,
"enabled": false,
"start_time": "07:00",
"end_time": "23:00"
},
"friday": {
"enabled": true,
"enabled": false,
"start_time": "07:00",
"end_time": "23:00"
},
"saturday": {
"enabled": true,
"enabled": false,
"start_time": "07:00",
"end_time": "23:00"
},
"sunday": {
"enabled": true,
"enabled": false,
"start_time": "07:00",
"end_time": "23:00"
}
@@ -51,46 +51,46 @@
"end_time": "07:00",
"days": {
"monday": {
"enabled": true,
"enabled": false,
"start_time": "20:00",
"end_time": "07:00"
},
"tuesday": {
"enabled": true,
"enabled": false,
"start_time": "20:00",
"end_time": "07:00"
},
"wednesday": {
"enabled": true,
"enabled": false,
"start_time": "20:00",
"end_time": "07:00"
},
"thursday": {
"enabled": true,
"enabled": false,
"start_time": "20:00",
"end_time": "07:00"
},
"friday": {
"enabled": true,
"enabled": false,
"start_time": "20:00",
"end_time": "07:00"
},
"saturday": {
"enabled": true,
"enabled": false,
"start_time": "20:00",
"end_time": "07:00"
},
"sunday": {
"enabled": true,
"enabled": false,
"start_time": "20:00",
"end_time": "07:00"
}
}
},
"timezone": "America/Chicago",
"timezone": "America/New_York",
"location": {
"city": "Dallas",
"state": "Texas",
"city": "Tampa",
"state": "Florida",
"country": "US"
},
"display": {

View File

@@ -1,5 +1,5 @@
requests>=2.33.0
urllib3>=2.2.2
urllib3>=2.6.3
Pillow>=12.2.0
pytz>=2022.1
numpy>=1.24.0

View File

@@ -1,4 +1,6 @@
import json
import os
import tempfile
if os.getenv("EMULATOR", "false") == "true":
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
else:
@@ -58,6 +60,7 @@ class DisplayManager:
def _setup_matrix(self):
"""Initialize the RGB matrix with configuration settings."""
_init_error_str = None
try:
# Allow callers (e.g., web UI) to force non-hardware fallback mode
if getattr(self, '_force_fallback', False):
@@ -87,7 +90,7 @@ class DisplayManager:
options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False)
options.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 2)
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 3)
# Disable internal privilege dropping - we manage this via systemd or remain root
# This prevents the library from dropping to 'daemon' user which breaks file permissions
@@ -141,6 +144,7 @@ class DisplayManager:
self._draw_test_pattern()
except Exception as e:
_init_error_str = str(e)
logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True)
# Create a fallback image for web preview using configured dimensions when available
self.matrix = None
@@ -164,9 +168,38 @@ class DisplayManager:
except Exception: # nosec B110 - best-effort fallback visualization; drawing errors must not crash startup
# Best-effort; ignore drawing errors in fallback
pass
logger.error(f"Matrix initialization failed, using fallback mode with size {fallback_width}x{fallback_height}. Error: {e}")
logger.error(
f"Matrix initialization failed — running in fallback/simulation mode "
f"(size {fallback_width}x{fallback_height}). Error: {e}. "
"On Raspberry Pi 5: ensure rpi-rgb-led-matrix was built from the latest "
"submodule (re-run first_time_install.sh). gpio_slowdown of 23 is typical for Pi 5 PIO mode."
)
# Do not raise here; allow fallback mode so web preview and non-hardware environments work
# Write hardware status file so the web UI can surface init failures
_hw_status = {"ok": self.matrix is not None, "error": _init_error_str}
_status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
try:
if os.path.islink(_status_path):
logger.warning("Skipping hardware status write: %s is a symlink", _status_path)
else:
_fd, _tmp_path = tempfile.mkstemp(dir="/tmp", prefix=".led_hw_") # nosec B108
try:
with os.fdopen(_fd, "w") as _f:
json.dump(_hw_status, _f)
_f.flush()
os.fsync(_f.fileno())
os.chmod(_tmp_path, 0o600)
os.replace(_tmp_path, _status_path)
except Exception:
try:
os.unlink(_tmp_path)
except OSError:
pass
raise
except Exception:
logger.error("Failed to write hardware status file", exc_info=True)
@property
def width(self):
"""Get the display width."""
@@ -747,8 +780,8 @@ class DisplayManager:
try:
self.image = Image.new('RGB', (self.width, self.height))
self.draw = ImageDraw.Draw(self.image)
except Exception: # nosec B110 - best-effort canvas reset during cleanup; non-critical
pass
except (OSError, RuntimeError, ValueError, MemoryError):
logger.debug("Canvas reset during cleanup failed", exc_info=True)
# Reset the singleton state when cleaning up
DisplayManager._instance = None
DisplayManager._initialized = False

View File

@@ -10,6 +10,7 @@ import json
import stat
import subprocess
import shutil
import threading
import zipfile
import tempfile
import requests
@@ -100,6 +101,10 @@ 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)
@@ -575,41 +580,50 @@ class PluginStoreManager:
(current_time - self.registry_cache_time) < 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 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
)
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):
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
)
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
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]:
"""

View File

@@ -716,6 +716,33 @@ 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 = "/tmp/ledmatrix_reconciliation.json"
try:
if not _os.path.islink(_recon_path):
_fd, _tmp = _tempfile.mkstemp(dir="/tmp", prefix=".led_recon_")
with _os.fdopen(_fd, "w") as _f:
_json.dump(_recon_status, _f)
_os.replace(_tmp, _recon_path)
except Exception as _e:
_logger.warning("[Reconciliation] Could not write status file: %s", _e)
except Exception as e:
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
finally:

View File

@@ -699,7 +699,7 @@ def save_main_config():
# Handle display settings
display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping',
'gpio_slowdown', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate',
'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate',
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format',
'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type']
@@ -747,6 +747,14 @@ def save_main_config():
# Handle runtime settings
if 'gpio_slowdown' in data:
current_config['display']['runtime']['gpio_slowdown'] = int(data['gpio_slowdown'])
if 'rp1_rio' in data:
try:
rp1_val = int(data['rp1_rio'])
if rp1_val not in (0, 1):
return jsonify({'status': 'error', 'message': "rp1_rio must be 0 (PIO) or 1 (RIO)"}), 400
current_config['display']['runtime']['rp1_rio'] = rp1_val
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': "rp1_rio must be 0 or 1"}), 400
# Handle checkboxes - coerce to bool to ensure proper JSON types
for checkbox in ['disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate']:
@@ -1376,6 +1384,52 @@ 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)
subprocess.run(
['git', 'fetch', 'origin', 'main', '--quiet'],
capture_output=True, timeout=10, cwd=cwd,
)
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.debug("check-update: %s", e)
return jsonify(_safe)
@api_v3.route('/system/action', methods=['POST'])
def execute_system_action():
"""Execute system actions (start/stop/reboot/etc)"""
@@ -1527,6 +1581,23 @@ def execute_system_action():
print(error_details)
return jsonify({'status': 'error', 'message': str(e), 'details': error_details}), 500
@api_v3.route('/hardware/status', methods=['GET'])
def get_hardware_status():
"""Return LED matrix hardware initialization status written by display_manager at startup."""
status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
try:
with open(status_path) as f:
hw_data = json.load(f)
return jsonify({"status": "success", "data": hw_data})
except FileNotFoundError:
return jsonify({"status": "success", "data": {"ok": None, "error": "Display service not yet started"}})
except (json.JSONDecodeError, PermissionError):
logger.error("Failed to read hardware status file", exc_info=True)
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
except Exception:
logger.error("Unexpected error reading hardware status", exc_info=True)
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
@api_v3.route('/display/current', methods=['GET'])
def get_display_current():
"""Get current display state"""
@@ -2408,6 +2479,19 @@ 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 = "/tmp/ledmatrix_reconciliation.json"
try:
with open(_recon_path) as _f:
data = json.load(_f)
return jsonify({'status': 'success', 'data': data})
except FileNotFoundError:
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@api_v3.route('/plugins/config', methods=['GET'])
def get_plugin_config():
"""Get plugin configuration"""

View File

@@ -4,6 +4,25 @@
<p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p>
</div>
<!-- Hardware status banner: shown when display service is in fallback/simulation mode -->
<div x-data="{ show: false, errorMsg: '' }"
x-init="fetch('/api/v3/hardware/status').then(r => r.json()).then(d => {
const hw = (d && d.data) || {};
if (hw.ok === false) { show = true; errorMsg = hw.error || 'Unknown error'; }
}).catch(() => {})"
x-show="show"
style="display:none"
class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-6">
<p class="font-semibold text-yellow-800"><i class="fas fa-exclamation-triangle mr-2"></i>LED matrix running in simulation mode</p>
<p class="text-sm text-yellow-700 mt-1">Hardware initialization failed: <span x-text="errorMsg" class="font-mono text-xs break-all"></span></p>
<p class="text-sm text-yellow-700 mt-2">
On Raspberry Pi 5: ensure the library was rebuilt from the latest submodule
(<code class="bg-yellow-100 px-1 rounded">first_time_install.sh</code>)
and try adjusting <strong>GPIO Slowdown</strong> (start at 3, reduce if the display looks dim or choppy).
Check the <a href="/v3/logs" class="underline font-medium">Logs tab</a> for the full error.
</p>
</div>
<form hx-post="/api/v3/config/main"
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
@@ -149,7 +168,7 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group">
<label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown</label>
<input type="number"
@@ -157,9 +176,20 @@
name="gpio_slowdown"
value="{{ main_config.display.runtime.gpio_slowdown or 3 }}"
min="0"
max="5"
max="10"
class="form-control">
<p class="mt-1 text-sm text-gray-600">GPIO slowdown factor (0-5)</p>
<p class="mt-1 text-sm text-gray-600">Pi 3: 1&ndash;2 &middot; Pi 4: 2&ndash;4 &middot; Pi 5 PIO: 1&ndash;3. Increase if display shows garbage; in RIO mode higher values may improve performance.</p>
</div>
<div class="form-group">
<label for="rp1_rio" class="block text-sm font-medium text-gray-700">
RP1 Backend <span class="text-xs text-gray-400 font-normal">(Pi 5 only)</span>
</label>
<select id="rp1_rio" name="rp1_rio" class="form-control">
<option value="0" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 0 %}selected{% endif %}>0 &mdash; PIO (default, low CPU)</option>
<option value="1" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 1 %}selected{% endif %}>1 &mdash; RIO (higher throughput; slowdown inverted)</option>
</select>
<p class="mt-1 text-sm text-gray-600">Pi 5 RP1 coprocessor mode. Ignored on Pi 3/4.</p>
</div>
<div class="form-group">

View File

@@ -1,3 +1,53 @@
<!-- 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';
fetch('/api/v3/plugins/reconciliation-status')
.then(function (r) { return r.json(); })
.then(function (resp) {
var d = resp.data || {};
if (!d.done || !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.style.setProperty('display', 'flex', 'important');
})
.catch(function () {});
window.dismissReconciliationBanner = function () {
var banner = document.getElementById('reconciliation-banner');
banner.style.setProperty('display', 'none', 'important');
try {
fetch('/api/v3/plugins/reconciliation-status')
.then(function (r) { return r.json(); })
.then(function (resp) {
var d = resp.data || {};
if (d.unresolved && d.unresolved.length) {
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
sessionStorage.setItem(DISMISS_KEY, key);
}
});
} catch (e) {}
};
}());
</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>