mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-05-21 20:33:33 +00:00
Compare commits
1 Commits
main
...
fix/web-ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ac6499b0c |
@@ -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,33 @@ 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 = "/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:
|
except Exception as e:
|
||||||
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -1384,6 +1384,52 @@ def get_system_version():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
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'])
|
@api_v3.route('/system/action', methods=['POST'])
|
||||||
def execute_system_action():
|
def execute_system_action():
|
||||||
"""Execute system actions (start/stop/reboot/etc)"""
|
"""Execute system actions (start/stop/reboot/etc)"""
|
||||||
@@ -2433,6 +2479,19 @@ 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 = "/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'])
|
@api_v3.route('/plugins/config', methods=['GET'])
|
||||||
def get_plugin_config():
|
def get_plugin_config():
|
||||||
"""Get plugin configuration"""
|
"""Get plugin configuration"""
|
||||||
|
|||||||
@@ -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="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>
|
||||||
|
|||||||
Reference in New Issue
Block a user