From acaf8a248ece272eb46b0f5b39cf625bb9268bff Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:38:03 -0400 Subject: [PATCH] feat(web): update-available banner in web UI (#311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * fix(update-banner): address review findings — lock, returncode checks, update_available logic, a11y, button state - Add _update_check_lock (threading.Lock) around all reads/writes to _update_check_cache in check_for_update() and git_pull, preventing races on concurrent requests - Validate returncode for git fetch, rev-parse HEAD, and rev-parse origin/main; raise RuntimeError on failure so errors are caught and returned as error payloads instead of silently producing stale/empty SHAs - Set update_available = commits_behind > 0 (was unconditionally True when local_sha != remote_sha); prevents false positive when local is ahead of remote - Add type="button" and aria-label="Dismiss update" to the icon-only dismiss button - Restore btn.innerHTML and btn.disabled in both success and error paths of applyUpdate(); only hide the banner and clear sessionStorage when data.status === 'success' Co-Authored-By: Claude Sonnet 4.6 * fix(update-banner): address second-round review findings api_v3.py: - Move all git work inside _update_check_lock so concurrent requests re-check cache staleness after acquiring the lock; only the first caller runs git fetch/rev-parse/log, subsequent callers return the cached result - Check log_result.returncode and raise on failure so a broken git log doesn't produce a silent false-negative (commits_behind=0) - Rename loop variable l → commit_line base.html: - Replace boolean _dismissed flag with SHA-scoped sessionStorage key 'update-sha-dismissed'; dismissing for SHA X still allows the banner to reappear when origin/main advances to SHA Y - Successful applyUpdate clears 'update-sha-dismissed' so the next update cycle can show the banner again - Add aria-live="polite" aria-atomic="true" to #update-banner-text so screen readers announce content changes Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Chuck Co-authored-by: Claude Opus 4.6 (1M context) --- web_interface/blueprints/api_v3.py | 70 ++++++++++++++++++++ web_interface/static/v3/app.css | 36 ++++++++++ web_interface/templates/v3/base.html | 99 ++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index dbe4d016..1eae921e 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -9,6 +9,7 @@ import time import hashlib import uuid import logging +import threading from datetime import datetime, timezone from pathlib import Path from typing import Optional, Tuple, Dict, Any, Type @@ -1626,6 +1627,71 @@ def get_system_version(): logger.exception("[System] get_system_version failed") return jsonify({'status': 'error', 'message': 'Failed to get system version'}), 500 +_update_check_cache: Dict = {} +_UPDATE_CHECK_TTL = 300 # 5 minutes +_update_check_lock = threading.Lock() + +@api_v3.route('/system/check-update', methods=['GET']) +def check_for_update(): + """Check if a newer version is available on the remote.""" + now = time.time() + project_dir = str(PROJECT_ROOT) + with _update_check_lock: + if _update_check_cache.get('ts', 0) + _UPDATE_CHECK_TTL > now: + return jsonify(_update_check_cache['data']) + + try: + fetch_result = subprocess.run( + ['git', 'fetch', 'origin', 'main'], + capture_output=True, text=True, timeout=15, cwd=project_dir + ) + if fetch_result.returncode != 0: + raise RuntimeError(f"git fetch failed: {fetch_result.stderr.strip()}") + + local_result = subprocess.run( + ['git', 'rev-parse', 'HEAD'], + capture_output=True, text=True, timeout=5, cwd=project_dir + ) + if local_result.returncode != 0: + raise RuntimeError(f"git rev-parse HEAD failed: {local_result.stderr.strip()}") + local_sha = local_result.stdout.strip() + + remote_result = subprocess.run( + ['git', 'rev-parse', 'origin/main'], + capture_output=True, text=True, timeout=5, cwd=project_dir + ) + if remote_result.returncode != 0: + raise RuntimeError(f"git rev-parse origin/main failed: {remote_result.stderr.strip()}") + remote_sha = remote_result.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 + ) + if log_result.returncode != 0: + raise RuntimeError(f"git log failed: {log_result.stderr.strip()}") + lines = [commit_line for commit_line in log_result.stdout.strip().split('\n') if commit_line] + commits_behind = len(lines) + data = { + 'status': 'success', + 'update_available': commits_behind > 0, + 'local_sha': local_sha[:8], + 'remote_sha': remote_sha[:8], + 'commits_behind': commits_behind, + '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']) def execute_system_action(): """Execute system actions (start/stop/reboot/etc)""" @@ -1735,6 +1801,10 @@ def execute_system_action(): cwd=project_dir ) + # Invalidate update-check cache so the banner hides immediately + with _update_check_lock: + _update_check_cache.clear() + # Return custom response for git_pull if result.returncode == 0: pull_message = "Code updated successfully." diff --git a/web_interface/static/v3/app.css b/web_interface/static/v3/app.css index 38036194..8894b9de 100644 --- a/web_interface/static/v3/app.css +++ b/web_interface/static/v3/app.css @@ -1004,3 +1004,39 @@ button.bg-white { [data-theme="dark"] .theme-toggle-btn { 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; +} diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index a6aa748d..8e26515f 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -900,6 +900,34 @@ + + +
@@ -4874,6 +4902,77 @@ + +