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))