diff --git a/web/static/css/custom.css b/web/static/css/custom.css index f6385133..6aed4c91 100644 --- a/web/static/css/custom.css +++ b/web/static/css/custom.css @@ -452,6 +452,7 @@ footer { .di-pill--completed { --accent: #4CD964; --accent-dim: rgba(76,217,100,0.2); --accent-glow: rgba(76,217,100,0.15); } .di-pill--completed-errors { --accent: #FF9500; --accent-dim: rgba(255,149,0,0.2); --accent-glow: rgba(255,149,0,0.15); } .di-pill--idle { --accent: #e5a00d; --accent-dim: rgba(229,160,13,0.15); --accent-glow: rgba(229,160,13,0.1); } +.di-pill--disconnected { --accent: #FF3B30; --accent-dim: rgba(255,59,48,0.2); --accent-glow: rgba(255,59,48,0.15); } /* Slide-down entrance */ @keyframes diSlideDown { @@ -585,6 +586,7 @@ footer { /* Error — red glow border */ .di-pill--error { border: 1.5px solid rgba(255,59,48,0.3); } +.di-pill--disconnected { border: 1.5px solid rgba(255,59,48,0.3); } /* Running pills use a fixed width in compact mode to prevent jumpiness as content changes (phase labels, counts, durations) every 2s poll */ diff --git a/web/templates/components/global_operation_banner.html b/web/templates/components/global_operation_banner.html index d976e29f..bb51fd04 100644 --- a/web/templates/components/global_operation_banner.html +++ b/web/templates/components/global_operation_banner.html @@ -305,9 +305,66 @@ } }); +// ── Connection-lost detection ───────────────────────────────────────── +// Track consecutive banner poll failures. After 3 failures, replace the +// pill with a "Connection Lost" state so the user knows the server is +// unreachable (instead of the banner just freezing on its last state). +var _diBannerFailCount = 0; +var _diBannerDisconnected = false; + +document.addEventListener('htmx:sendError', function(evt) { + if (!evt.detail.elt || !evt.detail.elt.closest('#global-operation-banner')) return; + _diBannerFailCount++; + if (_diBannerFailCount >= 3 && !_diBannerDisconnected) { + _diBannerDisconnected = true; + var banner = document.getElementById('global-operation-banner'); + if (!banner) return; + // Preserve expansion state on outer div — only replace inner content + banner.innerHTML = + '
' + + '
' + + '
' + + '
' + + ' Connection Lost' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + ' Server Unreachable' + + '
' + + '
' + + '
' + + ' Unable to reach the server. Retrying…' + + '
' + + '
' + + '
'; + if (typeof lucide !== 'undefined') lucide.createIcons(); + if (typeof htmx !== 'undefined') htmx.process(banner); + } +}); + +// Also catch HTTP errors (5xx) on the banner poll as connection issues +document.addEventListener('htmx:responseError', function(evt) { + if (!evt.detail.elt || !evt.detail.elt.closest('#global-operation-banner')) return; + _diBannerFailCount++; + if (_diBannerFailCount >= 3 && !_diBannerDisconnected) { + // Trigger the same logic — dispatch a synthetic sendError + document.dispatchEvent(new CustomEvent('htmx:sendError', { + detail: { elt: evt.detail.elt } + })); + } +}); + // After every HTMX swap: re-render icons, sync animations, one-shot entrance document.addEventListener('htmx:afterSwap', function(evt) { if (evt.detail.target && evt.detail.target.id === 'global-operation-banner') { + // Successful swap — reset connection failure tracking + _diBannerFailCount = 0; + _diBannerDisconnected = false; + if (typeof lucide !== 'undefined') lucide.createIcons(); // Sync looping animations to wall clock so they don't visibly reset on 2s swap.