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.
This commit is contained in:
LeoVasanko committed 2026-09-25 18:01:05 +00:00
1 parent 204c6ab608
commit 7780d190d0
5 files changed
+216 -34

No files matched your search

+19 -2
View File
@@ -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/<range>` 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
+176 -28
View File
@@ -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
+8 -1
View File
@@ -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
+3 -3
View File
@@ -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 }
}
+10
View File
@@ -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")) {