feat(web): update-available banner in web UI#311
Conversation
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>
📝 WalkthroughWalkthroughA new update-availability checking system is introduced. The backend provides a cached API endpoint using git commands to detect available updates, while the frontend displays an update banner with dismiss and apply functionality. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Browser
participant SessionStorage
participant Server
participant Git
Browser->>+Server: GET /api/v3/system/check-update (every 30 min)
Server->>+Git: git fetch origin main
Git-->>-Server: fetch complete
Server->>Git: git rev-parse HEAD
Server->>Git: git rev-parse origin/main
Server->>Git: git log HEAD..origin/main --oneline
Git-->>Server: commit info
Server-->>-Browser: {update_available, local_sha, remote_sha, commits_behind}
Browser->>SessionStorage: Check update-dismissed flag
alt update_available && !dismissed
Browser->>Browser: Show update banner
else
Browser->>Browser: Hide update banner
end
User->>Browser: Click "Update Now"
Browser->>+Server: POST /api/v3/system/action {action: 'git_pull'}
Server->>Git: Execute git pull
Git-->>Server: pull complete
Server->>Server: Clear _update_check_cache
Server-->>-Browser: Success response
Browser->>Browser: Hide banner & show notification
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~30 minutes 🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@web_interface/blueprints/api_v3.py`:
- Around line 1344-1345: The shared cache _update_check_cache is accessed
concurrently causing race conditions; protect all reads and writes (including
clear() and checks using _UPDATE_CHECK_TTL) by introducing a dedicated
threading.Lock (e.g., _update_check_lock) and wrapping every access to
_update_check_cache and its keys in a with _update_check_lock block inside
check_for_update(), git_pull(), and any other functions that touch the cache so
ts/data are updated and read atomically.
- Around line 1370-1386: The current logic treats any local_sha != remote_sha as
an available update; change it to detect whether origin/main is actually ahead
of the checkout by running a git compare and only set update_available=True when
the remote has commits the local lacks. For example, after obtaining local_sha
and remote_sha (existing variables), run a git rev-list --left-right --count
local_sha...origin/main (or use git rev-list local_sha..origin/main) in
project_dir and parse the counts to determine commits_behind (remote-only
count); set update_available = True only when commits_behind > 0 and populate
latest_message/commits_behind from that result (use the existing log_result
parsing for commit messages when commits_behind > 0).
- Around line 1357-1377: The git subprocess calls in the update-check block (the
fetch, the two rev-parse calls that populate local_sha/remote_sha, and the git
log that assigns log_result) must have their return codes validated before
building a success payload; update the code around subprocess.run calls in
api_v3.py to either use check=True or inspect each CompletedProcess.returncode
and on failure log/process stderr (from .stderr) and return an error payload
(status: "error" or similar) instead of treating empty/stale SHAs as success,
ensuring you don't cache a success response when any of fetch, rev-parse, or log
failed; reference variables: the subprocess.run for fetch, local_sha assignment,
remote_sha assignment, and log_result when adding these checks.
In `@web_interface/templates/v3/base.html`:
- Around line 951-955: The dismiss button markup that invokes
dismissUpdateBanner() and uses class "update-banner-dismiss" is icon-only and
needs explicit accessibility attributes: add aria-label="Dismiss update" (or
similar descriptive text) to the <button> and also include type="button" to
avoid default form-submit behavior if moved inside a form; keep the existing
title if desired for tooltip support.
- Around line 4948-4972: applyUpdate currently hides the `#update-banner` and
removes sessionStorage regardless of whether the API succeeded, and only
restores the `#update-banner-btn` state in the .catch branch, which can leave the
button stuck; change applyUpdate to check the real success condition (e.g.,
inspect fetch Response.ok before calling r.json() or check data.status from the
parsed JSON) and only hide `#update-banner` and remove sessionStorage when the
response indicates success, while ensuring the button (element id
update-banner-btn) is always restored to its original innerHTML and
disabled=false in both success and error paths (use a finally-equivalent or
duplicate the restore logic), and still call showNotification with the
message/status returned by the API.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 0491dab3-8779-4b1a-9e5f-f8116dcba8aa
📒 Files selected for processing (3)
web_interface/blueprints/api_v3.pyweb_interface/static/v3/app.cssweb_interface/templates/v3/base.html
| _update_check_cache: Dict = {} | ||
| _UPDATE_CHECK_TTL = 300 # 5 minutes |
There was a problem hiding this comment.
Synchronize the update cache.
This cache is shared across requests, but ts and data are read/written independently. A second request can observe ts before data is set, or hit git_pull's clear() between the freshness check and the read, which makes check_for_update() intermittently raise KeyError or serve stale state.
🔒 Suggested fix
+import threading
+
_update_check_cache: Dict = {}
+_update_check_lock = threading.Lock()
_UPDATE_CHECK_TTL = 300 # 5 minutes
@@
import time as _time
now = _time.time()
- if _update_check_cache.get('ts', 0) + _UPDATE_CHECK_TTL > now:
- return jsonify(_update_check_cache['data'])
+ with _update_check_lock:
+ cached = _update_check_cache.copy()
+ if cached.get('ts', 0) + _UPDATE_CHECK_TTL > now and 'data' in cached:
+ return jsonify(cached['data'])
@@
- _update_check_cache['ts'] = now
- _update_check_cache['data'] = data
+ with _update_check_lock:
+ _update_check_cache.clear()
+ _update_check_cache.update({'ts': now, 'data': data})
return jsonify(data)
@@
- _update_check_cache.clear()
+ with _update_check_lock:
+ _update_check_cache.clear()Also applies to: 1352-1353, 1391-1392, 1504-1505
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web_interface/blueprints/api_v3.py` around lines 1344 - 1345, The shared
cache _update_check_cache is accessed concurrently causing race conditions;
protect all reads and writes (including clear() and checks using
_UPDATE_CHECK_TTL) by introducing a dedicated threading.Lock (e.g.,
_update_check_lock) and wrapping every access to _update_check_cache and its
keys in a with _update_check_lock block inside check_for_update(), git_pull(),
and any other functions that touch the cache so ts/data are updated and read
atomically.
| 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 | ||
| ) |
There was a problem hiding this comment.
Check each git command result before building a success payload.
These subprocess calls only fail via non-zero returncode; they do not raise by default. Right now a failed fetch/rev-parse/log can still fall through as "status": "success" with empty or stale SHAs, and that bad payload gets cached for 5 minutes.
🛑 Suggested fix
project_dir = str(PROJECT_ROOT)
try:
- subprocess.run(
+ fetch_result = subprocess.run(
['git', 'fetch', 'origin', 'main'],
capture_output=True, text=True, timeout=15, cwd=project_dir
)
- local_sha = subprocess.run(
+ if fetch_result.returncode != 0:
+ raise RuntimeError(fetch_result.stderr.strip() or 'git fetch origin main failed')
+
+ local_result = subprocess.run(
['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=5, cwd=project_dir
- ).stdout.strip()
- remote_sha = subprocess.run(
+ )
+ if local_result.returncode != 0:
+ raise RuntimeError(local_result.stderr.strip() or 'git rev-parse HEAD failed')
+ 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
- ).stdout.strip()
+ )
+ if remote_result.returncode != 0:
+ raise RuntimeError(remote_result.stderr.strip() or 'git rev-parse origin/main failed')
+ remote_sha = remote_result.stdout.strip()As per coding guidelines, "Validate inputs and handle errors early (Fail Fast principle)".
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 | |
| ) | |
| 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(fetch_result.stderr.strip() or 'git fetch origin main failed') | |
| 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(local_result.stderr.strip() or 'git rev-parse HEAD failed') | |
| 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(remote_result.stderr.strip() or 'git rev-parse origin/main failed') | |
| 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 | |
| ) |
🧰 Tools
🪛 Ruff (0.15.10)
[error] 1358-1358: Starting a process with a partial executable path
(S607)
[error] 1362-1362: Starting a process with a partial executable path
(S607)
[error] 1366-1366: Starting a process with a partial executable path
(S607)
[error] 1375-1375: Starting a process with a partial executable path
(S607)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web_interface/blueprints/api_v3.py` around lines 1357 - 1377, The git
subprocess calls in the update-check block (the fetch, the two rev-parse calls
that populate local_sha/remote_sha, and the git log that assigns log_result)
must have their return codes validated before building a success payload; update
the code around subprocess.run calls in api_v3.py to either use check=True or
inspect each CompletedProcess.returncode and on failure log/process stderr (from
.stderr) and return an error payload (status: "error" or similar) instead of
treating empty/stale SHAs as success, ensuring you don't cache a success
response when any of fetch, rev-parse, or log failed; reference variables: the
subprocess.run for fetch, local_sha assignment, remote_sha assignment, and
log_result when adding these checks.
| 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 '', | ||
| } |
There was a problem hiding this comment.
Only mark updates available when this checkout is actually behind origin/main.
local_sha != remote_sha is also true when the repo is ahead of origin/main or has diverged from it. In those cases this endpoint still reports update_available=True, which doesn't match the feature contract and will surface a false banner.
↕️ Suggested fix
- if local_sha == remote_sha:
+ counts_result = subprocess.run(
+ ['git', 'rev-list', '--left-right', '--count', 'HEAD...origin/main'],
+ capture_output=True, text=True, timeout=5, cwd=project_dir
+ )
+ ahead_count, behind_count = map(int, counts_result.stdout.strip().split())
+
+ if behind_count == 0:
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),
+ 'commits_behind': behind_count,
'latest_message': lines[0].split(' ', 1)[1] if lines else '',
}🧰 Tools
🪛 Ruff (0.15.10)
[error] 1375-1375: Starting a process with a partial executable path
(S607)
[error] 1378-1378: Ambiguous variable name: l
(E741)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web_interface/blueprints/api_v3.py` around lines 1370 - 1386, The current
logic treats any local_sha != remote_sha as an available update; change it to
detect whether origin/main is actually ahead of the checkout by running a git
compare and only set update_available=True when the remote has commits the local
lacks. For example, after obtaining local_sha and remote_sha (existing
variables), run a git rev-list --left-right --count local_sha...origin/main (or
use git rev-list local_sha..origin/main) in project_dir and parse the counts to
determine commits_behind (remote-only count); set update_available = True only
when commits_behind > 0 and populate latest_message/commits_behind from that
result (use the existing log_result parsing for commit messages when
commits_behind > 0).
| <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> |
There was a problem hiding this comment.
Add an explicit accessible label to the icon-only dismiss button.
The close control is icon-only and currently relies on title. Please add aria-label (and preferably type="button" for safety if this ever moves inside a form).
♿ Suggested patch
- <button onclick="dismissUpdateBanner()"
+ <button type="button"
+ onclick="dismissUpdateBanner()"
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
- title="Dismiss">
+ title="Dismiss"
+ aria-label="Dismiss update banner">
<i class="fas fa-times text-sm"></i>
</button>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web_interface/templates/v3/base.html` around lines 951 - 955, The dismiss
button markup that invokes dismissUpdateBanner() and uses class
"update-banner-dismiss" is icon-only and needs explicit accessibility
attributes: add aria-label="Dismiss update" (or similar descriptive text) to the
<button> and also include type="button" to avoid default form-submit behavior if
moved inside a form; keep the existing title if desired for tooltip support.
| 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'); | ||
| } | ||
| }); | ||
| }; |
There was a problem hiding this comment.
Only hide the banner on successful update, and always restore button state.
applyUpdate() currently hides the banner for any JSON response and only re-enables the button in .catch(). If the API returns an error payload (or the banner reappears later), the button can stay stuck in “Updating…”.
🐛 Suggested patch
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) {}
+ .then(function(r) {
+ return r.json().then(function(data) {
+ return { ok: r.ok, data: data };
+ });
+ })
+ .then(function(res) {
+ var data = res.data || {};
+ var isSuccess = res.ok && data.status === 'success';
+ if (isSuccess) {
+ document.getElementById('update-banner').style.display = 'none';
+ try { sessionStorage.removeItem('update-dismissed'); } catch(e) {}
+ }
+ if (typeof showNotification === 'function') {
+ showNotification(
+ data.message || (isSuccess ? 'Update complete' : 'Update failed'),
+ isSuccess ? 'success' : 'error'
+ );
+ }
})
.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');
}
+ })
+ .finally(function() {
+ btn.innerHTML = '<i class="fas fa-download mr-1"></i> Update Now';
+ btn.disabled = false;
});
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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'); | |
| } | |
| }); | |
| }; | |
| 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) { | |
| return { ok: r.ok, data: data }; | |
| }); | |
| }) | |
| .then(function(res) { | |
| var data = res.data || {}; | |
| var isSuccess = res.ok && data.status === 'success'; | |
| if (isSuccess) { | |
| document.getElementById('update-banner').style.display = 'none'; | |
| try { sessionStorage.removeItem('update-dismissed'); } catch(e) {} | |
| } | |
| if (typeof showNotification === 'function') { | |
| showNotification( | |
| data.message || (isSuccess ? 'Update complete' : 'Update failed'), | |
| isSuccess ? 'success' : 'error' | |
| ); | |
| } | |
| }) | |
| .catch(function() { | |
| if (typeof showNotification === 'function') { | |
| showNotification('Update failed — check your connection', 'error'); | |
| } | |
| }) | |
| .finally(function() { | |
| btn.innerHTML = '<i class="fas fa-download mr-1"></i> Update Now'; | |
| btn.disabled = false; | |
| }); | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web_interface/templates/v3/base.html` around lines 4948 - 4972, applyUpdate
currently hides the `#update-banner` and removes sessionStorage regardless of
whether the API succeeded, and only restores the `#update-banner-btn` state in the
.catch branch, which can leave the button stuck; change applyUpdate to check the
real success condition (e.g., inspect fetch Response.ok before calling r.json()
or check data.status from the parsed JSON) and only hide `#update-banner` and
remove sessionStorage when the response indicates success, while ensuring the
button (element id update-banner-btn) is always restored to its original
innerHTML and disabled=false in both success and error paths (use a
finally-equivalent or duplicate the restore logic), and still call
showNotification with the message/status returned by the API.
Summary
origin/mainGET /api/v3/system/check-updateendpoint compares local HEAD vs origin/main SHA (cached 5 min)git_pullaction; banner hides on successTest plan
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes