diff --git a/lang/de/common.php b/lang/de/common.php index 18de928..5b39e5b 100644 --- a/lang/de/common.php +++ b/lang/de/common.php @@ -2,11 +2,15 @@ return [ 'close' => 'Schließen', - 'tagline' => 'Kontrollzentrum für Ihre Cloud.', - 'sign_in' => 'Anmelden', - 'save' => 'Speichern', - 'cancel' => 'Abbrechen', - 'online' => 'Online', - 'offline' => 'Offline', - 'loading' => 'Wird geladen …', + 'tagline' => 'Kontrollzentrum für Ihre Cloud.', + 'sign_in' => 'Anmelden', + 'save' => 'Speichern', + 'cancel' => 'Abbrechen', + 'online' => 'Online', + 'offline' => 'Offline', + 'loading' => 'Wird geladen …', + + // Connection banner (resources/views/components/shell/connection-banner.blade.php). + 'connection_lost' => 'Keine Verbindung', + 'connection_restored' => 'Verbindung wiederhergestellt', ]; diff --git a/lang/en/common.php b/lang/en/common.php index aef88d2..c8c0054 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -2,11 +2,15 @@ return [ 'close' => 'Close', - 'tagline' => 'Control center for your cloud.', - 'sign_in' => 'Sign in', - 'save' => 'Save', - 'cancel' => 'Cancel', - 'online' => 'Online', - 'offline' => 'Offline', - 'loading' => 'Loading …', + 'tagline' => 'Control center for your cloud.', + 'sign_in' => 'Sign in', + 'save' => 'Save', + 'cancel' => 'Cancel', + 'online' => 'Online', + 'offline' => 'Offline', + 'loading' => 'Loading …', + + // Connection banner (resources/views/components/shell/connection-banner.blade.php). + 'connection_lost' => 'No connection', + 'connection_restored' => 'Connection restored', ]; diff --git a/resources/js/app.js b/resources/js/app.js index d94387d..e09e2b0 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -72,24 +72,106 @@ function applyLineGradients(cfg) { } } -// A page that was open across a deploy carries a Livewire snapshot and a CSRF -// token the new code no longer accepts. The user sees "419 Page Expired" and a -// dead interface; the session itself is still fine, so a reload fixes it. +// ── Connection health: a silent 419 recovery, and a banner for everything +// that is not a Livewire-recognised failure at all ───────────────────── // -// Hooked at the network layer rather than through Livewire's request hook: the -// hook's failure callback is not invoked for this case in every version, and a -// silent no-op here is exactly the bug we are trying to remove. +// Livewire's OWN default handling of a failed request (vendor/livewire/ +// livewire/dist/livewire.js, js/request/index.js — read directly, not +// assumed, since the previous comment here turned out to be wrong about it) +// is not what it looks like from the outside: +// +// - A 419 (expired session or a stale CSRF token) triggers a native +// "This page has expired…" browser dialog (window's own confirm prompt) +// and THEN, whatever the operator answers, the same full-page HTML +// overlay used for a 500 — both fire unconditionally unless the request +// hook's preventDefault() is called. +// - A request that never gets a response at all (offline, DNS failure, +// connection refused — fetch() itself throwing) is already silent: no +// overlay, no dialog, nothing. Correct for case 3 below, but it also +// means a dropped connection currently gives the operator no feedback +// whatsoever — the gap the banner fills. +// - Anything else (500 and friends) is untouched — Livewire's own overlay +// is the right behaviour for a real server error; this project shows +// failures rather than hiding them (R22). +// +// preventDefault() runs synchronously inside Livewire's own fail(), before +// it decides whether to show that dialog and the overlay — so calling it +// here is not a race against window.location.reload() the way triggering +// the reload alone (the previous approach) was. +// +// A page left open across a deploy, or simply idle long enough, carries a +// CSRF token the server no longer accepts. Reloading gets a fresh one for a +// session that is still valid, and lands an actually-expired session +// straight on the sign-in page — both through completely ordinary Laravel +// middleware, nothing bespoke. +// +// sessionStorage survives the reload (same tab) but not a closed one: +// remembering that a reload was already tried means a SECOND 419 inside the +// window below is treated as "reloading did not fix it" rather than looped +// on forever, and sends the operator to sign in directly instead. A 419 in a +// fresh tab, or one long after the window has passed, always gets its own +// reload first. +const RELOAD_GUARD_KEY = 'clupilot:419-reload-at'; +const RELOAD_GUARD_WINDOW_MS = 15000; + +function recoverFrom419() { + const triedAt = Number(sessionStorage.getItem(RELOAD_GUARD_KEY) || 0); + + if (Date.now() - triedAt < RELOAD_GUARD_WINDOW_MS) { + sessionStorage.removeItem(RELOAD_GUARD_KEY); + // Each shell carries its OWN sign-in route on its
(R21: the + // console and the portal do not share one) — see layouts/admin.blade.php + // and layouts/portal-app.blade.php. + window.location.href = document.body.dataset.loginUrl || '/login'; + + return; + } + + sessionStorage.setItem(RELOAD_GUARD_KEY, String(Date.now())); + window.location.reload(); +} + +document.addEventListener('livewire:init', () => { + window.Livewire.hook('request', ({ fail }) => { + fail(({ status, preventDefault }) => { + if (status !== 419) return; + + preventDefault(); + recoverFrom419(); + }); + }); +}); + +// Connection banner ("Keine Verbindung" / "Verbindung wiederhergestellt" — +// see connectionBanner() below): navigator.onLine and the browser's +// online/offline events are both used, but neither is trusted alone — +// onLine is only ever a hint (true on a wifi with no real route out, a +// captive portal, a VPN that dropped without the OS noticing), which is why +// an actual Livewire request completing or failing at the network level is +// wired in as a third, stronger signal. +// +// Wrapping fetch (module load, before Livewire's own bundle sends anything) +// rather than reading the request hook's fail() for this part: fail() only +// fires for a request Livewire itself made, and represents a thrown fetch as +// a synthetic status-503 failure — recognisable, but wrapping fetch directly +// observes the same thrown error without leaning on that internal detail. const originalFetch = window.fetch.bind(window); window.fetch = async (...args) => { - const response = await originalFetch(...args); + const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? ''; + const isLivewireRequest = url.includes('/livewire/'); - if (response.status === 419) { - const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? ''; - if (url.includes('/livewire/')) { - window.location.reload(); - } + let response; + try { + response = await originalFetch(...args); + } catch (error) { + if (isLivewireRequest) window.dispatchEvent(new CustomEvent('connection-lost')); + throw error; } + // Any response at all — even a 500 — proves the network path to our own + // server works, which is all "back online" means here. + if (isLivewireRequest) window.dispatchEvent(new CustomEvent('connection-restored')); + return response; }; @@ -347,4 +429,41 @@ document.addEventListener('alpine:init', () => { this.instance?.destroy(); }, })); + + // ── Connection banner ────────────────────────────────────────────────── + // "Keine Verbindung" while offline, "Verbindung wiederhergestellt" for a + // few seconds once a signal below says it is back, then nothing. Bound + // once per page in each shell layout, listening for both the browser's + // own online/offline events and the connection-lost/connection-restored + // events the fetch wrapper above dispatches for real Livewire traffic. + // Whichever notices first wins; a wrong optimistic "restored" from the + // browser's online event self-corrects the moment the next Livewire + // request actually fails again. + window.Alpine.data('connectionBanner', () => ({ + // A page opened while already offline should say so immediately + // rather than wait for the first failed request. + offline: !navigator.onLine, + justRestored: false, + restoreTimer: null, + + markOffline() { + clearTimeout(this.restoreTimer); + this.justRestored = false; + this.offline = true; + }, + + markOnline() { + // Nothing to announce if this page was never marked offline. + if (!this.offline) return; + + this.offline = false; + this.justRestored = true; + clearTimeout(this.restoreTimer); + this.restoreTimer = setTimeout(() => { this.justRestored = false; }, 3000); + }, + + destroy() { + clearTimeout(this.restoreTimer); + }, + })); }); diff --git a/resources/views/components/shell/connection-banner.blade.php b/resources/views/components/shell/connection-banner.blade.php new file mode 100644 index 0000000..b63a5d8 --- /dev/null +++ b/resources/views/components/shell/connection-banner.blade.php @@ -0,0 +1,47 @@ +{{-- + "Keine Verbindung" / "Verbindung wiederhergestellt" — shared by both + shells (console and portal), unlike the toast a few lines away in each + layout. Deliberately its own element rather than reusing that toast: + + - The toast always self-dismisses after a fixed 3.2s. A dropped + connection can easily outlast that, and the message quietly + disappearing while still offline would read as "resolved" when it is + not. + - Both are `fixed`, bottom-anchored pills; keeping them at different + corners means an unrelated toast firing while this is up (a + client-side-only action, e.g. the VPN config "copied") never overlaps + it. + + The two icons are both always in the DOM with x-show toggling between + them, rather than one icon whose :class is swapped, so R18's "never + larger than the call site asked for" stays trivially true either way. + + See connectionBanner() in resources/js/app.js for the three signals + (navigator.onLine, the online/offline window events, and real Livewire + request failures) that drive offline/justRestored. +--}} +