mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-30 12:33:01 +00:00
feat(web): add update-available banner to web UI
Adds a polite, dismissible banner between the header and navigation tabs that appears when the local repo is behind origin/main. Shows commit count and a one-click "Update Now" button that triggers the existing git_pull action. - New GET /api/v3/system/check-update endpoint (5-min cache, compares local HEAD vs origin/main SHA) - Banner auto-checks on page load then every 30 minutes - Dismiss persists for the browser session via sessionStorage - Styled for both light and dark themes - Cache invalidated after successful git_pull so banner hides immediately Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1341,6 +1341,57 @@ def get_system_version():
|
|||||||
logger.exception("[System] get_system_version failed")
|
logger.exception("[System] get_system_version failed")
|
||||||
return jsonify({'status': 'error', 'message': 'Failed to get system version'}), 500
|
return jsonify({'status': 'error', 'message': 'Failed to get system version'}), 500
|
||||||
|
|
||||||
|
_update_check_cache: Dict = {}
|
||||||
|
_UPDATE_CHECK_TTL = 300 # 5 minutes
|
||||||
|
|
||||||
|
@api_v3.route('/system/check-update', methods=['GET'])
|
||||||
|
def check_for_update():
|
||||||
|
"""Check if a newer version is available on the remote."""
|
||||||
|
import time as _time
|
||||||
|
now = _time.time()
|
||||||
|
if _update_check_cache.get('ts', 0) + _UPDATE_CHECK_TTL > now:
|
||||||
|
return jsonify(_update_check_cache['data'])
|
||||||
|
|
||||||
|
project_dir = str(PROJECT_ROOT)
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
['git', 'fetch', 'origin', 'main'],
|
||||||
|
capture_output=True, text=True, timeout=15, cwd=project_dir
|
||||||
|
)
|
||||||
|
local_sha = subprocess.run(
|
||||||
|
['git', 'rev-parse', 'HEAD'],
|
||||||
|
capture_output=True, text=True, timeout=5, cwd=project_dir
|
||||||
|
).stdout.strip()
|
||||||
|
remote_sha = subprocess.run(
|
||||||
|
['git', 'rev-parse', 'origin/main'],
|
||||||
|
capture_output=True, text=True, timeout=5, cwd=project_dir
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
if local_sha == remote_sha:
|
||||||
|
data = {'status': 'success', 'update_available': False,
|
||||||
|
'local_sha': local_sha[:8], 'remote_sha': remote_sha[:8]}
|
||||||
|
else:
|
||||||
|
log_result = subprocess.run(
|
||||||
|
['git', 'log', 'HEAD..origin/main', '--oneline'],
|
||||||
|
capture_output=True, text=True, timeout=5, cwd=project_dir
|
||||||
|
)
|
||||||
|
lines = [l for l in log_result.stdout.strip().split('\n') if l]
|
||||||
|
data = {
|
||||||
|
'status': 'success',
|
||||||
|
'update_available': True,
|
||||||
|
'local_sha': local_sha[:8],
|
||||||
|
'remote_sha': remote_sha[:8],
|
||||||
|
'commits_behind': len(lines),
|
||||||
|
'latest_message': lines[0].split(' ', 1)[1] if lines else '',
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[System] check-update failed: %s", e)
|
||||||
|
data = {'status': 'error', 'update_available': False, 'message': str(e)}
|
||||||
|
|
||||||
|
_update_check_cache['ts'] = now
|
||||||
|
_update_check_cache['data'] = data
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
@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)"""
|
||||||
@@ -1450,6 +1501,9 @@ def execute_system_action():
|
|||||||
cwd=project_dir
|
cwd=project_dir
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Invalidate update-check cache so the banner hides immediately
|
||||||
|
_update_check_cache.clear()
|
||||||
|
|
||||||
# Return custom response for git_pull
|
# Return custom response for git_pull
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
pull_message = "Code updated successfully."
|
pull_message = "Code updated successfully."
|
||||||
|
|||||||
@@ -1004,3 +1004,39 @@ button.bg-white {
|
|||||||
[data-theme="dark"] .theme-toggle-btn {
|
[data-theme="dark"] .theme-toggle-btn {
|
||||||
color: #fbbf24;
|
color: #fbbf24;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Update available banner */
|
||||||
|
.update-banner {
|
||||||
|
background-color: #eff6ff;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
.update-banner-action {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.update-banner-action:hover {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
.update-banner-dismiss {
|
||||||
|
color: #1e40af;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.update-banner-dismiss:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .update-banner {
|
||||||
|
background-color: #1e293b;
|
||||||
|
border-color: #334155;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .update-banner-action {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .update-banner-action:hover {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .update-banner-dismiss {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|||||||
@@ -931,6 +931,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Update available banner -->
|
||||||
|
<div id="update-banner" style="display:none"
|
||||||
|
class="update-banner border-b transition-all duration-300 ease-in-out">
|
||||||
|
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<i class="fas fa-arrow-circle-up text-lg"></i>
|
||||||
|
<span class="text-sm font-medium" id="update-banner-text">
|
||||||
|
A new LEDMatrix update is available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<button onclick="applyUpdate()" id="update-banner-btn"
|
||||||
|
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
|
||||||
|
update-banner-action transition-colors duration-150">
|
||||||
|
<i class="fas fa-download mr-1"></i> Update Now
|
||||||
|
</button>
|
||||||
|
<button onclick="dismissUpdateBanner()"
|
||||||
|
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
|
||||||
|
title="Dismiss">
|
||||||
|
<i class="fas fa-times text-sm"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
|
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
|
||||||
<!-- Navigation tabs -->
|
<!-- Navigation tabs -->
|
||||||
@@ -4888,6 +4915,72 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Update banner logic -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
|
||||||
|
var _dismissed = false;
|
||||||
|
|
||||||
|
function checkForUpdate() {
|
||||||
|
fetch('/api/v3/system/check-update')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.update_available && !_dismissed) {
|
||||||
|
var n = data.commits_behind || 0;
|
||||||
|
var msg = 'A new LEDMatrix update is available';
|
||||||
|
if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
|
||||||
|
document.getElementById('update-banner-text').textContent = msg;
|
||||||
|
document.getElementById('update-banner').style.display = '';
|
||||||
|
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
|
||||||
|
} else {
|
||||||
|
document.getElementById('update-banner').style.display = 'none';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dismissUpdateBanner = function() {
|
||||||
|
_dismissed = true;
|
||||||
|
document.getElementById('update-banner').style.display = 'none';
|
||||||
|
try { sessionStorage.setItem('update-dismissed', '1'); } catch(e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.applyUpdate = function() {
|
||||||
|
var btn = document.getElementById('update-banner-btn');
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Updating...';
|
||||||
|
btn.disabled = true;
|
||||||
|
fetch('/api/v3/system/action', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'git_pull' })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
document.getElementById('update-banner').style.display = 'none';
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification(data.message || 'Update complete', data.status || 'success');
|
||||||
|
}
|
||||||
|
try { sessionStorage.removeItem('update-dismissed'); } catch(e) {}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
btn.innerHTML = '<i class="fas fa-download mr-1"></i> Update Now';
|
||||||
|
btn.disabled = false;
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('Update failed — check your connection', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// On load: skip if dismissed this session for the same SHA
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem('update-dismissed') === '1') _dismissed = true;
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// Initial check shortly after page load, then periodic
|
||||||
|
setTimeout(checkForUpdate, 2000);
|
||||||
|
setInterval(checkForUpdate, CHECK_INTERVAL);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user