diff --git a/docs/pr-media/1321/update-network-error.png b/docs/pr-media/1321/update-network-error.png new file mode 100644 index 00000000..7c438a14 Binary files /dev/null and b/docs/pr-media/1321/update-network-error.png differ diff --git a/static/ui.js b/static/ui.js index 4b93eb29..73c10756 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3202,8 +3202,29 @@ function dismissUpdate(){ const b=$('updateBanner');if(b)b.classList.remove('visible'); sessionStorage.setItem('hermes-update-dismissed','1'); } +function _isUpdateApplyNetworkError(error){ + const message=(error&&error.message)||String(error||''); + return /Failed to fetch|NetworkError|Load failed/i.test(message); +} +function _formatUpdateApplyExceptionMessage(error){ + if(_isUpdateApplyNetworkError(error)){ + return 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.'; + } + const message=(error&&error.message)||String(error||'unknown error'); + return 'Update failed: '+message; +} async function applyUpdates(){ + if(window._updateApplyInFlight) return; + window._updateApplyInFlight=true; const btn=$('btnApplyUpdate'); + const resetApplyButton=(delayMs)=>{ + const reset=()=>{ + window._updateApplyInFlight=false; + if(btn){btn.disabled=false;btn.textContent='Update Now';} + }; + if(delayMs>0) setTimeout(reset,delayMs); + else reset(); + }; if(btn){btn.disabled=true;btn.textContent='Updating\u2026';} const errEl=$('updateError'); if(errEl){errEl.style.display='none';errEl.textContent='';} @@ -3219,7 +3240,7 @@ async function applyUpdates(){ const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target})}); if(!res.ok){ _showUpdateError(target,res); - if(btn){btn.disabled=false;btn.textContent='Update Now';} + resetApplyButton(0); return; } } @@ -3228,9 +3249,10 @@ async function applyUpdates(){ sessionStorage.removeItem('hermes-update-dismissed'); _waitForServerThenReload(); }catch(e){ - if(errEl){errEl.textContent='Update failed: '+e.message;errEl.style.display='block';} - else showToast('Update failed: '+e.message); - if(btn){btn.disabled=false;btn.textContent='Update Now';} + const msg=_formatUpdateApplyExceptionMessage(e); + if(errEl){errEl.textContent=msg;errEl.style.display='block';} + else showToast(msg); + resetApplyButton(_isUpdateApplyNetworkError(e)?5000:0); } } function _showUpdateError(target,res){ diff --git a/tests/test_update_apply_ui.py b/tests/test_update_apply_ui.py new file mode 100644 index 00000000..c0747c15 --- /dev/null +++ b/tests/test_update_apply_ui.py @@ -0,0 +1,42 @@ +"""Frontend regression coverage for Update Now apply failures (#1321).""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = ROOT / "static" / "ui.js" + + +def _ui_js() -> str: + return UI_JS.read_text(encoding="utf-8") + + +def test_update_apply_network_error_has_recovery_message_not_raw_failed_to_fetch(): + """Network/interrupted update apply failures should not surface raw fetch text alone.""" + src = _ui_js() + assert "function _formatUpdateApplyExceptionMessage" in src + assert "could not reach the WebUI server" in src + assert "restarted or the connection was interrupted" in src + assert "wait a few seconds, reload the page, then check the server" in src + assert "Update failed: '+e.message" not in src + assert 'Update failed: "+e.message' not in src + + +def test_update_apply_structured_server_errors_still_use_json_message_path(): + """Server-reachable JSON errors must keep the existing targeted message path.""" + src = _ui_js() + apply_start = src.index("async function applyUpdates()") + show_error_call = src.index("_showUpdateError(target,res);", apply_start) + reset_button = src.index("resetApplyButton(0);", show_error_call) + assert show_error_call < reset_button + assert "const msg='Update failed ('+target+'): '+(res.message||'unknown error');" in src + + +def test_update_apply_prevents_duplicate_apply_requests_while_in_flight(): + """Double-clicks should not send a second update apply request during restart race windows.""" + src = _ui_js() + apply_start = src.index("async function applyUpdates()") + next_fn = src.index("function _showUpdateError", apply_start) + body = src[apply_start:next_fn] + assert "window._updateApplyInFlight" in body + assert "if(window._updateApplyInFlight) return;" in body + assert "window._updateApplyInFlight=true;" in body + assert "window._updateApplyInFlight=false;" in body