feat: self-update checker with one-click update for WebUI + Agent
Shows a blue banner when the webui or hermes-agent git repos are behind
their upstream branches. One-click 'Update Now' button does stash, pull
--ff-only, stash pop, then reloads the page.
Backend (api/updates.py):
- _check_repo(): git fetch + rev-list count with 15s timeout
- check_for_updates(): 30-min server-side cache, thread-safe, skips
Docker (no .git dir)
- apply_update(): stash (if dirty), pull --ff-only, pop, invalidate cache
Routes:
- GET /api/updates/check -- returns cached {webui, agent} with behind count
- POST /api/updates/apply -- {target: 'webui'|'agent'}
Frontend:
- Blue banner (matches reconnect-banner pattern) with 'Later' / 'Update Now'
- Non-blocking boot check via fire-and-forget .then(), once per tab session
- sessionStorage guards prevent re-checking and re-showing after dismiss
Settings:
- 'Check for updates' checkbox (default: on) -- when off, no git operations
- Removed 'Default Workspace' dropdown to keep settings panel compact
Performance:
- Server cache: git fetch at most 2x/hour regardless of client count
- sessionStorage: one check per browser tab session
- _check_in_progress flag prevents concurrent fetch storms
- Fire-and-forget: does NOT block the boot sequence
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -674,6 +674,7 @@ _SETTINGS_DEFAULTS = {
|
|||||||
'show_token_usage': False, # show input/output token badge below assistant messages
|
'show_token_usage': False, # show input/output token badge below assistant messages
|
||||||
'show_cli_sessions': False, # merge CLI sessions from state.db into the sidebar
|
'show_cli_sessions': False, # merge CLI sessions from state.db into the sidebar
|
||||||
'sync_to_insights': False, # mirror WebUI token usage to state.db for /insights
|
'sync_to_insights': False, # mirror WebUI token usage to state.db for /insights
|
||||||
|
'check_for_updates': True, # check if webui/agent repos are behind upstream
|
||||||
'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes)
|
'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes)
|
||||||
'password_hash': None, # SHA-256 hash; None = auth disabled
|
'password_hash': None, # SHA-256 hash; None = auth disabled
|
||||||
}
|
}
|
||||||
@@ -694,7 +695,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
|
|||||||
_SETTINGS_ENUM_VALUES = {
|
_SETTINGS_ENUM_VALUES = {
|
||||||
'send_key': {'enter', 'ctrl+enter'},
|
'send_key': {'enter', 'ctrl+enter'},
|
||||||
}
|
}
|
||||||
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights'}
|
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights', 'check_for_updates'}
|
||||||
|
|
||||||
def save_settings(settings: dict) -> dict:
|
def save_settings(settings: dict) -> dict:
|
||||||
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
||||||
|
|||||||
@@ -227,6 +227,14 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
info = git_info_for_workspace(Path(s.workspace))
|
info = git_info_for_workspace(Path(s.workspace))
|
||||||
return j(handler, {'git': info})
|
return j(handler, {'git': info})
|
||||||
|
|
||||||
|
if parsed.path == '/api/updates/check':
|
||||||
|
settings = load_settings()
|
||||||
|
if not settings.get('check_for_updates', True):
|
||||||
|
return j(handler, {'disabled': True})
|
||||||
|
force = parse_qs(parsed.query).get('force', ['0'])[0] == '1'
|
||||||
|
from api.updates import check_for_updates
|
||||||
|
return j(handler, check_for_updates(force=force))
|
||||||
|
|
||||||
if parsed.path == '/api/chat/stream/status':
|
if parsed.path == '/api/chat/stream/status':
|
||||||
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
|
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
|
||||||
return j(handler, {'active': stream_id in STREAMS, 'stream_id': stream_id})
|
return j(handler, {'active': stream_id in STREAMS, 'stream_id': stream_id})
|
||||||
@@ -600,6 +608,14 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
if parsed.path == '/api/session/import':
|
if parsed.path == '/api/session/import':
|
||||||
return _handle_session_import(handler, body)
|
return _handle_session_import(handler, body)
|
||||||
|
|
||||||
|
# ── Self-update (POST) ──
|
||||||
|
if parsed.path == '/api/updates/apply':
|
||||||
|
target = body.get('target', '')
|
||||||
|
if target not in ('webui', 'agent'):
|
||||||
|
return bad(handler, 'target must be "webui" or "agent"')
|
||||||
|
from api.updates import apply_update
|
||||||
|
return j(handler, apply_update(target))
|
||||||
|
|
||||||
# ── CLI session import (POST) ──
|
# ── CLI session import (POST) ──
|
||||||
if parsed.path == '/api/session/import_cli':
|
if parsed.path == '/api/session/import_cli':
|
||||||
return _handle_session_import_cli(handler, body)
|
return _handle_session_import_cli(handler, body)
|
||||||
|
|||||||
153
api/updates.py
Normal file
153
api/updates.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
Hermes Web UI -- Self-update checker.
|
||||||
|
|
||||||
|
Checks if the webui and hermes-agent git repos are behind their upstream
|
||||||
|
branches. Results are cached server-side (30-min TTL) so git fetch runs
|
||||||
|
at most twice per hour regardless of client count.
|
||||||
|
|
||||||
|
Skips repos that are not git checkouts (e.g. Docker baked images where
|
||||||
|
.git does not exist).
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from api.config import REPO_ROOT
|
||||||
|
|
||||||
|
# Lazy -- may be None if agent not found
|
||||||
|
try:
|
||||||
|
from api.config import _AGENT_DIR
|
||||||
|
except ImportError:
|
||||||
|
_AGENT_DIR = None
|
||||||
|
|
||||||
|
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0}
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
_check_in_progress = False
|
||||||
|
CACHE_TTL = 1800 # 30 minutes
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(args, cwd, timeout=10):
|
||||||
|
"""Run a git command and return (stdout, ok)."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
['git'] + args, cwd=str(cwd), capture_output=True,
|
||||||
|
text=True, timeout=timeout,
|
||||||
|
)
|
||||||
|
return r.stdout.strip(), r.returncode == 0
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
return '', False
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_default_branch(path):
|
||||||
|
"""Detect the remote default branch (master or main)."""
|
||||||
|
out, ok = _run_git(['symbolic-ref', 'refs/remotes/origin/HEAD'], path)
|
||||||
|
if ok and out:
|
||||||
|
# refs/remotes/origin/master -> master
|
||||||
|
return out.split('/')[-1]
|
||||||
|
# Fallback: try master, then main
|
||||||
|
for branch in ('master', 'main'):
|
||||||
|
_, ok = _run_git(['rev-parse', '--verify', f'origin/{branch}'], path)
|
||||||
|
if ok:
|
||||||
|
return branch
|
||||||
|
return 'master'
|
||||||
|
|
||||||
|
|
||||||
|
def _check_repo(path, name):
|
||||||
|
"""Check if a git repo is behind its upstream. Returns dict or None."""
|
||||||
|
if path is None or not (path / '.git').exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fetch latest from origin (network call, cached by TTL)
|
||||||
|
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
|
||||||
|
if not fetch_ok:
|
||||||
|
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
|
||||||
|
|
||||||
|
branch = _detect_default_branch(path)
|
||||||
|
|
||||||
|
# Count commits behind
|
||||||
|
out, ok = _run_git(['rev-list', '--count', f'HEAD..origin/{branch}'], path)
|
||||||
|
behind = int(out) if ok and out.isdigit() else 0
|
||||||
|
|
||||||
|
# Get short SHAs for display
|
||||||
|
current, _ = _run_git(['rev-parse', '--short', 'HEAD'], path)
|
||||||
|
latest, _ = _run_git(['rev-parse', '--short', f'origin/{branch}'], path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'behind': behind,
|
||||||
|
'current_sha': current,
|
||||||
|
'latest_sha': latest,
|
||||||
|
'branch': branch,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_for_updates(force=False):
|
||||||
|
"""Return cached update status for webui and agent repos."""
|
||||||
|
global _check_in_progress
|
||||||
|
with _cache_lock:
|
||||||
|
if not force and time.time() - _update_cache['checked_at'] < CACHE_TTL:
|
||||||
|
return dict(_update_cache)
|
||||||
|
if _check_in_progress:
|
||||||
|
return dict(_update_cache) # another thread is already checking
|
||||||
|
_check_in_progress = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run checks outside the lock (network I/O)
|
||||||
|
webui_info = _check_repo(REPO_ROOT, 'webui')
|
||||||
|
agent_info = _check_repo(_AGENT_DIR, 'agent')
|
||||||
|
|
||||||
|
with _cache_lock:
|
||||||
|
_update_cache['webui'] = webui_info
|
||||||
|
_update_cache['agent'] = agent_info
|
||||||
|
_update_cache['checked_at'] = time.time()
|
||||||
|
return dict(_update_cache)
|
||||||
|
finally:
|
||||||
|
_check_in_progress = False
|
||||||
|
|
||||||
|
|
||||||
|
def apply_update(target):
|
||||||
|
"""Stash, pull --ff-only, pop for the given target repo."""
|
||||||
|
if target == 'webui':
|
||||||
|
path = REPO_ROOT
|
||||||
|
elif target == 'agent':
|
||||||
|
path = _AGENT_DIR
|
||||||
|
else:
|
||||||
|
return {'ok': False, 'message': f'Unknown target: {target}'}
|
||||||
|
|
||||||
|
if path is None or not (path / '.git').exists():
|
||||||
|
return {'ok': False, 'message': 'Not a git repository'}
|
||||||
|
|
||||||
|
branch = _detect_default_branch(path)
|
||||||
|
|
||||||
|
# Check for dirty working tree
|
||||||
|
status_out, _ = _run_git(['status', '--porcelain'], path)
|
||||||
|
stashed = False
|
||||||
|
if status_out:
|
||||||
|
_, ok = _run_git(['stash'], path)
|
||||||
|
if not ok:
|
||||||
|
return {'ok': False, 'message': 'Failed to stash local changes'}
|
||||||
|
stashed = True
|
||||||
|
|
||||||
|
# Pull with ff-only (no merge commits)
|
||||||
|
pull_out, pull_ok = _run_git(['pull', '--ff-only', 'origin', branch], path, timeout=30)
|
||||||
|
if not pull_ok:
|
||||||
|
if stashed:
|
||||||
|
_run_git(['stash', 'pop'], path)
|
||||||
|
return {'ok': False, 'message': f'Pull failed: {pull_out[:200]}'}
|
||||||
|
|
||||||
|
# Pop stash if we stashed
|
||||||
|
if stashed:
|
||||||
|
_, pop_ok = _run_git(['stash', 'pop'], path)
|
||||||
|
if not pop_ok:
|
||||||
|
return {
|
||||||
|
'ok': False,
|
||||||
|
'message': 'Updated but stash pop failed -- manual merge needed',
|
||||||
|
'stash_conflict': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Invalidate cache
|
||||||
|
with _cache_lock:
|
||||||
|
_update_cache['checked_at'] = 0
|
||||||
|
|
||||||
|
return {'ok': True, 'message': f'{target} updated successfully', 'target': target}
|
||||||
@@ -308,7 +308,12 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
|
|||||||
|
|
||||||
(async()=>{
|
(async()=>{
|
||||||
// Load send key preference
|
// Load send key preference
|
||||||
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;}
|
let _bootSettings={};
|
||||||
|
try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;}
|
||||||
|
// Non-blocking update check (fire-and-forget, once per tab session)
|
||||||
|
if(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed')){
|
||||||
|
api('/api/updates/check').then(d=>{sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
|
||||||
|
}
|
||||||
// Fetch active profile
|
// Fetch active profile
|
||||||
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
||||||
// Update profile chip label immediately
|
// Update profile chip label immediately
|
||||||
|
|||||||
@@ -203,6 +203,13 @@
|
|||||||
<div class="messages-inner" id="msgInner"></div>
|
<div class="messages-inner" id="msgInner"></div>
|
||||||
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="update-banner" id="updateBanner">
|
||||||
|
<span id="updateMsg"></span>
|
||||||
|
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||||
|
<button class="update-btn" onclick="dismissUpdate()">Later</button>
|
||||||
|
<button class="update-btn update-primary" id="btnApplyUpdate" onclick="applyUpdates()">Update Now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="reconnect-banner" id="reconnectBanner">
|
<div class="reconnect-banner" id="reconnectBanner">
|
||||||
<span id="reconnectMsg">⚠ A response may have been in progress when you last left. Reload messages?</span>
|
<span id="reconnectMsg">⚠ A response may have been in progress when you last left. Reload messages?</span>
|
||||||
<div style="display:flex;gap:8px;flex-shrink:0">
|
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||||
@@ -319,10 +326,6 @@
|
|||||||
<label for="settingsModel">Default Model</label>
|
<label for="settingsModel">Default Model</label>
|
||||||
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field">
|
|
||||||
<label for="settingsWorkspace">Default Workspace</label>
|
|
||||||
<select id="settingsWorkspace" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
|
|
||||||
</div>
|
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
<label for="settingsSendKey">Send Key</label>
|
<label for="settingsSendKey">Send Key</label>
|
||||||
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||||
@@ -362,6 +365,13 @@
|
|||||||
</label>
|
</label>
|
||||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
|
<div style="font-size:11px;color:var(--muted);margin-top:4px">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="settingsCheckUpdates" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||||
|
Check for updates
|
||||||
|
</label>
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-top:4px">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
|
||||||
|
</div>
|
||||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||||
<label for="settingsPassword">Access Password</label>
|
<label for="settingsPassword">Access Password</label>
|
||||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||||
|
|||||||
@@ -1022,6 +1022,8 @@ async function loadSettingsPanel(){
|
|||||||
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
const syncCb=$('settingsSyncInsights');
|
const syncCb=$('settingsSyncInsights');
|
||||||
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
|
const updateCb=$('settingsCheckUpdates');
|
||||||
|
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
// Password field: always blank (we don't send hash back)
|
// Password field: always blank (we don't send hash back)
|
||||||
const pwField=$('settingsPassword');
|
const pwField=$('settingsPassword');
|
||||||
if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});}
|
if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});}
|
||||||
@@ -1055,6 +1057,7 @@ async function saveSettings(andClose){
|
|||||||
body.show_token_usage=showTokenUsage;
|
body.show_token_usage=showTokenUsage;
|
||||||
body.show_cli_sessions=showCliSessions;
|
body.show_cli_sessions=showCliSessions;
|
||||||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||||||
|
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||||||
// Password: only act if the field has content; blank = leave auth unchanged
|
// Password: only act if the field has content; blank = leave auth unchanged
|
||||||
if(pw && pw.trim()){
|
if(pw && pw.trim()){
|
||||||
try{
|
try{
|
||||||
|
|||||||
@@ -145,6 +145,13 @@
|
|||||||
.reconnect-banner.visible{display:flex;}
|
.reconnect-banner.visible{display:flex;}
|
||||||
.reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;}
|
.reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;}
|
||||||
.reconnect-btn:hover{background:rgba(201,168,76,0.25);}
|
.reconnect-btn:hover{background:rgba(201,168,76,0.25);}
|
||||||
|
/* ── Update banner ── */
|
||||||
|
.update-banner{display:none;background:var(--surface);border:1px solid rgba(124,185,255,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--blue);align-items:center;justify-content:space-between;gap:12px;}
|
||||||
|
.update-banner.visible{display:flex;}
|
||||||
|
.update-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.3);color:var(--blue);cursor:pointer;transition:background .15s;}
|
||||||
|
.update-btn:hover{background:rgba(124,185,255,0.2);}
|
||||||
|
.update-primary{background:rgba(124,185,255,0.2);border-color:rgba(124,185,255,0.5);}
|
||||||
|
.update-btn:disabled{opacity:0.5;cursor:not-allowed;}
|
||||||
/* ── Approval card ── */
|
/* ── Approval card ── */
|
||||||
.approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;}
|
.approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;}
|
||||||
.approval-card.visible{display:block;}
|
.approval-card.visible{display:block;}
|
||||||
|
|||||||
41
static/ui.js
41
static/ui.js
@@ -334,6 +334,47 @@ async function refreshSession() {
|
|||||||
showToast('Conversation refreshed');
|
showToast('Conversation refreshed');
|
||||||
} catch(e) { setStatus('Refresh failed: ' + e.message); }
|
} catch(e) { setStatus('Refresh failed: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
// ── Update banner ──
|
||||||
|
function _showUpdateBanner(data){
|
||||||
|
const parts=[];
|
||||||
|
if(data.webui&&data.webui.behind>0) parts.push(`WebUI: ${data.webui.behind} update${data.webui.behind>1?'s':''}`);
|
||||||
|
if(data.agent&&data.agent.behind>0) parts.push(`Agent: ${data.agent.behind} update${data.agent.behind>1?'s':''}`);
|
||||||
|
if(!parts.length)return;
|
||||||
|
const msg=$('updateMsg');
|
||||||
|
if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available';
|
||||||
|
const banner=$('updateBanner');
|
||||||
|
if(banner) banner.classList.add('visible');
|
||||||
|
window._updateData=data;
|
||||||
|
}
|
||||||
|
function dismissUpdate(){
|
||||||
|
const b=$('updateBanner');if(b)b.classList.remove('visible');
|
||||||
|
sessionStorage.setItem('hermes-update-dismissed','1');
|
||||||
|
}
|
||||||
|
async function applyUpdates(){
|
||||||
|
const btn=$('btnApplyUpdate');
|
||||||
|
if(btn){btn.disabled=true;btn.textContent='Updating\u2026';}
|
||||||
|
const targets=[];
|
||||||
|
if(window._updateData?.webui?.behind>0) targets.push('webui');
|
||||||
|
if(window._updateData?.agent?.behind>0) targets.push('agent');
|
||||||
|
try{
|
||||||
|
for(const target of targets){
|
||||||
|
const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target})});
|
||||||
|
if(!res.ok){
|
||||||
|
showToast('Update failed ('+target+'): '+(res.message||'unknown error'));
|
||||||
|
if(btn){btn.disabled=false;btn.textContent='Update Now';}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast('Updated! Reloading\u2026');
|
||||||
|
sessionStorage.removeItem('hermes-update-checked');
|
||||||
|
sessionStorage.removeItem('hermes-update-dismissed');
|
||||||
|
setTimeout(()=>location.reload(),1500);
|
||||||
|
}catch(e){
|
||||||
|
showToast('Update failed: '+e.message);
|
||||||
|
if(btn){btn.disabled=false;btn.textContent='Update Now';}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function checkInflightOnBoot(sid) {
|
async function checkInflightOnBoot(sid) {
|
||||||
const raw = localStorage.getItem(INFLIGHT_KEY);
|
const raw = localStorage.getItem(INFLIGHT_KEY);
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user