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:
1 parent
204c6ab608
commit
7780d190d0
5 files changed
+216
-34
No files matched your search
+19
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
Reference in new issue
Block a user