Files
pagerite/frontend/src/AnalyticsView.vue
T
LeoVasanko afa6b967e6 contact messages: unified overlay, corner ✉️ icon for everyone
The admin inbox leaves the short-lived /_m page and joins the visitor panel as one overlay (ContactOverlay shell over paskia's shared blur backdrop, centered dialog, full-screen on mobile): the corner ✉️ icon opens the visitor thread for visitors and the wider sidebar inbox for admins, while {contact:} trigger buttons always open the visitor side so admins can test both. The icon highlights unread state without extra connections: visitors via localStorage seen/last-admin timestamps, admins via the contact_unread count added to the /_api/settings probe, both kept live by the bundle once loaded. MessagesView owns its admin socket only while the overlay is open.
2026-09-28 01:37:37 +00:00

687 lines
22 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
// Analytics viewer rendered as a normal page inside #main. Receives live
// analytics data over /_api/ws/analytics (admin-gated by the auth proxy) and
// renders totals, smoothed visit/views curves, a transition map, and recent
// visit/crawler tables. The same socket also carries the contact-messaging
// protocol (typed frames) for the admin inbox overlay (MessagesView.vue,
// docs/messages.md), so typed frames are ignored here.
// See docs/analytics.md for the data format.
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { fetchJson } from 'paskia'
import {
RANGES,
rangeWindow,
filterRecordsByRange,
filterTransitionsByRange,
filterViewsByRange,
} from './analytics/time.js'
import {
calcReadStats,
calcTotalViews,
copyIp,
copyList,
formatCount,
formatAbuseRows,
formatCrawlerRows,
formatVisitRows,
} from './analytics/format.js'
import TrailLink from './TrailLink.vue'
import RefererBadge from './RefererBadge.vue'
import VisitorCell from './VisitorCell.vue'
import TransitionGraph from './TransitionGraph.vue'
import VisitorCharts from './VisitorCharts.vue'
import { VIEW_W } from './analytics/chart.js'
import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import ConnNote from './ConnNote.vue'
// Same centering margin as the charts, so the totals row's left edge
// aligns with the chart svg above the natural width.
const CHART_MARGIN = `max(0px, calc(50% - ${VIEW_W / 2}px))`
const ABUSE_MAX_LINES = 5
const data = ref(null)
const pageTree = ref(null)
const error = ref('')
const now = ref(Date.now())
let ws = 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 hiddenAt = 0
// 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…'
: '',
)
// The initial range comes from the URL hash (shareable links); without one,
// it is derived from the first analytics snapshot: day when the recorded
// history is shorter than 24 h, week otherwise.
const hashRange = location.hash.slice(1)
const range = ref(RANGES[hashRange] ? hashRange : 'week')
let rangePinned = Boolean(RANGES[hashRange])
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`)
clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'analytics')
ws.onopen = () => {
conn.value = 'open'
reconnects.opened()
error.value = ''
}
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
// Contact-messaging frames (docs/messages.md) carry a type field and
// are for the admin inbox overlay; the type-less snapshot is
// analytics data.
if (msg?.type) return
data.value = msg
if (!rangePinned) {
rangePinned = true
const starts = (data.value?.visits || [])
.map((v) => Date.parse(v.start))
.filter((t) => !Number.isNaN(t))
if (starts.length && Date.now() - Math.min(...starts) < 24 * 3600 * 1000) {
range.value = 'day'
}
}
} catch {
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 (./reconnect's
// authReconnect): 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. A lost session runs the paskia login flow and reconnects
// the moment it completes; an aborted login (declined, or a proxy offering
// no paskia flow) leaves for the front page — the viewer is useless without
// a session; anything else gets the paced backoff.
async function onSocketClose() {
ws = null
if (!mounted) return
const gen = epoch
const wait = reconnects.closed()
const outcome = await authReconnect(() => { conn.value = 'login' })
if (!mounted || gen !== epoch) return
if (outcome === 'auth') {
loadPageTree() // the mount-time fetch may have run anonymous
connectNow()
} else if (outcome === 'abort') {
goHome()
} else {
scheduleRetry(wait)
}
}
function scheduleRetry(wait) {
clearTimeout(reconnectTimer)
retryIn.value = Math.max(1, Math.round(wait / 1000))
conn.value = 'waiting'
reconnectTimer = setTimeout(connect, wait)
}
// 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
}
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 —
// 'login' means it is pending and will connect itself on resolve) 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 (conn.value === 'open' || conn.value === 'login') return
loadPageTree()
connectNow()
} else if (type === 'auth-logout') {
epoch++
clearTimeout(reconnectTimer)
reconnectTimer = null
if (ws) {
ws.onclose = null
ws.close()
ws = null
}
goHome()
}
}
// 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).
reconnectTimer = setTimeout(connect, socketSlot())
now.value = Date.now()
timeInterval = setInterval(() => { now.value = Date.now() }, 1000)
addEventListener('visibilitychange', onVisibility)
addEventListener('online', onOnline)
addEventListener('pageshow', onPageShow)
addEventListener('message', onAuthMessage)
loadPageTree()
})
onUnmounted(() => {
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()
ws = null
}
})
// 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
// seasonal "typical week" curve).
const rangeData = computed(() => {
if (!data.value) return null
const { t0, t1 } = window.value
return {
...data.value,
transitions: filterTransitionsByRange(data.value.transitions, t0, t1),
views: filterViewsByRange(data.value.views, t0, t1),
visits: filterRecordsByRange(data.value.visits, t0, t1),
crawlers: filterRecordsByRange(data.value.crawlers, t0, t1),
abuse: filterRecordsByRange(data.value.abuse, t0, t1),
}
})
const visits = computed(() => rangeData.value?.visits || [])
const totalViews = computed(() => calcTotalViews(rangeData.value?.views))
const readStats = computed(() => calcReadStats(visits.value))
// Keep the URL shareable when the range changes.
watch(range, (r) => {
const url = new URL(location.href)
url.hash = r
history.replaceState(history.state, '', url)
})
const clients = computed(() => data.value?.clients || {})
const favicons = computed(() => data.value?.favicons || {})
// Site language context from the payload: drives the discreet rendered-
// language markers in the visit/crawler rows (multilingual sites only).
const site = computed(() => ({
multilingual: !!data.value?.multilingual,
primaryLang: data.value?.primary_lang || '',
}))
const visitRows = computed(() => formatVisitRows(visits.value, clients.value, pageTree.value, now.value, site.value))
const crawlers = computed(() => rangeData.value?.crawlers || [])
const crawlerRows = computed(() => formatCrawlerRows(crawlers.value, clients.value, pageTree.value, now.value, site.value))
const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], clients.value, pageTree.value, now.value))
</script>
<template>
<!-- Untranslated admin dashboard: always LTR, like the editor panel. -->
<div class="analytics-view" lang="en" dir="ltr">
<div class="analytics-panel">
<header>
<h1>Analytics</h1>
<nav class="ranges">
<button v-for="(r, key) in RANGES" :key="key" type="button"
:class="{ active: range === key }" @click="range = key">
{{ r.label }}
</button>
</nav>
<a href="/" class="close" title="home">✕</a>
</header>
<ConnNote :text="connNote" />
<div>
<p v-if="error" class="error">⚠️ {{ error }}</p>
<p v-else-if="!data" class="loading">loading…</p>
<template v-else>
<section class="totals" :style="{ marginLeft: CHART_MARGIN }">
<div><strong :title="String(visits.length)">{{ formatCount(visits.length) }}</strong> visits</div>
<div><strong :title="String(totalViews)">{{ formatCount(totalViews) }}</strong> page views</div>
<div><strong>{{ readStats.avgMinPerVisit }}</strong> min/visit</div>
<div><strong>{{ readStats.avgArticleMedianMin }}</strong> min/read</div>
</section>
<VisitorCharts :data="data" :range="range" />
<TransitionGraph :data="rangeData" :window="window" :page-tree="pageTree" :favicons="favicons" />
<section>
<h2>Recent visits</h2>
<div v-if="visitRows.length" class="visit-table-wrap">
<table class="visit-table">
<thead>
<tr>
<th>trail</th>
<th>visitor</th>
<th class="last-seen">last seen</th>
</tr>
</thead>
<tbody>
<tr v-for="(v, i) in visitRows" :key="i">
<td class="trail">
<RefererBadge v-if="v.refererBadge" :badge="v.refererBadge" :favicons="favicons" />
<span v-if="v.rowFlag" class="flag" v-html="v.rowFlag" :title="v.rowFlagTitle"></span>
<TrailLink v-for="(s, si) in v.trail" :key="si" :step="s" :favicons="favicons" :flags="s.langFlags" @close="$emit('close')" />
</td>
<VisitorCell
:ip="v.ip"
:ip-display="v.ipDisplay"
:ua="v.ua"
:ua-raw="v.uaRaw"
:ua-url="v.uaUrl"
:country="v.country"
:city="v.city"
:lang="v.lang"
:lang-display="v.langDisplay"
:is-host="v.isHost"
/>
<td class="last-seen muted"
:title="v.lastSeenLocal"
@click="copyList(v.lastSeenIso, $event)">{{ v.lastSeen }}</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="empty">no visits recorded yet</p>
<div v-if="crawlerRows.length" class="visit-table-wrap">
<table class="visit-table">
<thead>
<tr>
<th>pages crawled</th>
<th>visitor</th>
<th class="last-seen">last seen</th>
</tr>
</thead>
<tbody>
<tr v-for="(c, i) in crawlerRows" :key="i">
<td class="trail">
<RefererBadge v-if="c.refererBadge" :badge="c.refererBadge" :favicons="favicons" />
<TrailLink v-for="(s, si) in c.pages" :key="si" :step="s" :count="s.count" @close="$emit('close')" />
<span v-for="(f, fi) in c.readFlags" :key="fi" class="flag" v-html="f.flag" :title="f.name"></span>
</td>
<VisitorCell
:ip="c.ip"
:ip-display="c.ipDisplay"
:ua="c.ua"
:ua-raw="c.uaRaw"
:ua-url="c.uaUrl"
:country="c.country"
:city="c.city"
:lang="c.lang"
:lang-display="c.langDisplay"
:is-host="c.isHost"
/>
<td class="last-seen muted"
:title="c.lastSeenLocal"
@click="copyList(c.lastSeenIso, $event)">{{ c.lastSeen }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="abuseRows.length" class="visit-table-wrap">
<table class="visit-table">
<thead>
<tr>
<th>paths abused</th>
<th>articles read</th>
<th>visitor</th>
<th class="last-seen">last seen</th>
</tr>
</thead>
<tbody>
<tr v-for="(a, i) in abuseRows" :key="i">
<td class="trail abuse-list clickable-list"
@click="copyList(a.allPaths, $event)">
<div class="abuse-items">
<span v-for="(p, pi) in a.paths.slice(0, ABUSE_MAX_LINES)" :key="pi"
class="inline-item">
<small v-if="p.count > 1" class="muted">{{ formatCount(p.count) }}×</small>{{ p.path }}
</span>
<small v-if="a.paths.length > ABUSE_MAX_LINES" class="muted">+{{ a.paths.length - ABUSE_MAX_LINES }} more</small>
</div>
</td>
<td class="trail clickable-list"
@click="copyList(a.allArticles, $event)">
<TrailLink v-for="(s, si) in a.articles" :key="si" :step="s" :count="s.count" @close="$emit('close')" />
<small v-if="!a.articles.length" class="muted">—</small>
</td>
<VisitorCell
:ip="a.ip"
:ip-display="a.ipDisplay"
:ua="a.ua"
:ua-raw="a.uaRaw"
:ua-url="a.uaUrl"
:ua-raws="a.uaRaws"
:country="a.country"
:city="a.city"
:lang="a.lang"
:lang-display="a.langDisplay"
:is-host="a.isHost"
:variant-count="a.clientCount"
/>
<td class="last-seen muted"
:title="a.lastSeenLocal"
@click="copyList(a.lastSeenIso, $event)">{{ a.lastSeen }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.analytics-view {
min-height: 100vh;
background: var(--bg, Canvas);
color: var(--text, CanvasText);
}
.analytics-panel {
margin: 0;
width: 100%;
/* Same 1.25rem side spacing as main's article padding. */
padding: 1.5rem 1.25rem 4rem;
/* Container for cqw-based shrink-to-fit (see .totals). */
container-type: inline-size;
}
.analytics-panel header {
display: flex;
align-items: center;
gap: 1rem;
}
.analytics-panel h1 {
margin: 0;
font-size: 1.4rem;
}
.ranges {
display: flex;
gap: 0.25rem;
margin-left: auto;
}
.ranges button {
padding: 0.2rem 0.7rem;
font: inherit;
font-size: 0.9rem;
color: var(--muted);
background: none;
border: 1px solid var(--line);
border-radius: 1rem;
cursor: pointer;
}
.ranges button:hover { color: var(--text); }
.ranges button.active {
color: var(--text);
border-color: var(--accent);
}
.close {
padding: 0 0.3rem;
background: none;
border: none;
color: var(--muted);
font-size: 1.2rem;
cursor: pointer;
}
.close:hover { color: var(--text); }
.analytics-panel h2 {
margin: 0 0 0.6rem;
font-size: 1rem;
color: var(--muted);
}
.analytics-panel section {
margin-top: 1.8rem;
}
.analytics-view a {
color: var(--text);
text-decoration: none;
}
.analytics-view a:hover { color: var(--accent); }
.analytics-view :deep(.muted) { color: var(--muted); }
.analytics-view :deep(.small) { font-size: 0.75em; }
/* One line at any width: the gap shrinks first, then the font (the number
scales along in em), both following the panel's container width. */
.totals {
display: flex;
gap: clamp(0.5rem, 3cqw, 2rem);
font-size: clamp(0.6rem, 2.2cqw, 1.1rem);
white-space: nowrap;
}
.totals strong { font-size: 1.36em; }
.visit-table-wrap {
overflow-x: auto;
}
.visit-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
line-height: 1.3;
}
.visit-table th,
.visit-table td {
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.visit-table th {
color: var(--muted);
font-weight: normal;
text-transform: lowercase;
position: sticky;
top: 0;
background: var(--bg, Canvas);
}
.visit-table .last-seen {
width: 6rem;
text-align: right;
white-space: nowrap;
cursor: pointer;
}
.visit-table .trail {
max-width: 20rem;
overflow-wrap: break-word;
}
.visit-table .trail a,
.visit-table .trail-link {
display: inline-block;
max-width: 8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
}
.visit-table .trail > * + * {
margin-left: 0.5rem;
}
.analytics-view :deep(.trail-link.error),
.analytics-view :deep(.trail-link.error:hover) {
color: var(--error, #c00);
}
/* The referer badge outgrows the 8rem trail-link cap (it carries the UTM
summary too); keep the inline-flex layout from the component. The
generic trail-link rule above would otherwise clip the badge (overflow:
hidden, hiding the absolutely positioned favicon) and cap its inner
link — the link is display: contents, so its parts lay out as badge
flex items. */
.visit-table .trail .referer-badge {
display: inline-flex;
max-width: 100%;
overflow: visible;
}
.visit-table .trail .referer-badge a.badge-link {
display: contents;
}
/* Same flag chip as the visitor cells (VisitorCell.vue); the flags here
mark the language the page was read in. */
.visit-table .flag {
display: inline-flex;
width: 18px;
height: 12px;
border-radius: 2px;
overflow: hidden;
border: 1px solid var(--line);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;
vertical-align: middle;
}
.visit-table .flag :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
.visit-table .clickable-list {
cursor: pointer;
max-width: 22rem;
}
.visit-table .abuse-items {
display: flex;
flex-wrap: wrap;
gap: 0.15rem 0.5rem;
align-items: baseline;
}
.visit-table .inline-item {
max-width: 18rem;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
word-break: keep-all;
hyphens: none;
}
.visit-table :deep(.clickable-ip),
.visit-table .clickable-list,
.visit-table .last-seen {
cursor: pointer;
}
.crawler-top-uas {
font-size: 0.9rem;
margin-bottom: 0.6rem;
}
.crawler-top-uas strong {
color: var(--muted);
}
.empty, .loading, .error { color: var(--muted); }
.error { color: var(--error, #c00); }
</style>