From 6eaa1c1a8be002f67aafa47c814b632f9218dcea Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 21 Aug 2026 20:14:04 +0000 Subject: [PATCH] analytics: 24h day view with bar chart for precise realtime stats. Tables redesigned with cleaner layout. Tracking article read times. Adjust connection graph visualizations by time range. Other cleanup and supporting systems. --- docs/analytics.md | 45 +++- frontend/src/AnalyticsView.vue | 305 ++++++++++++++++++++++---- frontend/src/TransitionGraph.vue | 70 +++++- frontend/src/VisitorCharts.vue | 57 +++-- frontend/src/analytics/chart.js | 93 +++++++- frontend/src/analytics/format.js | 163 +++++++++++++- frontend/src/analytics/time.js | 34 ++- frontend/src/analytics/transitions.js | 104 ++++++--- frontend/src/pagerite.js | 116 ++++++++-- pagerite/analytics.py | 246 +++++++++++++++++++-- pagerite/app.py | 45 +++- 11 files changed, 1119 insertions(+), 159 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index ec3cc37..89dc78d 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -38,10 +38,17 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with links are stored by full URL so several links to the same domain remain distinct. - **Excluded**: back/forward (popstate) navigations, navigation involving - the analytics page itself (`/_a`), and everything while the user is known to - be an admin *and SSO is actually in use* — with no auth proxy (dev/test) - "admin" is everyone's state, so the gate is off and everything is recorded — - or has the editor open (`body.editing`). Admin noise, not visits. + the analytics page itself (`/_a`), and everything while the user has the + editor open (`body.editing`). Admin noise, not visits. +- **Admins**: when SSO is in use and the session is known to be an admin, + the client still pings but adds `hide=1`. The server then records + nothing — and if the same (IP, UA) session already had a visit from + before logging in, that visit is removed from the JSON along with the + counts recorded when it was created (site visit, entry view, entry + transition). Views/transitions logged by later pings inside such a visit + lack per-event timestamps and are left as-is. With no auth proxy + (dev/test) "admin" is everyone's state, so `hide` stays 0 and everything + is recorded. - The server validates `to`: internal paths must be valid slug paths ("/" or `[a-z0-9_-]` segments), external ones are re-derived to the https origin and accepted only when the client sent exactly that. @@ -69,6 +76,19 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with hits are grouped by the same (IP, User-Agent) pair and shown as a trail of internal pages that crawler visited; the crawler table lists the most active crawlers first rather than the most recent hits. +- **Abuse (scanner) hits**: a 404 for a telltale path — any URL segment + starting with a dot (`/.env`, `/.git/config`) or ending in `.php` — + classifies the source IP as abuse immediately, and ten plain 404s from one + IP do too. Classification reclassifies history: all earlier crawler hits + from that IP (persisted and pending) move to the `abuse` list, so a + random-UA scanner no longer pollutes the crawler stats of the legitimate + bot it impersonates. Once classified, every document GET and 404 from the + IP is recorded as an abuse hit with the full request path (query string + included), and its pings are ignored. The classified IP set (`abuse_ips`) + is persisted in the JSON file; the plain-404 counters are RAM-only. In the + viewer, abuse hits are grouped by IP (never by UA — scanners randomize + theirs) in a separate "Abuse" table listing the full paths probed and the + raw User-Agent strings, one per line, with click-to-copy full lists. ## Visits and sessions @@ -111,7 +131,22 @@ Each `CrawlerHit` record: - `referer` — external https origin of the request, `""` for direct/none, - `query` — raw query string of the request. -Crawler hits are grouped by User-Agent in the analytics viewer. +Each `AbuseHit` record: + +- `start` — timestamp of the request, +- `path` — full request path including the query string (e.g. `/.env?x=1`), +- `ip` — IP address (the grouping key for abusers), +- `ua` — raw `User-Agent` header, +- `ua_pretty` — compact display form of the UA when parsable, +- `flag` — true for the path that triggered abuse classification (telltale + path or the 404 that crossed the threshold), +- `is_404` — true for 404 responses, false for document GETs from the + abuser. + +Crawler hits are grouped by (IP, User-Agent) in the analytics viewer; abuse +hits are grouped by IP alone. In the Abuse table paths are listed in access +order, oldest first, with flagged paths lifted to the top, followed by other +404s and then document GETs. ## Aggregates diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index d4de75f..c30216e 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -9,6 +9,8 @@ import { RANGES } from './analytics/time.js' import { calcTotalViews, copyIp, + copyList, + formatAbuseRows, formatCrawlerRows, formatVisitRows, } from './analytics/format.js' @@ -20,6 +22,8 @@ const props = defineProps({ initialRange: { type: String, default: 'week' }, }) +const ABUSE_MAX_LINES = 5 + const data = ref(null) const pageTree = ref(null) const error = ref('') @@ -52,7 +56,7 @@ function connectAnalytics() { onMounted(async () => { connectAnalytics() now.value = Date.now() - timeInterval = setInterval(() => { now.value = Date.now() }, 30000) + 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 { @@ -86,6 +90,7 @@ watch(range, (r) => { const visitRows = computed(() => formatVisitRows(visits.value, pageTree.value, now.value)) const crawlers = computed(() => data.value?.crawlers || []) const crawlerRows = computed(() => formatCrawlerRows(crawlers.value, pageTree.value, now.value)) +const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], now.value)) function flagSvg(code) { return flagSvgs[code?.toUpperCase()] || '' @@ -131,20 +136,24 @@ function countryName(code) { - - - - - - - + + - - - - - - - +
when trailrefereriplangcountryuautmvisitorlast seen
{{ v.when }} + + {{ v.utm }} {{ v.referer }} - {{ v.ipDisplay }} + +
+
+
+ + + +
+
{{ v.ipDisplay }}
+
+
+
{{ v.ua }}
+
{{ v.langDisplay }}
+
+
{{ v.lang }} - - - - - {{ v.ua }}{{ v.utm }}{{ v.lastSeen }}
@@ -181,15 +196,13 @@ function countryName(code) { - - - + + - - - +
when pagesipuaip / ualast seen
{{ c.when }} {{ s.count }}×{{ s.slug }} - {{ c.ipDisplay }} + +
{{ c.ipDisplay }}
+
{{ c.ua }}
{{ c.ua }}{{ c.lastSeen }}

no crawler hits recorded yet

+ +
+

Abuse

+
+ + + + + + + + + + + + + + + +
pathsip / uaslast seen
+
+ {{ p.path }} +
+
+ +{{ a.paths.length - ABUSE_MAX_LINES }} more +
+
+
{{ a.ipDisplay }}
+
+
+ {{ u.count }}×{{ u.ua }} +
+
+ +{{ a.uas.length - ABUSE_MAX_LINES }} more +
+
+
{{ a.lastSeen }}
+
+
@@ -296,7 +359,6 @@ function countryName(code) { .visit-table { width: 100%; border-collapse: collapse; - font-family: monospace; font-size: 0.82rem; line-height: 1.3; } @@ -318,9 +380,16 @@ function countryName(code) { background: var(--bg, Canvas); } -.visit-table .when { +.visit-table .last-seen { + width: 7.5rem; + text-align: right; white-space: nowrap; color: var(--muted); + cursor: pointer; +} + +.visit-table .last-seen:hover { + color: var(--accent); } .visit-table .trail { @@ -328,17 +397,50 @@ function countryName(code) { overflow-wrap: break-word; } -.visit-table .trail a { +.visit-table .trail a, +.visit-table .trail-link { + display: inline-block; + max-width: 8rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; color: var(--text); text-decoration: none; + vertical-align: bottom; } -.visit-table .trail a:hover { color: var(--accent); } +.visit-table .trail a:hover, +.visit-table .trail-link:hover { color: var(--accent); } -.visit-table .trail a + a { +.visit-table .trail > * + * { margin-left: 0.5rem; } +.visit-table .utm-tag { + display: inline-block; + color: var(--muted); +} + +.visit-table .clickable-list { + cursor: pointer; + max-width: 22rem; +} + +.visit-table .clickable-list .list-line { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.35; +} + +.visit-table .clickable-list .list-line + .list-line { + margin-top: 0.15rem; +} + +.visit-table .ua.abuse-uas { + max-width: 24rem; +} + .visit-table .trail small, .visit-table small.muted { color: var(--muted); @@ -346,23 +448,130 @@ function countryName(code) { } .visit-table .clickable-ip { - cursor: pointer; - text-decoration: underline; - text-decoration-style: dotted; + font-size: 0.75em; } -.visit-table .clickable-ip:hover { +.visit-table .clickable-ip, +.visit-table .clickable-list, +.visit-table .last-seen { + cursor: pointer; + position: relative; +} + +.visit-table .clickable-ip:hover, +.visit-table .clickable-list:hover, +.visit-table .last-seen:hover { color: var(--accent); } -.visit-table .ua { - max-width: 18rem; +.visit-table .ip-locale-cell { + width: 36ch; + max-width: 36ch; + overflow: hidden; + text-overflow: ellipsis; +} + +.visit-table .ip-ua-cell { + width: 22ch; + max-width: 22ch; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.visit-table .host-cell { + text-align: right; +} + +.visit-table .ip-locale-rows { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.visit-table .ip-locale-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.visit-table .ip-locale-row > * { + min-width: 0; +} + +.visit-table .ip-locale-row .locale-line, +.visit-table .ip-locale-row .ip-line, +.visit-table .ip-locale-row .ua-line { + flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.visit-table .country .flag { +.visit-table .ip-locale-row .locale-lang { + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; +} + +.visit-table .ip-locale-row .locale-line { + text-align: left; +} + +.visit-table .ip-locale-row .ip-line { + text-align: right; +} + +.visit-table .ip-locale-row .ua-line { + text-align: left; +} + +.visit-table .locale-line { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.visit-table .city-name { + display: inline-block; + max-width: 10ch; + font-size: 0.75em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +} + +.visit-table .ua-line { + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.visit-table .abuse-uas-list { + text-align: right; +} + +.visit-table .copy-popup { + position: absolute; + bottom: calc(100% + 0.25rem); + left: 50%; + transform: translateX(-50%); + padding: 0.15rem 0.4rem; + background: var(--text, CanvasText); + color: var(--bg, Canvas); + border-radius: 0.25rem; + font-size: 0.75rem; + white-space: nowrap; + pointer-events: none; + z-index: 10; +} + +.visit-table .locale-line .flag { display: inline-flex; width: 18px; height: 12px; @@ -370,14 +579,20 @@ function countryName(code) { 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 .country .flag :deep(svg) { +.visit-table .locale-line .flag :deep(svg) { width: 100%; height: 100%; display: block; } +.visit-table .locale-line .city-name { + margin-left: 0.3rem; + vertical-align: middle; +} + .crawler-top-uas { font-size: 0.9rem; margin-bottom: 0.6rem; diff --git a/frontend/src/TransitionGraph.vue b/frontend/src/TransitionGraph.vue index bb58669..2164d5b 100644 --- a/frontend/src/TransitionGraph.vue +++ b/frontend/src/TransitionGraph.vue @@ -7,7 +7,7 @@ * range, exactly like the charts and per-page views do. */ import { computed, onBeforeUnmount, shallowRef, watch } from 'vue' -import { rangeWindow } from './analytics/time.js' +import { rangeWindow, WEEK } from './analytics/time.js' import { TNODE_R, BEAD_R, @@ -25,6 +25,19 @@ const props = defineProps({ const window = computed(() => rangeWindow(props.range)) +const visualScale = computed(() => { + const { t0, t1 } = window.value + if (t0 != null && t1 != null) return WEEK / (t1 - t0) + // 'all': scale by the actual data span. + const times = new Set() + for (const buckets of Object.values(props.data?.views || {})) { + for (const k of Object.keys(buckets)) times.add(Date.parse(k)) + } + const arr = [...times] + if (arr.length < 2) return 1 + return WEEK / (Math.max(...arr) - Math.min(...arr)) +}) + const filteredData = computed(() => { if (!props.data) return null const { t0, t1 } = window.value @@ -36,7 +49,7 @@ const filteredData = computed(() => { const graph = computed(() => filteredData.value - ? buildTransitionGraph(filteredData.value, props.pageTree) + ? buildTransitionGraph(filteredData.value, props.pageTree, props.data?.visits, visualScale.value) : null, ) @@ -47,16 +60,24 @@ const graph = computed(() => const beads = shallowRef([]) let rafId = 0 +const MAX_BEAD_RATE = 120 // upper bound on total beads per second + const startBeads = (flows) => { cancelAnimationFrame(rafId) beads.value = [] if (!flows?.length) return if (matchMedia('(prefers-reduced-motion: reduce)').matches) return + // Cap the total bead emission rate so a busy range cannot spawn enough + // beads to kill the page. Existing per-range time scaling is preserved; + // this is only a proportional emergency throttle when the limit is hit. + const totalRate = flows.reduce((s, f) => s + 1 / f.interval, 0) + const scale = totalRate > MAX_BEAD_RATE ? MAX_BEAD_RATE / totalRate : 1 + const live = [] // { flow, t0 } — one entry per bead in flight const now = performance.now() const emitters = flows.map((flow) => { - const interval = flow.interval * 1000 + const interval = (flow.interval / scale) * 1000 // Pre-fill the traversal with evenly spaced beads (random phase), so // the flow appears already running instead of starting empty. const phase = Math.random() * interval @@ -100,10 +121,14 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
+ + + + + :d="e.d" :class="['tconn', e.external && 'tconn-exit']"> {{ e.title }} cancelAnimationFrame(rafId)) - {{ x.label }} - {{ x.count }} + + {{ x.label }} + + {{ x.count }} - + - {{ n.label }} - {{ n.views }} + + {{ n.label }} + + + {{ n.readMin ? `${n.views}×${n.readMin}m` : n.views }} + +
@@ -139,6 +180,9 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId)) fill: var(--accent); opacity: 0.4; /* uniform, not strength-encoded: width carries that */ } +.tmap .tconn-exit { + fill: var(--text); +} .tmap .tbead { fill: var(--accent); opacity: 0.85; @@ -149,7 +193,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId)) stroke-width: 1.5; } .tmap .txnode-source { stroke: var(--text); } -.tmap .txnode-exit { stroke: var(--muted); } +.tmap .txnode-exit { stroke: var(--text); } .tmap .tarc { fill: none; stroke: var(--line); @@ -162,7 +206,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId)) } .tmap .tnodeslug { fill: var(--text); - font-size: 11px; + font-size: calc(11px / var(--node-r, 34)); text-anchor: middle; } .tmap a { cursor: pointer; } @@ -172,6 +216,10 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId)) font-size: 10px; text-anchor: middle; } +.tmap .tnodehidden { + fill: var(--text); + font-size: 9px; +} section { margin-top: 1.8rem; } diff --git a/frontend/src/VisitorCharts.vue b/frontend/src/VisitorCharts.vue index bd7251f..8da396a 100644 --- a/frontend/src/VisitorCharts.vue +++ b/frontend/src/VisitorCharts.vue @@ -2,10 +2,12 @@ /** * Visitor and page-view smoothed curves for a single shared time range. */ -import { computed } from 'vue' +import { computed, onMounted, onUnmounted, ref } from 'vue' import { makeSeries } from './analytics/time.js' import { CHART_H, CHART_W, buildChart, fmtY } from './analytics/chart.js' +const DAY_REFRESH_MS = 15000 + const props = defineProps({ data: { type: Object, default: null }, range: { type: String, required: true }, @@ -22,33 +24,52 @@ const allViews = computed(() => { const visitSeries = computed(() => makeSeries(props.data?.site_visits, props.range)) const viewSeries = computed(() => makeSeries(allViews.value, props.range)) -const unit = computed(() => (props.range === 'week' ? 'h' : 'day')) -const visitChart = computed(() => buildChart(visitSeries.value)) -const viewChart = computed(() => buildChart(viewSeries.value)) +function freqLabel(unit) { + return unit === '5min' ? '5 min' : unit === 'hour' ? 'hourly' : 'daily' +} + +const now = ref(Date.now()) +let refreshInterval = null +onMounted(() => { + refreshInterval = setInterval(() => { now.value = Date.now() }, DAY_REFRESH_MS) +}) +onUnmounted(() => { + if (refreshInterval) clearInterval(refreshInterval) +}) + +const visitChart = computed(() => buildChart(visitSeries.value, now.value)) +const viewChart = computed(() => buildChart(viewSeries.value, now.value))