From 7780d190d0ba08fb210c31b07e1b4f5bf14d3d15 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 25 Sep 2026 18:01:05 +0000 Subject: [PATCH] Analytics viewer: auth-aware reconnect, wake recovery, sliding time window - Expired session dropped the socket into an endless paced retry loop the WS handshake can never escape: probe /_api/settings on close, run the paskia login flow on 401/403 and reconnect the moment it resolves; an aborted login leaves for the front page (history replace). - Follow paskia's auth-success/auth-logout window messages: logins anywhere in the page reconnect at once instead of waiting out the backoff; logout leaves /_a. pagerite.js re-probes its pens on the same events. - Recycle the socket on visibilitychange/online/bfcache restore: a socket that survived sleep looks OPEN but is dead, so no updates ever landed. - The stats range window was frozen at mount (no reactive time dependency); it now slides on a minute clock. - Transition map beads: fast-forward emitter schedules after long gaps instead of spawning a bead per missed interval in one tick. --- docs/analytics.md | 21 +++- frontend/src/AnalyticsView.vue | 204 ++++++++++++++++++++++++++----- frontend/src/TransitionGraph.vue | 9 +- frontend/src/analytics/time.js | 6 +- frontend/src/pagerite.js | 10 ++ 5 files changed, 216 insertions(+), 34 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index 59ec41e..af86caa 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -341,8 +341,8 @@ the edit pens) links to `/_a`, the analytics page. It is a normal site page: the standard banner, navigation and footer stay in place, and the analytics content is rendered inside `#main`. The page itself is public, but the data stream comes from `WebSocket /_api/ws/analytics`, which remains admin-gated -like the rest of the management API; visitors without access see the viewer -with a "could not be loaded" message. +like the rest of the management API; without a session the viewer starts the +paskia login flow instead of loading data. Because it is a real page, fetch-navigation handles it like any other internal link: clicking the πŸ“Š pen (or any link to `/_a`) fetches the server-rendered @@ -355,6 +355,23 @@ spans less than 24 hours, otherwise `week`. `AnalyticsView.vue` is no longer a full-screen overlay; the `body.analytics-open` page-chrome hiding and `#/analytics/` hash routing have been removed. +The viewer owns its connection lifecycle. When the socket drops it probes +`/_api/settings` (plain `fetchJson`, never pops a dialog) to tell an expired +session from a dead network: a 401/403 runs the paskia login flow instead of +retrying (the WS handshake cannot open the login dialog) and the stream +reconnects the moment the login completes; an aborted login (declined, or a +proxy offering no paskia flow) leaves for the front page with a history +replace, so Back does not return to `/_a` just to pop the login again. +Anything else retries with the shared paced backoff (`reconnect.js`). +paskia's `auth-success`/`auth-logout` window messages are followed too: a +login anywhere in the page (the banner's πŸ”‘ button included) reconnects the +stream at once, never waiting out a backoff timer, and a logout leaves for +the front page. Waking the page (`visibilitychange`, `online`, a bfcache +restore) recycles the socket immediately β€” one that survived sleep still +looks open but is dead β€” so the fresh snapshot lands right away, and the +stats window slides on a minute clock so an open page never leaves +"last 24 hours" frozen at mount. + Charts are SVG curves (Catmull-Rom over an edge-aware Gaussian β€” a change-point detector splits the series at traffic-level shifts, then each segment is smoothed independently with a fixed sigma chosen so N events in diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index 8b2e815..cfb821d 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -5,7 +5,7 @@ // visit/crawler tables. Read-only. // See docs/analytics.md for the data format. import { computed, onMounted, onUnmounted, ref, watch } from 'vue' -import { apiJson } from 'paskia' +import { ApiError, NetworkError, apiJson, fetchJson } from 'paskia' import { RANGES, rangeWindow, @@ -43,18 +43,24 @@ const pageTree = ref(null) const error = ref('') const now = ref(Date.now()) let ws = null -let reconnectTimeout = null +let reconnectTimer = null let connectWatchdog = null const reconnects = reconnectPolicy() let timeInterval = null +let mounted = false +let epoch = 0 // bumped on every intentional (re)connect; stale async tails bail +let authFlow = false // our paskia login flow (apiJson) is pending +let hiddenAt = 0 -// The panel is live data over its socket: while it is connecting or waiting -// to reconnect, say so (ConnNote) instead of showing a silent stale view. -const conn = ref('connecting') // connecting | open | waiting +// The panel is live data over its socket: while it is connecting, waiting to +// reconnect or logged out, say so (ConnNote / the login line) instead of +// showing a silent stale view. +const conn = ref('connecting') // connecting | open | waiting | login const retryIn = ref(0) const connNote = computed(() => conn.value === 'connecting' ? 'connecting to the server…' : conn.value === 'waiting' ? `connection lost β€” reconnecting in ~${retryIn.value} s…` + : conn.value === 'login' ? 'login required β€” authenticate to continue…' : '', ) @@ -65,8 +71,8 @@ const hashRange = location.hash.slice(1) const range = ref(RANGES[hashRange] ? hashRange : 'week') let rangePinned = Boolean(RANGES[hashRange]) -function connectAnalytics() { - if (ws) return +function connect() { + if (ws || !mounted) return conn.value = 'connecting' const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`) @@ -93,37 +99,175 @@ function connectAnalytics() { error.value = 'analytics data could not be loaded' } } - ws.onerror = () => { - error.value = 'analytics data could not be loaded' + // No error banner from onerror: it is always followed by onclose, which + // probes the cause β€” a lost login gets the login flow, not an error loop. + ws.onclose = onSocketClose +} + +// The socket dropped. Find out why before retrying: an expired session fails +// the handshake exactly like a dead network, but retrying the socket loops +// forever β€” the WS API cannot open the login dialog. Probe /_api with plain +// fetchJson (it never pops a dialog): 401/403 means the session is gone and +// the login flow runs instead of another blind retry; anything else is +// transient and gets the paced backoff. +async function onSocketClose() { + ws = null + if (!mounted) return + const gen = epoch + const wait = reconnects.closed() + let denied = false + try { + await fetchJson('/_api/settings') + } catch (e) { + denied = e instanceof ApiError && (e.status === 401 || e.status === 403) } - ws.onclose = () => { + if (!mounted || gen !== epoch) return + if (denied) beginLogin() + else scheduleRetry(wait) +} + +function scheduleRetry(wait) { + clearTimeout(reconnectTimer) + retryIn.value = Math.max(1, Math.round(wait / 1000)) + conn.value = 'waiting' + reconnectTimer = setTimeout(connect, wait) +} + +// The session is missing or expired: run the paskia login flow instead of +// retrying. apiJson pauses on the 401/403 while the auth iframe completes and +// retries the request, so resolving means we are in β€” reconnect at once, no +// backoff. An aborted login (declined, or a proxy offering no paskia flow) +// leaves for the front page: the viewer is useless without a session. +async function beginLogin() { + if (authFlow || !mounted) return + clearTimeout(reconnectTimer) + reconnectTimer = null + conn.value = 'login' + authFlow = true + try { + await apiJson('/_api/settings') + } catch (e) { + if (mounted) { + if (e instanceof NetworkError + || (e instanceof ApiError && e.status !== 401 && e.status !== 403)) { + // The server vanished mid-login: back to paced retries. + scheduleRetry(reconnects.closed()) + } else { + goHome() + } + } + return + } finally { + authFlow = false + } + loadPageTree() // the mount-time fetch may have run anonymous + connectNow() +} + +// Leave for the front page, replacing the history entry: Back must not +// return to /_a just to pop the login flow again. +function goHome() { + location.replace('/') +} + +// Connect (or recycle) right away, skipping any pending backoff: used after +// login and when the page wakes β€” a socket that survived sleep still looks +// OPEN but is dead (the server gave up on it while we were asleep), and only +// a fresh connect pushes the full snapshot that catches the view up. +function connectNow() { + epoch++ + clearTimeout(reconnectTimer) + reconnectTimer = null + if (ws) { + ws.onclose = null + ws.close() ws = null - // The policy paces the retry: doubling backoff with jitter, reset only - // by a healthy connection β€” a fixed rapid loop trips the browser's - // WebSocket throttling (all sockets then sit "pending" for minutes). - const wait = reconnects.closed() - retryIn.value = Math.max(1, Math.round(wait / 1000)) - conn.value = 'waiting' - reconnectTimeout = setTimeout(connectAnalytics, wait) + } + connect() +} + +// Waking up (sleep, or a long while hidden): recycle the connection rather +// than trust a zombie socket or wait out a stale backoff. A short tab switch +// with a live socket is left alone. +const WAKE_RECYCLE_MS = 30_000 + +function onVisibility() { + if (document.visibilityState === 'hidden') { + hiddenAt = Date.now() + return + } + now.value = Date.now() + if (!mounted || conn.value === 'login') return + const longAway = hiddenAt && Date.now() - hiddenAt > WAKE_RECYCLE_MS + hiddenAt = 0 + if (conn.value !== 'open' || longAway) connectNow() +} + +function onOnline() { + if (mounted && conn.value !== 'open' && conn.value !== 'login') connectNow() +} + +function onPageShow(ev) { + // A bfcache restore comes back with dead sockets. + if (ev.persisted && mounted) connectNow() +} + +// paskia's auth iframe announces its result as a window message: a login +// completed anywhere in the page (the banner's πŸ”‘ button or our own flow) +// reconnects at once instead of waiting out the backoff timer; a logout +// leaves for the front page β€” the stream is useless without a session. +function onAuthMessage(ev) { + if (!mounted) return + const type = ev.data?.type + if (type === 'auth-success') { + if (authFlow || conn.value === 'open') return + loadPageTree() + connectNow() + } else if (type === 'auth-logout') { + epoch++ + clearTimeout(reconnectTimer) + reconnectTimer = null + if (ws) { + ws.onclose = null + ws.close() + ws = null + } + goHome() } } -onMounted(async () => { +// The site tree for the transition map (all pages in menu order). Not fatal: +// without it the map just narrows to pages seen in transitions. Plain +// fetchJson: the login flow is the socket's business, this fetch never pops +// a dialog. +async function loadPageTree() { + try { + pageTree.value = await fetchJson('/_api/pages') + } catch { /* map just narrows to pages seen in transitions */ } +} + +onMounted(() => { + mounted = true // The first connection takes a staggered slot (see ./reconnect). - reconnectTimeout = setTimeout(connectAnalytics, socketSlot()) + reconnectTimer = setTimeout(connect, socketSlot()) now.value = Date.now() timeInterval = setInterval(() => { now.value = Date.now() }, 1000) - // The site tree for the transition map (all pages in menu order). Not - // fatal: without it the map just narrows to pages seen in transitions. - try { - pageTree.value = await apiJson('/_api/pages') - } catch { /* map just narrows to pages seen in transitions */ } + addEventListener('visibilitychange', onVisibility) + addEventListener('online', onOnline) + addEventListener('pageshow', onPageShow) + addEventListener('message', onAuthMessage) + loadPageTree() }) onUnmounted(() => { - if (reconnectTimeout) clearTimeout(reconnectTimeout) - if (connectWatchdog) clearTimeout(connectWatchdog) - if (timeInterval) clearInterval(timeInterval) + mounted = false + clearTimeout(reconnectTimer) + clearTimeout(connectWatchdog) + clearInterval(timeInterval) + removeEventListener('visibilitychange', onVisibility) + removeEventListener('online', onOnline) + removeEventListener('pageshow', onPageShow) + removeEventListener('message', onAuthMessage) if (ws) { ws.onclose = null ws.close() @@ -131,7 +275,11 @@ onUnmounted(() => { } }) -const window = computed(() => rangeWindow(range.value)) +// Minute-granular clock for the stats window: the range filters slide with +// time (a page left open must not leave "last 24 hours" frozen at mount) +// without re-filtering every second. +const windowMinute = computed(() => Math.floor(now.value / 60_000) * 60_000) +const window = computed(() => rangeWindow(range.value, windowMinute.value)) // All non-chart stats follow the selected range; the charts keep their own // range-specific x windows (week aligned to Monday, overlaid with the diff --git a/frontend/src/TransitionGraph.vue b/frontend/src/TransitionGraph.vue index fea6913..4b4ceb6 100644 --- a/frontend/src/TransitionGraph.vue +++ b/frontend/src/TransitionGraph.vue @@ -121,9 +121,16 @@ const syncBeads = (flows) => { } const tick = (t) => { - const dt = lastTick ? (t - lastTick) / 1000 : 0 + // Long gaps (sleep, hidden tab) must not spawn a bead per missed interval + // just to drop them all in the same tick: clamp the step (every in-flight + // bead simply completes) and fast-forward each emitter's schedule to its + // next future slot, keeping the phase. + const dt = lastTick ? Math.min((t - lastTick) / 1000, TRAVERSAL_S) : 0 lastTick = t for (const e of emitters.values()) { + if (e.next < t - e.interval) { + e.next = t + (((e.next - t) % e.interval) + e.interval) % e.interval + } while (e.next <= t) { live.push({ e, p: 0 }) e.next += e.interval diff --git a/frontend/src/analytics/time.js b/frontend/src/analytics/time.js index eb711e6..68d6465 100644 --- a/frontend/src/analytics/time.js +++ b/frontend/src/analytics/time.js @@ -190,11 +190,11 @@ export function makeSeries(buckets, rangeKey) { * Every bounded range is a rolling span ending at now; the charts instead * align week to Monday 00:00 UTC (overlaying previous weeks) and month+ * to UTC day boundaries, so their x windows differ from the stats range - * on purpose. + * on purpose. `now` is passed in by reactive callers so the window slides + * as time passes instead of freezing at mount. * Returns { t0, t1 } where null means unbounded. */ -export function rangeWindow(rangeKey) { - const now = Date.now() +export function rangeWindow(rangeKey, now = Date.now()) { if (rangeKey === 'all') { return { t0: null, t1: null } } diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index ae6cf86..2f4c2ec 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -293,6 +293,16 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; if (ev.persisted) setupAuth(); }); + // paskia's auth iframe announces its result as a window message: a login + // or logout completed anywhere in the page (e.g. the analytics view's + // login flow) re-probes auth and re-renders the pens. setupAuth is + // idempotent, so the profile button's own re-probe after the same login + // is a harmless duplicate. + addEventListener("message", (ev) => { + const type = ev.data?.type; + if (type === "auth-success" || type === "auth-logout") setupAuth(); + }); + function runScripts(root) { // Scripts inserted via DOM swapping do not execute; re-create them. for (const old of root.querySelectorAll("script")) {