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.
This commit is contained in:
+40
-5
@@ -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
|
||||
|
||||
|
||||
+260
-45
@@ -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) {
|
||||
<table class="visit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>when</th>
|
||||
<th>trail</th>
|
||||
<th>referer</th>
|
||||
<th>ip</th>
|
||||
<th>lang</th>
|
||||
<th>country</th>
|
||||
<th>ua</th>
|
||||
<th>utm</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="when" :title="v.whenTooltip">{{ v.when }}</td>
|
||||
<td class="trail">
|
||||
<template v-if="v.refererStep">
|
||||
<a class="trail-link"
|
||||
:href="v.refererStep.path"
|
||||
:title="v.refererStep.title"
|
||||
:target="v.refererStep.external ? '_blank' : undefined"
|
||||
:rel="v.refererStep.external ? 'noopener' : undefined">
|
||||
{{ v.refererStep.slug }}
|
||||
</a>
|
||||
</template>
|
||||
<span v-if="v.utm && v.utm !== '—'" class="utm-tag"><small class="muted">{{ v.utm }}</small></span>
|
||||
<a v-for="(s, si) in v.trail" :key="si"
|
||||
:href="s.path" :title="s.title"
|
||||
:target="s.external ? '_blank' : undefined"
|
||||
@@ -153,21 +162,27 @@ function countryName(code) {
|
||||
{{ s.slug }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ v.referer }}</td>
|
||||
<td>
|
||||
<span class="clickable-ip"
|
||||
:title="`Click to copy full IP: ${v.ip}`"
|
||||
@click="copyIp(v.ip)">{{ v.ipDisplay }}</span>
|
||||
<td class="ip-locale-cell" :class="{ 'host-cell': v.isHost }">
|
||||
<div class="ip-locale-rows">
|
||||
<div class="ip-locale-row">
|
||||
<div class="locale-line">
|
||||
<span v-if="flagSvg(v.country)" class="flag" v-html="flagSvg(v.country)" :title="countryName(v.country) || v.country"></span>
|
||||
<template v-if="v.city && v.city !== '—'"><small class="city-name">{{ v.city }}</small></template>
|
||||
<template v-else-if="!flagSvg(v.country)">—</template>
|
||||
</div>
|
||||
<div class="ip-line"><span class="clickable-ip"
|
||||
:title="v.ip"
|
||||
@click="copyIp(v.ip, $event)">{{ v.ipDisplay }}</span></div>
|
||||
</div>
|
||||
<div class="ip-locale-row">
|
||||
<div class="ua-line"><small class="muted" :title="v.uaRaw">{{ v.ua }}</small></div>
|
||||
<div v-if="v.lang && v.lang !== '—'" class="locale-lang"><small class="muted">{{ v.langDisplay }}</small></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ v.lang }}</td>
|
||||
<td class="country">
|
||||
<span v-if="flagSvg(v.country)" class="flag" v-html="flagSvg(v.country)" :title="countryName(v.country) || v.country"></span>
|
||||
<template v-if="v.city !== '—'">{{ countryName(v.country) || v.country }}<br><small class="muted">{{ v.city }}</small></template>
|
||||
<template v-else-if="v.country !== '—'">{{ countryName(v.country) || v.country }}</template>
|
||||
<template v-else>—</template>
|
||||
</td>
|
||||
<td class="ua" :title="v.uaRaw">{{ v.ua }}</td>
|
||||
<td>{{ v.utm }}</td>
|
||||
<td class="last-seen"
|
||||
:title="v.lastSeenLocal"
|
||||
@click="copyList(v.lastSeenIso, $event)">{{ v.lastSeen }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -181,15 +196,13 @@ function countryName(code) {
|
||||
<table class="visit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>when</th>
|
||||
<th>pages</th>
|
||||
<th>ip</th>
|
||||
<th>ua</th>
|
||||
<th>ip / ua</th>
|
||||
<th class="last-seen">last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(c, i) in crawlerRows" :key="i">
|
||||
<td class="when" :title="c.whenTooltip">{{ c.when }}</td>
|
||||
<td class="trail">
|
||||
<a v-for="(s, si) in c.pages" :key="si"
|
||||
:href="s.path" :title="`${s.title}${s.count > 1 ? ` (${s.count} hits)` : ''}`"
|
||||
@@ -197,18 +210,68 @@ function countryName(code) {
|
||||
<small v-if="s.count > 1" class="muted">{{ s.count }}×</small>{{ s.slug }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="clickable-ip"
|
||||
:title="`Click to copy full IP: ${c.ip}`"
|
||||
@click="copyIp(c.ip)">{{ c.ipDisplay }}</span>
|
||||
<td class="ip-ua-cell">
|
||||
<div><span class="clickable-ip"
|
||||
:title="c.ip"
|
||||
@click="copyIp(c.ip, $event)">{{ c.ipDisplay }}</span></div>
|
||||
<div class="ua-line"><small class="muted" :title="c.uaRaw">{{ c.ua }}</small></div>
|
||||
</td>
|
||||
<td class="ua" :title="c.uaRaw">{{ c.ua }}</td>
|
||||
<td class="last-seen"
|
||||
:title="c.lastSeenLocal"
|
||||
@click="copyList(c.lastSeenIso, $event)">{{ c.lastSeen }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="empty">no crawler hits recorded yet</p>
|
||||
</section>
|
||||
|
||||
<section v-if="abuseRows.length">
|
||||
<h2>Abuse</h2>
|
||||
<div class="visit-table-wrap">
|
||||
<table class="visit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>paths</th>
|
||||
<th>ip / uas</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 v-for="(p, pi) in a.paths.slice(0, ABUSE_MAX_LINES)" :key="pi"
|
||||
class="list-line">
|
||||
{{ p.path }}
|
||||
</div>
|
||||
<div v-if="a.paths.length > ABUSE_MAX_LINES" class="list-line">
|
||||
<small class="muted">+{{ a.paths.length - ABUSE_MAX_LINES }} more</small>
|
||||
</div>
|
||||
</td>
|
||||
<td class="ip-ua-cell">
|
||||
<div><span class="clickable-ip"
|
||||
:title="a.ip"
|
||||
@click="copyIp(a.ip, $event)">{{ a.ipDisplay }}</span></div>
|
||||
<div class="abuse-uas-list clickable-list"
|
||||
@click="copyList(a.allUas, $event)">
|
||||
<div v-for="(u, ui) in a.uas.slice(0, ABUSE_MAX_LINES)" :key="ui"
|
||||
class="list-line">
|
||||
<small v-if="u.count > 1" class="muted">{{ u.count }}×</small>{{ u.ua }}
|
||||
</div>
|
||||
<div v-if="a.uas.length > ABUSE_MAX_LINES" class="list-line">
|
||||
<small class="muted">+{{ a.uas.length - ABUSE_MAX_LINES }} more</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="last-seen"
|
||||
:title="a.lastSeenLocal"
|
||||
@click="copyList(a.lastSeenIso, $event)">{{ a.lastSeen }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
<section v-if="graph">
|
||||
<svg class="tmap" :viewBox="`${graph.bounds.x0} ${graph.bounds.y0} ${graph.bounds.x1 - graph.bounds.x0} ${graph.bounds.y1 - graph.bounds.y0}`"
|
||||
role="img" aria-label="map of transitions between pages">
|
||||
<defs>
|
||||
<!-- Unit-radius circle; only the portion near the bottom is used. -->
|
||||
<path id="tnode-label-arc" d="M 0,-1 A 1,1 0 1,0 0,1 A 1,1 0 1,0 -0.001,-1" />
|
||||
</defs>
|
||||
<path v-for="(a, i) in graph.arcs" :key="'a' + i"
|
||||
:d="a.d" class="tarc" />
|
||||
<path v-for="(e, i) in graph.edges" :key="'e' + i"
|
||||
:d="e.d" class="tconn">
|
||||
:d="e.d" :class="['tconn', e.external && 'tconn-exit']">
|
||||
<title>{{ e.title }}</title>
|
||||
</path>
|
||||
<circle v-for="(b, i) in beads" :key="'b' + i"
|
||||
@@ -112,16 +137,32 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
<a :href="x.path" target="_blank" rel="noopener" :title="x.path">
|
||||
<circle :cx="x.x" :cy="x.y" :r="x.r"
|
||||
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||
<text :x="x.x" :y="x.y - 2" class="tnodeslug">{{ x.label }}</text>
|
||||
<text :x="x.x" :y="x.y + 12" class="tnodecount">{{ x.count }}</text>
|
||||
<text :transform="`translate(${x.x}, ${x.y}) scale(${x.r - 4})`" class="tnodeslug" :style="{ '--node-r': x.r - 4 }">
|
||||
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ x.label }}</textPath>
|
||||
</text>
|
||||
<text :x="x.x" :y="x.y + 4" class="tnodecount">{{ x.count }}</text>
|
||||
</a>
|
||||
</g>
|
||||
<g v-for="n in graph.nodes" :key="n.path">
|
||||
<a :href="n.path" :title="n.title">
|
||||
<a v-if="!n.hidden" :href="n.path" :title="n.title">
|
||||
<circle :cx="n.x" :cy="n.y" :r="TNODE_R" class="tnode" />
|
||||
<text :x="n.x" :y="n.y - 2" class="tnodeslug">{{ n.label }}</text>
|
||||
<text :x="n.x" :y="n.y + 12" class="tnodecount">{{ n.views }}</text>
|
||||
<text :transform="`translate(${n.x}, ${n.y}) scale(${TNODE_R - 4})`" class="tnodeslug" :style="{ '--node-r': TNODE_R - 4 }">
|
||||
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ n.label }}</textPath>
|
||||
</text>
|
||||
<text :x="n.x" :y="n.y + 4" class="tnodecount">
|
||||
{{ n.readMin ? `${n.views}×${n.readMin}m` : n.views }}
|
||||
</text>
|
||||
</a>
|
||||
<template v-else>
|
||||
<text :x="n.x" :y="n.y"
|
||||
:transform="`rotate(${n.angle * 180 / Math.PI}, ${n.x}, ${n.y})`"
|
||||
class="tnodehidden" text-anchor="start" dominant-baseline="middle">➤</text>
|
||||
<text :x="n.x + Math.cos(n.angle) * 10"
|
||||
:y="n.y + Math.sin(n.angle) * 10"
|
||||
:transform="`rotate(${(n.angle + (Math.cos(n.angle) < 0 ? Math.PI : 0)) * 180 / Math.PI}, ${n.x + Math.cos(n.angle) * 10}, ${n.y + Math.sin(n.angle) * 10})`"
|
||||
:text-anchor="Math.cos(n.angle) < 0 ? 'end' : 'start'"
|
||||
class="tnodehidden" dominant-baseline="middle">{{ n.label }}</text>
|
||||
</template>
|
||||
</g>
|
||||
</svg>
|
||||
</section>
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
@@ -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))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-for="c in [
|
||||
{ ylabel: 'visitors', chart: visitChart, empty: 'no visits recorded yet' },
|
||||
{ ylabel: 'visits', chart: visitChart, empty: 'no visits recorded yet' },
|
||||
{ ylabel: 'views', chart: viewChart, empty: 'no views recorded yet' },
|
||||
]" :key="c.ylabel">
|
||||
<template v-if="c.chart">
|
||||
<div class="chartwrap">
|
||||
<div class="plot">
|
||||
<div class="plotarea">
|
||||
<span class="yaxis-label">{{ c.ylabel }}/{{ unit }}</span>
|
||||
<span class="yaxis-label">{{ freqLabel(c.chart.unit) }} {{ c.ylabel }}</span>
|
||||
<svg class="chart" :viewBox="`0 0 ${CHART_W} ${CHART_H}`"
|
||||
preserveAspectRatio="none" role="img" :aria-label="`${c.ylabel} per ${unit}`">
|
||||
preserveAspectRatio="none" role="img" :aria-label="`${freqLabel(c.chart.unit)} ${c.ylabel}`">
|
||||
<line v-for="g in c.chart.majors.slice(1)" :key="'j' + g.value"
|
||||
:x1="0" :x2="CHART_W" :y1="g.y" :y2="g.y" class="major" />
|
||||
<template v-for="t in c.chart.xticks" :key="'t' + t.x">
|
||||
<line v-if="t.line" :x1="t.x" :x2="t.x" :y1="0" :y2="CHART_H"
|
||||
class="minor vertical" />
|
||||
</template>
|
||||
<template v-for="(s, i) in c.chart.series" :key="i">
|
||||
<path v-if="s.area" :d="s.area" class="area" />
|
||||
<path :d="s.line" class="line" :style="{ opacity: s.opacity }" />
|
||||
<template v-if="c.chart.bars">
|
||||
<rect v-for="(b, i) in c.chart.bars" :key="'b' + i"
|
||||
:x="b.x" :y="b.y" :width="b.width" :height="b.height" class="bar" />
|
||||
<path :d="c.chart.skyline" class="line" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-for="(s, i) in c.chart.series" :key="i">
|
||||
<path v-if="s.area" :d="s.area" class="area" />
|
||||
<path :d="s.line" class="line" :style="{ opacity: s.opacity }" />
|
||||
</template>
|
||||
</template>
|
||||
<line :x1="0" :x2="CHART_W" :y1="CHART_H - 0.5" :y2="CHART_H - 0.5"
|
||||
class="axis" />
|
||||
@@ -62,7 +83,7 @@ const viewChart = computed(() => buildChart(viewSeries.value))
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="c.chart.series.length > 1" class="legend">
|
||||
<div v-if="c.chart.series && c.chart.series.length > 1" class="legend">
|
||||
<span v-for="(s, i) in c.chart.series" :key="i" :style="{ opacity: s.opacity }">
|
||||
● {{ s.label }}
|
||||
</span>
|
||||
@@ -76,7 +97,7 @@ const viewChart = computed(() => buildChart(viewSeries.value))
|
||||
/* The svg is stretched (preserveAspectRatio none), so all text lives in
|
||||
HTML overlays positioned by the same fractions the geometry uses. */
|
||||
.chartwrap {
|
||||
padding-left: 2.2rem; /* y labels */
|
||||
padding-left: 2.8rem; /* y labels */
|
||||
}
|
||||
|
||||
.plot {
|
||||
@@ -103,8 +124,8 @@ const viewChart = computed(() => buildChart(viewSeries.value))
|
||||
|
||||
.ylab {
|
||||
position: absolute;
|
||||
left: -2.2rem;
|
||||
width: 1.9rem;
|
||||
left: -2.8rem;
|
||||
width: 2.6rem;
|
||||
text-align: right;
|
||||
transform: translateY(50%);
|
||||
font-size: 0.7rem;
|
||||
@@ -154,6 +175,11 @@ const viewChart = computed(() => buildChart(viewSeries.value))
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.chart .bar {
|
||||
fill: var(--accent);
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.chart .line {
|
||||
fill: none;
|
||||
stroke: var(--accent);
|
||||
@@ -176,10 +202,11 @@ const viewChart = computed(() => buildChart(viewSeries.value))
|
||||
.yaxis-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -2.2rem;
|
||||
left: -2.8rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
writing-mode: vertical-rl;
|
||||
white-space: nowrap;
|
||||
transform: translateY(-50%) rotate(180deg);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* rates (hour on the week view, day on month+).
|
||||
*/
|
||||
|
||||
import { DAY, HOUR, WEEK, mondayUTC } from './time.js'
|
||||
import { DAY, HOUR, MIN5, WEEK, mondayUTC } from './time.js'
|
||||
|
||||
export const CHART_W = 720
|
||||
export const CHART_H = 180
|
||||
@@ -206,9 +206,10 @@ export function spline(pts) {
|
||||
}
|
||||
|
||||
/** Build a full chart model from a series descriptor produced by time.js. */
|
||||
export function buildChart(input) {
|
||||
export function buildChart(input, now = Date.now()) {
|
||||
if (!input || !input.series.length) return null
|
||||
const { series, t0, t1, rate, binMinutes, unitMinutes } = input
|
||||
if (input.unit === '5min') return buildDayChart(input, now)
|
||||
const { series, t0, t1, rate, binMinutes, unitMinutes, unit } = input
|
||||
// Values are per-unit rates (hour on the week view, day on month+); the
|
||||
// y max is derived from the *smoothed* curves so random single-bucket
|
||||
// spikes don't blow up the scale. Smoothing works on raw counts (its edge
|
||||
@@ -283,7 +284,91 @@ export function buildChart(input) {
|
||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: fmtTick(t, t1 - t0), line: true,
|
||||
}))
|
||||
return { max, majors, minors, series: drawn, xticks }
|
||||
return { max, majors, minors, series: drawn, xticks, unit }
|
||||
}
|
||||
|
||||
/**
|
||||
* Day view: 5-minute bars for the last 24 hours. Bars are drawn at raw
|
||||
* counts; the skyline uses a projected full-bucket value for the still-open
|
||||
* final bucket. The y scale is derived from the projected skyline maximum.
|
||||
*/
|
||||
export function buildDayChart(input, now = Date.now()) {
|
||||
const { series, t0, t1 } = input
|
||||
const points = series[0]?.points || []
|
||||
const n = points.length
|
||||
if (!n) return null
|
||||
const bucketMs = (t1 - t0) / n
|
||||
const bucketWidth = CHART_W / n
|
||||
const gap = 0.2
|
||||
const barWidth = Math.max(0.2, bucketWidth - gap)
|
||||
|
||||
const x = (i) => i * bucketWidth + gap / 2
|
||||
const prevRaw = n > 1 ? points[n - 2].count : 0
|
||||
const projected = points.map((p, i) => {
|
||||
if (i !== n - 1) return p.count
|
||||
const bucketStart = t0 + i * bucketMs
|
||||
const elapsed = Math.max(1, Math.min(bucketMs, now - bucketStart))
|
||||
// Blend the observed partial bucket with the previous full bucket:
|
||||
// the longer the current bucket has run, the less we borrow from it.
|
||||
const share = elapsed / bucketMs
|
||||
return p.count + prevRaw * (1 - share)
|
||||
})
|
||||
const highest = Math.max(0, ...projected)
|
||||
const { max, step, minor } = yScale(highest)
|
||||
const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP)
|
||||
|
||||
const bars = points.map((p, i) => {
|
||||
const bx = x(i)
|
||||
const by = y(p.count)
|
||||
return {
|
||||
x: bx,
|
||||
y: by,
|
||||
width: barWidth,
|
||||
height: CHART_H - by,
|
||||
raw: p.count,
|
||||
projected: projected[i],
|
||||
}
|
||||
})
|
||||
|
||||
let skyline = ''
|
||||
for (let i = 0; i < bars.length; i++) {
|
||||
const b = bars[i]
|
||||
const top = y(b.projected)
|
||||
if (i === 0) {
|
||||
skyline += `M${b.x},${top} H${b.x + b.width}`
|
||||
} else {
|
||||
skyline += ` V${top} H${b.x + b.width}`
|
||||
}
|
||||
}
|
||||
|
||||
const majors = []
|
||||
const minors = []
|
||||
const nMajor = Math.round(max / step)
|
||||
for (let k = 0; k <= nMajor; k++) {
|
||||
const v = k * step
|
||||
majors.push({ value: v, y: y(v), bottom: (1 - PAD_TOP / CHART_H) * (v / max) * 100 })
|
||||
}
|
||||
if (minor) {
|
||||
for (let v = minor; v < max; v += minor) {
|
||||
if (v % step !== 0) minors.push({ y: y(v) })
|
||||
}
|
||||
}
|
||||
|
||||
const xticks = []
|
||||
const tickStep = 3 * HOUR
|
||||
const firstTick = Math.ceil(t0 / tickStep) * tickStep
|
||||
for (let t = firstTick; t < t1; t += tickStep) {
|
||||
if (t < t0) continue
|
||||
const d = new Date(t)
|
||||
xticks.push({
|
||||
x: ((t - t0) / (t1 - t0)) * CHART_W,
|
||||
left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: `${String(d.getUTCHours()).padStart(2, '0')}:00`,
|
||||
line: false,
|
||||
})
|
||||
}
|
||||
|
||||
return { bars, skyline: skyline.trim(), max, majors, minors, xticks, unit: '5min', series: [] }
|
||||
}
|
||||
|
||||
/** X ticks for year/all: Monday boundaries up to a quarter, UTC month
|
||||
|
||||
@@ -25,11 +25,44 @@ export const hostIP = (ip) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy the full IP to the clipboard, ignoring failures. */
|
||||
export async function copyIp(ip) {
|
||||
function showCopiedFeedback(el) {
|
||||
if (!el || typeof document === 'undefined') return
|
||||
const popup = document.createElement('span')
|
||||
popup.textContent = 'Copied!'
|
||||
popup.className = 'copy-popup'
|
||||
popup.style.cssText =
|
||||
'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;'
|
||||
el.classList.add('has-copy-popup')
|
||||
el.appendChild(popup)
|
||||
setTimeout(() => {
|
||||
popup.remove()
|
||||
el.classList.remove('has-copy-popup')
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
/** Copy the full IP to the clipboard and show a brief "Copied!" popup. */
|
||||
export async function copyIp(ip, event) {
|
||||
if (!ip) return
|
||||
const el = event?.currentTarget
|
||||
try {
|
||||
await navigator.clipboard.writeText(ip)
|
||||
showCopiedFeedback(el)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy arbitrary text to the clipboard and show a brief "Copied!" popup. */
|
||||
export async function copyList(text, event) {
|
||||
if (!text) return
|
||||
const el = event?.currentTarget
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showCopiedFeedback(el)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -140,6 +173,33 @@ export function formatWhenTooltip(ts) {
|
||||
return new Date(ts).toISOString().replace('T', ' ').replace('Z', ' UTC')
|
||||
}
|
||||
|
||||
/** Full local timestamp for tooltips, e.g. "21 Aug 2026, 17:38:48". */
|
||||
export function formatWhenLocal(ts) {
|
||||
return new Date(ts).toLocaleString('en-ie', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** Preserve locale case with the region/country subtag upper-cased. */
|
||||
export function formatLang(value) {
|
||||
if (!value || value === '—') return value
|
||||
const parts = value.split('-')
|
||||
if (parts.length > 1) {
|
||||
parts[parts.length - 1] = parts[parts.length - 1].toUpperCase()
|
||||
}
|
||||
return parts.join('-')
|
||||
}
|
||||
|
||||
/** ISO 8601 UTC timestamp without subseconds, e.g. "2026-08-21T00:20:48Z". */
|
||||
export function formatWhenIso(ts) {
|
||||
return `${new Date(ts).toISOString().split('.')[0]}Z`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format recent visits for display, newest first. Each step is a linked slug
|
||||
* pointing to its article; external referers/origins are shown as their
|
||||
@@ -241,8 +301,9 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
|
||||
.sort((a, b) => totalHits(b) - totalHits(a) || b.lastStart - a.lastStart)
|
||||
.slice(0, 10)
|
||||
.map((g) => ({
|
||||
when: formatWhen(g.lastStart, now),
|
||||
whenTooltip: formatWhenTooltip(g.lastStart),
|
||||
lastSeen: formatWhen(g.lastStart, now),
|
||||
lastSeenIso: formatWhenIso(g.lastStart),
|
||||
lastSeenLocal: formatWhenLocal(g.lastStart),
|
||||
pages: [...g.pages.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([path, count]) => ({
|
||||
@@ -259,6 +320,85 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Group abuse hits by IP (never by UA — scanners randomize theirs to
|
||||
* masquerade as legitimate crawlers) and format each group as a row with
|
||||
* the full paths probed, in access order. Flagged paths (the ones that
|
||||
* triggered abuse classification) are lifted to the top, followed by
|
||||
* other 404s, then document GETs from the abuser. UAs are shown raw,
|
||||
* one per line, with their occurrence counts. Paths are shown verbatim
|
||||
* (query string included), not resolved against the page tree.
|
||||
*/
|
||||
export function formatAbuseRows(abuse, now = Date.now()) {
|
||||
const groups = new Map()
|
||||
for (const a of abuse || []) {
|
||||
const g = groups.get(a.ip) || {
|
||||
ip: a.ip || '',
|
||||
pathHits: [],
|
||||
rawUas: [],
|
||||
uaCounts: new Map(),
|
||||
lastStart: 0,
|
||||
}
|
||||
const start = new Date(a.start).getTime()
|
||||
if (start > g.lastStart) g.lastStart = start
|
||||
g.pathHits.push({
|
||||
path: a.path || '',
|
||||
start,
|
||||
flag: a.flag || false,
|
||||
is_404: a.is_404 || false,
|
||||
})
|
||||
const ua = a.ua || '(no UA)'
|
||||
g.rawUas.push(ua)
|
||||
g.uaCounts.set(ua, (g.uaCounts.get(ua) || 0) + 1)
|
||||
groups.set(a.ip, g)
|
||||
}
|
||||
const totalHits = (g) => g.pathHits.length
|
||||
return [...groups.values()]
|
||||
.sort((a, b) => totalHits(b) - totalHits(a) || b.lastStart - a.lastStart)
|
||||
.slice(0, 10)
|
||||
.map((g) => {
|
||||
const pathCategory = (p) => (p.flag ? 0 : p.is_404 ? 1 : 2)
|
||||
const paths = [...g.pathHits].sort(
|
||||
(a, b) => pathCategory(a) - pathCategory(b) || a.start - b.start,
|
||||
)
|
||||
const uas = [...g.uaCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
return {
|
||||
lastSeen: formatWhen(g.lastStart, now),
|
||||
lastSeenIso: formatWhenIso(g.lastStart),
|
||||
lastSeenLocal: formatWhenLocal(g.lastStart),
|
||||
paths: paths.map((p) => ({ path: p.path, flag: p.flag, is_404: p.is_404 })),
|
||||
allPaths: paths.map((p) => p.path).join('\n'),
|
||||
uas: uas.map(([ua, count]) => ({ ua, count })),
|
||||
allUas: uas
|
||||
.map(([ua, count]) => (count > 1 ? `${count}× ${ua}` : ua))
|
||||
.join('\n'),
|
||||
ip: g.ip,
|
||||
ipDisplay: hostIP(g.ip) || g.ip || '—',
|
||||
total: totalHits(g),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a reverse-DNS hostname to its right-most components that fit
|
||||
* within ``limit`` characters. This keeps the meaningful main domain
|
||||
* while avoiding absurdly long subdomains like ``xxx.yyy.zzz...provider.net``.
|
||||
*/
|
||||
function mainDomain(host, limit = 24) {
|
||||
if (!host) return host
|
||||
const labels = host.split('.').filter(Boolean)
|
||||
if (!labels.length) return host
|
||||
const parts = [labels.pop()]
|
||||
while (labels.length) {
|
||||
const next = labels[labels.length - 1]
|
||||
const candidate = `${next}.${parts.join('.')}`
|
||||
if (candidate.length > limit) break
|
||||
parts.unshift(labels.pop())
|
||||
}
|
||||
return parts.join('.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format raw visit records as rows for a technical table. Returns objects
|
||||
* with display strings; missing values become "—". ``trail`` starts with the
|
||||
@@ -268,21 +408,26 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
|
||||
export function formatVisitRows(visits, pageTree, now = Date.now()) {
|
||||
const titles = buildTitleMap(pageTree)
|
||||
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
||||
const trail = [v.referer, v.entry, ...(v.trail || [])]
|
||||
const trail = [v.entry, ...(v.trail || [])]
|
||||
.map((p) => stepOf(p, titles))
|
||||
.filter(Boolean)
|
||||
const utm = Object.entries(v.utm || {})
|
||||
.map(([k, value]) => `${k}=${value}`)
|
||||
.join(', ')
|
||||
const dash = (s) => (s || '—')
|
||||
const host = v.host || ''
|
||||
const isHost = !!host
|
||||
return {
|
||||
when: formatWhen(v.start, now),
|
||||
whenTooltip: formatWhenTooltip(v.start),
|
||||
lastSeen: formatWhen(v.start, now),
|
||||
lastSeenIso: formatWhenIso(v.start),
|
||||
lastSeenLocal: formatWhenLocal(v.start),
|
||||
langDisplay: formatLang(v.lang),
|
||||
trail,
|
||||
refererStep: stepOf(v.referer, titles),
|
||||
referer: dash(v.referer),
|
||||
ip: v.ip || '',
|
||||
ipDisplay: v.host || hostIP(v.ip) || v.ip || '—',
|
||||
host: dash(v.host),
|
||||
ipDisplay: isHost ? mainDomain(host) : hostIP(v.ip) || v.ip || '—',
|
||||
isHost,
|
||||
lang: dash(v.lang),
|
||||
country: dash(v.country),
|
||||
city: dash(v.city),
|
||||
|
||||
@@ -13,6 +13,7 @@ export const DAY = 86400e3
|
||||
export const WEEK = 7 * DAY
|
||||
|
||||
export const RANGES = {
|
||||
day: { label: 'day', span: DAY, bucket: MIN5 },
|
||||
week: { label: 'week' },
|
||||
month: { label: 'month', span: 30 * DAY, bucket: 6 * HOUR },
|
||||
year: { label: 'year', span: 365 * DAY, bucket: DAY },
|
||||
@@ -117,11 +118,36 @@ export function rollingSeries(buckets, rangeKey) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch to weekly or rolling series based on the selected range. */
|
||||
/**
|
||||
* Day view: raw 5-minute bucket counts for the current 24-hour window.
|
||||
* No smoothing or rate conversion is applied; counts are used as-is.
|
||||
*/
|
||||
export function daySeries(buckets) {
|
||||
const raw = rawTimes(buckets)
|
||||
const now = Date.now()
|
||||
const { span, bucket } = RANGES.day
|
||||
const t1 = Math.floor(now / bucket) * bucket + bucket
|
||||
const t0 = t1 - span
|
||||
const points = []
|
||||
for (let t = t0; t < t1; t += bucket) {
|
||||
points.push({ t, count: raw[t] || 0 })
|
||||
}
|
||||
return {
|
||||
series: [{ points, label: '', opacity: 1, area: false }],
|
||||
t0,
|
||||
t1,
|
||||
rate: 1,
|
||||
binMinutes: bucket / 60e3,
|
||||
unitMinutes: bucket / 60e3,
|
||||
unit: '5min',
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch to daily, weekly or rolling series based on the selected range. */
|
||||
export function makeSeries(buckets, rangeKey) {
|
||||
return rangeKey === 'week'
|
||||
? weeklySeries(buckets)
|
||||
: rollingSeries(buckets, rangeKey)
|
||||
if (rangeKey === 'day') return daySeries(buckets)
|
||||
if (rangeKey === 'week') return weeklySeries(buckets)
|
||||
return rollingSeries(buckets, rangeKey)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,22 +23,23 @@ export const TNODE_R = 34 // node circles hold the slug and the view count
|
||||
export const EXT_R = 34 // external referer/exit nodes use the same full size
|
||||
|
||||
// Edge width (half-width of the thin middle) grows logarithmically with
|
||||
// the count, anchored so a single recorded transition renders as a ~1 px
|
||||
// line. There is no cap — growth is slow enough that even very hot
|
||||
// connections stay reasonable. Connections carrying less than
|
||||
// PRUNE_FRACTION of the total traffic are not drawn at all (this also
|
||||
// keeps the number of drawn connections under ~100).
|
||||
const WMID_MIN = 0.5
|
||||
const WIDTH_GROWTH = 1.5
|
||||
// the count. The constants are scaled down by ~10× so busy ranges (day,
|
||||
// year) do not overwhelm the graph with fat connectors. A single recorded
|
||||
// transition still renders as a faint ~0.4 px line. Connections carrying
|
||||
// less than PRUNE_FRACTION of the total traffic are not drawn at all (this
|
||||
// also keeps the graph under ~100 connections).
|
||||
const WMID_MIN = 0.2
|
||||
const WIDTH_GROWTH = 0.15
|
||||
const PRUNE_FRACTION = 0.01
|
||||
|
||||
// Beads: each edge direction emits beads at count * BEAD_RATE beads per
|
||||
// second (linear in the count). The component simulates every bead
|
||||
// independently in JS at BEAD_SPEED along the edge, with no limit on
|
||||
// second (linear in the count). The rate is reduced ~10× across all time
|
||||
// scales to keep the animation lightweight. The component simulates every
|
||||
// bead independently in JS at BEAD_SPEED along the edge, with no limit on
|
||||
// beads in flight.
|
||||
export const BEAD_SPEED = 180 // svg units per second
|
||||
export const BEAD_R = 2.2
|
||||
const BEAD_RATE = 0.12 // beads per second per recorded transition
|
||||
const BEAD_RATE = 0.012 // beads per second per recorded transition
|
||||
const FLOW_OFFSET = 3 // lane offset to the right of the travel direction
|
||||
|
||||
const MAX_EXT_IN = 8 // referer nodes in the top row
|
||||
@@ -84,10 +85,15 @@ function collectInternalTransitions(transitions) {
|
||||
return internal
|
||||
}
|
||||
|
||||
/** Short display label for an external origin (protocol stripped). */
|
||||
/** Domain-only label for an external origin (path removed). */
|
||||
function extLabel(ext) {
|
||||
const s = ext.replace(/^https?:\/\//, '')
|
||||
return s.length > 11 ? `${s.slice(0, 10)}…` : s
|
||||
try {
|
||||
const host = new URL(ext).hostname
|
||||
return host.length > 25 ? `${host.slice(0, 24)}…` : host
|
||||
} catch {
|
||||
const s = ext.replace(/^https?:\/\//, '').split('/')[0]
|
||||
return s.length > 25 ? `${s.slice(0, 24)}…` : s
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,8 +181,27 @@ function layoutAngles(root, unit, weight) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute median reading time per article in whole minutes. */
|
||||
function buildReadMinutes(visits) {
|
||||
const times = {}
|
||||
for (const v of visits || []) {
|
||||
for (const [path, sec] of Object.entries(v.read || {})) {
|
||||
;(times[path] || (times[path] = [])).push(sec)
|
||||
}
|
||||
}
|
||||
const minutes = {}
|
||||
for (const [path, arr] of Object.entries(times)) {
|
||||
arr.sort((a, b) => a - b)
|
||||
const mid = Math.floor(arr.length / 2)
|
||||
const median =
|
||||
arr.length % 2 ? arr[mid] : (arr[mid - 1] + arr[mid]) / 2
|
||||
minutes[path] = Math.max(1, Math.round(median / 60))
|
||||
}
|
||||
return minutes
|
||||
}
|
||||
|
||||
/** Compute radial positions, view counts and labels for each node. */
|
||||
function positionNodes(nodes, maxDepth, unit, viewsData, titles) {
|
||||
function positionNodes(nodes, maxDepth, unit, viewsData, titles, readMinutes) {
|
||||
// Constant radial gap between rings, equal to the arc spacing of nodes
|
||||
// along a ring: leaf arc = unit * GAP, so GAP scales up with `unit` on
|
||||
// sparse trees (where closing the circle forces wider arcs) and with
|
||||
@@ -196,10 +221,14 @@ function positionNodes(nodes, maxDepth, unit, viewsData, titles) {
|
||||
n.x = Math.cos(n.angle) * r
|
||||
n.y = Math.sin(n.angle) * r
|
||||
n.views = viewCount(n.path)
|
||||
n.readMin = readMinutes[n.path] || 0
|
||||
// Slug inside the circle; full title goes on the link title attribute.
|
||||
const slug = n.path === '/' ? '🏠' : n.path.split('/').pop()
|
||||
n.label = slug.length > 11 ? `${slug.slice(0, 10)}…` : slug
|
||||
n.label = slug.length > 16 ? `${slug.slice(0, 15)}…` : slug
|
||||
n.title = titles.get(n.path) || ''
|
||||
// Category (non-leaf) pages with no views in this window are left
|
||||
// blank to keep the layout, but their circle/label is not drawn.
|
||||
n.hidden = n.children.length > 0 && n.views === 0
|
||||
}
|
||||
|
||||
return { radius, GAP }
|
||||
@@ -279,7 +308,7 @@ const fmtPt = (p) => `${p[0].toFixed(2)} ${p[1].toFixed(2)}`
|
||||
* `wMid` is the half-width of the thin middle (already strength-scaled by
|
||||
* the caller); `ra`/`rb` are the radii of the node circles each end wraps.
|
||||
*/
|
||||
function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R) {
|
||||
function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external = false) {
|
||||
const count = ab + ba
|
||||
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
|
||||
const ux = (b.x - a.x) / len
|
||||
@@ -387,6 +416,7 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R) {
|
||||
return {
|
||||
d,
|
||||
title: `${a.path} ↔ ${b.path}: ${count} (${ab} / ${ba})`,
|
||||
external,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,7 +431,7 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R) {
|
||||
* edge run on parallel lanes instead of colliding. The component turns
|
||||
* these into independently simulated beads.
|
||||
*/
|
||||
function buildFlows(a, b, ra, rb, ab, ba) {
|
||||
function buildFlows(a, b, ra, rb, ab, ba, visualScale = 1) {
|
||||
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
|
||||
const ux = (b.x - a.x) / len
|
||||
const uy = (b.y - a.y) / len
|
||||
@@ -422,7 +452,7 @@ function buildFlows(a, b, ra, rb, ab, ba) {
|
||||
x2: a.x + toT * ux + s * rx,
|
||||
y2: a.y + toT * uy + s * ry,
|
||||
len: span,
|
||||
interval: 1 / (count * BEAD_RATE),
|
||||
interval: 1 / (count * BEAD_RATE * visualScale),
|
||||
}
|
||||
}
|
||||
const flows = []
|
||||
@@ -437,14 +467,17 @@ function buildFlows(a, b, ra, rb, ab, ba) {
|
||||
* Absolute on purpose — cool routes stay visible regardless of how hot
|
||||
* the hottest connection is.
|
||||
*/
|
||||
const scaledWidth = (count) => WMID_MIN + WIDTH_GROWTH * Math.log1p(count - 1)
|
||||
const scaledWidth = (count) => {
|
||||
if (count <= 0) return 0
|
||||
return WMID_MIN + WIDTH_GROWTH * Math.log1p(count - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ribbon edges and bead flows for every aggregated page-to-page
|
||||
* pair. Pairs carrying less than PRUNE_FRACTION of the total internal
|
||||
* traffic are pruned (this naturally bounds the graph to ~100 edges).
|
||||
*/
|
||||
function buildInternalEdges(pairs, byPath) {
|
||||
function buildInternalEdges(pairs, byPath, visualScale = 1) {
|
||||
let total = 0
|
||||
for (const [, [ab, ba]] of pairs) total += ab + ba
|
||||
const minCount = total * PRUNE_FRACTION
|
||||
@@ -456,8 +489,10 @@ function buildInternalEdges(pairs, byPath) {
|
||||
const [pf, pt] = k.split(' ')
|
||||
const a = byPath.get(pf)
|
||||
const b = byPath.get(pt)
|
||||
edges.push(buildRibbon(a, b, ab, ba, scaledWidth(ab + ba)))
|
||||
flows.push(...buildFlows(a, b, TNODE_R, TNODE_R, ab, ba))
|
||||
const wMid = scaledWidth((ab + ba) * visualScale)
|
||||
if (wMid <= 0) continue
|
||||
edges.push(buildRibbon(a, b, ab, ba, wMid))
|
||||
flows.push(...buildFlows(a, b, TNODE_R, TNODE_R, ab, ba, visualScale))
|
||||
}
|
||||
return { edges, flows }
|
||||
}
|
||||
@@ -507,7 +542,7 @@ export function filterViewsByRange(views, t0, t1) {
|
||||
* Widths and pruning use the same log scale and traffic-share rule as
|
||||
* internal connections.
|
||||
*/
|
||||
function buildExternal(external, byPath, radius, innerBounds) {
|
||||
function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) {
|
||||
const extNodes = []
|
||||
const edges = []
|
||||
const flows = []
|
||||
@@ -517,7 +552,7 @@ function buildExternal(external, byPath, radius, innerBounds) {
|
||||
const live = external.filter((p) => byPath.has(p.page))
|
||||
if (!live.length) return { extNodes, edges, flows }
|
||||
|
||||
const width = scaledWidth
|
||||
const width = (count) => scaledWidth(count * visualScale)
|
||||
|
||||
const overlaps = (x, y, r) =>
|
||||
[...byPath.values(), ...extNodes].some(
|
||||
@@ -547,8 +582,10 @@ function buildExternal(external, byPath, radius, innerBounds) {
|
||||
extNodes.push(xn)
|
||||
for (const p of ps) {
|
||||
const page = byPath.get(p.page)
|
||||
edges.push(buildRibbon(xn, page, p.in, 0, width(p.in), EXT_R, TNODE_R))
|
||||
flows.push(...buildFlows(xn, page, EXT_R, TNODE_R, p.in, 0))
|
||||
const wMid = width(p.in)
|
||||
if (wMid <= 0) continue
|
||||
edges.push(buildRibbon(xn, page, p.in, 0, wMid, EXT_R, TNODE_R, true))
|
||||
flows.push(...buildFlows(xn, page, EXT_R, TNODE_R, p.in, 0, visualScale))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -593,8 +630,10 @@ function buildExternal(external, byPath, radius, innerBounds) {
|
||||
extNodes.push(xn)
|
||||
}
|
||||
xn.count += p.out
|
||||
edges.push(buildRibbon(page, xn, p.out, 0, width(p.out), TNODE_R, EXT_R))
|
||||
flows.push(...buildFlows(page, xn, TNODE_R, EXT_R, p.out, 0))
|
||||
const wMid = width(p.out)
|
||||
if (wMid <= 0) continue
|
||||
edges.push(buildRibbon(page, xn, p.out, 0, wMid, TNODE_R, EXT_R, true))
|
||||
flows.push(...buildFlows(page, xn, TNODE_R, EXT_R, p.out, 0, visualScale))
|
||||
}
|
||||
|
||||
return { extNodes, edges, flows }
|
||||
@@ -605,11 +644,12 @@ function buildExternal(external, byPath, radius, innerBounds) {
|
||||
* Returns { nodes, edges, flows, extNodes, arcs, bounds } or null when
|
||||
* there is nothing to show.
|
||||
*/
|
||||
export function buildTransitionGraph(data, pageTree) {
|
||||
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) {
|
||||
const internal = collectInternalTransitions(data?.transitions)
|
||||
const external = collectExternalPairs(data?.transitions)
|
||||
const navOrder = buildNavigationOrder(pageTree)
|
||||
const titles = buildTitleMap(pageTree)
|
||||
const readMinutes = buildReadMinutes(visits)
|
||||
|
||||
if (!internal.length && !navOrder.size) return null
|
||||
|
||||
@@ -619,10 +659,10 @@ export function buildTransitionGraph(data, pageTree) {
|
||||
layoutAngles(root, unit, weightFn)
|
||||
|
||||
const maxDepth = Math.max(1, ...nodes.map((n) => n.depth))
|
||||
const { radius } = positionNodes(nodes, maxDepth, unit, data?.views, titles)
|
||||
const { radius } = positionNodes(nodes, maxDepth, unit, data?.views, titles, readMinutes)
|
||||
const arcs = buildFamilyArcs(nodes, radius)
|
||||
const pairs = aggregatePairs(internal)
|
||||
const { edges, flows } = buildInternalEdges(pairs, byPath)
|
||||
const { edges, flows } = buildInternalEdges(pairs, byPath, visualScale)
|
||||
|
||||
// Tight bounding box of the actual page nodes; family ring arcs can sweep
|
||||
// outside the node circle (e.g. a large arc between two siblings on the
|
||||
@@ -646,7 +686,7 @@ export function buildTransitionGraph(data, pageTree) {
|
||||
bounds.y1 = Math.max(bounds.y1, b.y1)
|
||||
}
|
||||
|
||||
const ext = buildExternal(external, byPath, radius, bounds)
|
||||
const ext = buildExternal(external, byPath, radius, bounds, visualScale)
|
||||
for (const xn of ext.extNodes) {
|
||||
bounds.x0 = Math.min(bounds.x0, xn.x - xn.r - pad)
|
||||
bounds.y0 = Math.min(bounds.y0, xn.y - xn.r - pad)
|
||||
|
||||
+101
-15
@@ -338,29 +338,107 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
}
|
||||
|
||||
// --- Analytics pings ---------------------------------------------------
|
||||
// Fire-and-forget POST /_a {fr, to}: on the initial page load (starts the
|
||||
// visit — the server counts nothing from the document GET alone), for
|
||||
// internal fetch-navigations and for external https exits. Excluded:
|
||||
// back/forward (popstate never pings) and everything while we know the
|
||||
// user is an admin — but only when SSO is actually in use; with no auth
|
||||
// (dev/test) "admin" is everyone's state and nothing would be recorded —
|
||||
// or has the editor open (admin noise, not visits). The analytics page
|
||||
// itself (/_a) is also excluded even though fetch-navigation treats it like
|
||||
// a normal article.
|
||||
// Fire-and-forget POST /_a {fr, to, read}: on the initial page load
|
||||
// (starts the visit — the server counts nothing from the document GET
|
||||
// alone), for internal fetch-navigations, for external https exits, and
|
||||
// on window close. ``read`` is the active time (ms) spent on ``fr``.
|
||||
// Reading time pauses after 1 minute of inactivity and resumes on the
|
||||
// next mouse/touch/scroll/keyboard event.
|
||||
// Excluded: back/forward (popstate never pings), everything while the
|
||||
// editor is open (body.editing — admin noise, not visits), and the
|
||||
// analytics page itself (/_a), even though fetch-navigation treats it
|
||||
// like a normal article.
|
||||
// Admins (when SSO is actually in use — with no auth proxy "admin" is
|
||||
// everyone's state) ping normally but with hide=1: the server then
|
||||
// records nothing and scrubs any session the same browser accumulated
|
||||
// before logging in, so admins never show up as visits or crawlers.
|
||||
// See docs/analytics.md.
|
||||
function ping(to, fr = currentPath) {
|
||||
if ((ssoAvailable && isAdmin) || document.body.classList.contains("editing")
|
||||
|| to === "/_a" || fr === "/_a") return;
|
||||
function ping(to, fr = currentPath, read = 0) {
|
||||
if (document.body.classList.contains("editing")) return;
|
||||
if ((to && to === "/_a") || fr === "/_a") return;
|
||||
const hide = ssoAvailable && isAdmin ? 1 : 0;
|
||||
const body = JSON.stringify({
|
||||
fr, to, hide,
|
||||
read: Math.max(0, Math.round(read / 1000)),
|
||||
});
|
||||
try {
|
||||
fetch("/_a", {
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ fr, to }),
|
||||
body,
|
||||
});
|
||||
} catch { /* analytics must never break navigation */ }
|
||||
}
|
||||
|
||||
// Active reading time for the current page. The clock stops after 1 minute
|
||||
// without activity and restarts on the next mouse/touch/scroll/keyboard
|
||||
// event.
|
||||
const INACTIVE_MS = 60_000;
|
||||
let readStart = performance.now();
|
||||
let readElapsed = 0;
|
||||
let reading = true;
|
||||
let readInactivityTimer = null;
|
||||
let closePingedFor = null;
|
||||
|
||||
function markReadActivity() {
|
||||
if (!reading) {
|
||||
reading = true;
|
||||
readStart = performance.now();
|
||||
}
|
||||
clearTimeout(readInactivityTimer);
|
||||
readInactivityTimer = setTimeout(() => {
|
||||
if (reading) {
|
||||
readElapsed += performance.now() - readStart;
|
||||
reading = false;
|
||||
}
|
||||
}, INACTIVE_MS);
|
||||
}
|
||||
|
||||
function takeReadTime() {
|
||||
if (reading) {
|
||||
readElapsed += performance.now() - readStart;
|
||||
readStart = performance.now();
|
||||
}
|
||||
const ms = Math.max(0, Math.round(readElapsed));
|
||||
readElapsed = 0;
|
||||
return ms;
|
||||
}
|
||||
|
||||
function resetReadTime() {
|
||||
readElapsed = 0;
|
||||
reading = true;
|
||||
readStart = performance.now();
|
||||
clearTimeout(readInactivityTimer);
|
||||
}
|
||||
|
||||
function sendClosePing() {
|
||||
if (closePingedFor === currentPath) return;
|
||||
const read = Math.max(0, Math.round(takeReadTime() / 1000));
|
||||
if (read <= 0) return;
|
||||
const hide = ssoAvailable && isAdmin ? 1 : 0;
|
||||
const body = JSON.stringify({ fr: currentPath, hide, read });
|
||||
const blob = new Blob([body], { type: "application/json" });
|
||||
try {
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon("/_a", blob);
|
||||
} else {
|
||||
fetch("/_a", {
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
headers: { "content-type": "application/json" },
|
||||
body,
|
||||
});
|
||||
}
|
||||
} catch { /* analytics must never break navigation */ }
|
||||
closePingedFor = currentPath;
|
||||
}
|
||||
|
||||
for (const ev of ["mousemove", "mousedown", "touchstart", "touchmove", "scroll", "keydown"]) {
|
||||
addEventListener(ev, markReadActivity, { passive: true });
|
||||
}
|
||||
addEventListener("pagehide", sendClosePing);
|
||||
|
||||
// The initial page load pings too — it is what starts the visit and
|
||||
// counts the entry page view (the document GET alone records nothing).
|
||||
// Sent once per load, after the auth probes so the admin gate applies;
|
||||
@@ -538,7 +616,10 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
if (url.origin !== location.origin) {
|
||||
// External link: the browser navigates; record the full https URL so
|
||||
// different links to the same domain stay distinct in analytics.
|
||||
if (url.protocol === "https:") ping(url.href);
|
||||
if (url.protocol === "https:") {
|
||||
closePingedFor = currentPath;
|
||||
ping(url.href, currentPath, takeReadTime());
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Same-page anchor links (footnotes etc.): let the browser handle them
|
||||
@@ -550,7 +631,12 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
ev.preventDefault();
|
||||
// Capture the source now: load() updates currentPath before pinging.
|
||||
const from = currentPath;
|
||||
load(url).then((ok) => { if (ok) ping(url.pathname, from); });
|
||||
load(url).then((ok) => {
|
||||
if (!ok) return;
|
||||
closePingedFor = null;
|
||||
ping(url.pathname, from, takeReadTime());
|
||||
resetReadTime();
|
||||
});
|
||||
});
|
||||
|
||||
addEventListener("popstate", () => {
|
||||
|
||||
+233
-13
@@ -5,10 +5,14 @@ ping on page load starts a visit, later pings extend it, and pings with no
|
||||
known session start a fresh one (missing data, not dropped). The document
|
||||
GET handler stashes the entry referer (external https origin) and any
|
||||
utm_* query parameters in in-memory IP tables, consumed when the ping
|
||||
starts the visit; nothing is counted without a ping (bots and admin
|
||||
browsing stay invisible). The session map is in-memory only. The visitor
|
||||
IP and, when available, its reverse-DNS host name are stored on the visit
|
||||
record itself.
|
||||
starts the visit; nothing is counted without a ping (bots stay invisible).
|
||||
Admin clients ping with ``hide=1``, which records nothing and removes any
|
||||
visit the session accumulated before logging in. Scanner telltale 404s
|
||||
(dotpaths, *.php) classify the source IP as abuse; its hits — including
|
||||
earlier crawler hits — are moved to the abuse list, which the viewer
|
||||
groups by IP with full request paths. The session map is in-memory only.
|
||||
The visitor IP and, when available, its reverse-DNS host name are stored
|
||||
on the visit record itself.
|
||||
|
||||
Data is a msgspec Struct JSON-dumped to its own file (not the kanta db),
|
||||
rewritten atomically on every recorded event.
|
||||
@@ -41,7 +45,10 @@ def _compact_user_agent(ua: str) -> str:
|
||||
dev = r.device.family if r.device else None
|
||||
if browser in (None, "Other") and os_name in (None, "Other"):
|
||||
return ua
|
||||
browser = browser if browser and browser != "Other" else ""
|
||||
if browser and browser != "Other":
|
||||
browser = browser.split()[0]
|
||||
else:
|
||||
browser = ""
|
||||
os_name = os_name if os_name and os_name != "Other" else ""
|
||||
if dev in (None, "Other") or dev == browser:
|
||||
dev = ""
|
||||
@@ -79,6 +86,8 @@ class Visit(msgspec.Struct, omit_defaults=True):
|
||||
ua_pretty: str = ""
|
||||
#: UTM query parameters from the landing URL, keyed by parameter name.
|
||||
utm: dict[str, str] = {}
|
||||
#: Active reading time per path (seconds), keyed by path.
|
||||
read: dict[str, int] = {}
|
||||
|
||||
|
||||
class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
||||
@@ -96,6 +105,29 @@ class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
||||
query: str = ""
|
||||
|
||||
|
||||
class AbuseHit(msgspec.Struct, omit_defaults=True):
|
||||
"""A request from an IP classified as a scanner/abuser.
|
||||
|
||||
Unlike crawler hits the full request path (query string included) is
|
||||
kept: the interesting part is exactly which paths were probed.
|
||||
``flag`` marks the path that triggered classification; ``is_404``
|
||||
distinguishes 404 responses from document GETs made by the abuser.
|
||||
"""
|
||||
|
||||
start: datetime
|
||||
#: Full request path including the query string (e.g. "/.env?x=1").
|
||||
path: str
|
||||
ip: str = ""
|
||||
ua: str = ""
|
||||
#: Compact display form of ``ua`` when parsable.
|
||||
ua_pretty: str = ""
|
||||
#: True when this path triggered abuse classification (telltale path
|
||||
#: or the 404 that crossed the threshold).
|
||||
flag: bool = False
|
||||
#: True for 404 responses; false for document GETs from the abuser.
|
||||
is_404: bool = False
|
||||
|
||||
|
||||
class Analytics(msgspec.Struct, omit_defaults=True):
|
||||
"""Root of the analytics JSON file. Append-only by design: old data is
|
||||
dropped by deleting list entries / bucket keys."""
|
||||
@@ -103,6 +135,10 @@ class Analytics(msgspec.Struct, omit_defaults=True):
|
||||
visits: list[Visit] = []
|
||||
#: Document GETs that never produced a ping, treated as crawler/bot hits.
|
||||
crawlers: list[CrawlerHit] = []
|
||||
#: Requests from abusive IPs (see AbuseHit), grouped by IP in the viewer.
|
||||
abuse: list[AbuseHit] = []
|
||||
#: IPs classified as scanners/abusers (keys; values always True).
|
||||
abuse_ips: dict[str, bool] = {}
|
||||
#: Page transitions per 5-minute bucket (sparse):
|
||||
#: from -> to -> bucket ISO -> count. ``from`` is the referer origin or
|
||||
#: "(direct)" for initial loads, a page path for pings.
|
||||
@@ -186,6 +222,19 @@ def _utm_tags(query: str) -> dict[str, str]:
|
||||
|
||||
_CRAWLER_TIMEOUT = timedelta(seconds=10)
|
||||
|
||||
#: Plain-404 count per IP that classifies it as abuse even without a
|
||||
#: telltale path hit.
|
||||
_ABUSE_404_THRESHOLD = 10
|
||||
|
||||
#: Paths that instantly classify an IP as abuse when they 404: any segment
|
||||
#: starting with a dot ("/.env", "/.git/config") or ending in ".php".
|
||||
_ABUSE_PATH = re.compile(r"(^|/)\.|\.php$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_abuse_path(path: str) -> bool:
|
||||
"""Telltale scanner path: dot segment or *.php."""
|
||||
return bool(_ABUSE_PATH.search(path.split("?")[0]))
|
||||
|
||||
|
||||
class Store:
|
||||
"""In-memory analytics data plus the (IP, UA) -> visit session map."""
|
||||
@@ -212,6 +261,9 @@ class Store:
|
||||
#: Document GETs that have not yet been matched by a ping. Kept
|
||||
#: in RAM only; expired entries are written to ``data.crawlers``.
|
||||
self.pending_crawlers: list[CrawlerHit] = []
|
||||
#: ip -> number of plain (non-telltale) 404s seen, in RAM only;
|
||||
#: reaching ``_ABUSE_404_THRESHOLD`` classifies the IP as abuse.
|
||||
self.not_found_counts: dict[str, int] = {}
|
||||
#: Callables to notify when persisted data changes. Registered by the
|
||||
#: analytics WebSocket broadcaster.
|
||||
self._on_change: list[Callable[[], None]] = []
|
||||
@@ -267,6 +319,123 @@ class Store:
|
||||
buckets = self.data.transitions.setdefault(fr, {}).setdefault(to, {})
|
||||
self._count(buckets, _bucket(now))
|
||||
|
||||
def _uncount(self, table: dict[str, int], key: str) -> None:
|
||||
"""Reverse one ``_count``: decrement and drop empty keys."""
|
||||
if key in table:
|
||||
table[key] -= 1
|
||||
if table[key] <= 0:
|
||||
del table[key]
|
||||
|
||||
def _remove_visit(self, index: int) -> None:
|
||||
"""Delete a visit and reverse the counts its creation recorded.
|
||||
|
||||
Used when a known visitor turns out to be an admin (hide=1 ping):
|
||||
the session is scrubbed from the stats. Views/transitions logged
|
||||
by later pings inside the visit lack per-event timestamps and are
|
||||
left as-is.
|
||||
"""
|
||||
visit = self.data.visits[index]
|
||||
bucket = _bucket(visit.start)
|
||||
self._uncount(self.data.site_visits, bucket)
|
||||
views = self.data.views.get(visit.entry)
|
||||
if views is not None:
|
||||
self._uncount(views, bucket)
|
||||
if not views:
|
||||
del self.data.views[visit.entry]
|
||||
fr_map = self.data.transitions.get(visit.referer or "(direct)")
|
||||
if fr_map is not None:
|
||||
buckets = fr_map.get(visit.entry)
|
||||
if buckets is not None:
|
||||
self._uncount(buckets, bucket)
|
||||
if not buckets:
|
||||
del fr_map[visit.entry]
|
||||
if not fr_map:
|
||||
del self.data.transitions[visit.referer or "(direct)"]
|
||||
del self.data.visits[index]
|
||||
# Sessions store list indices; shift the ones past the removed visit.
|
||||
for key, i in list(self.sessions.items()):
|
||||
if i > index:
|
||||
self.sessions[key] = i - 1
|
||||
|
||||
def _abuse_hit(
|
||||
self,
|
||||
ip: str,
|
||||
ua: str,
|
||||
path: str,
|
||||
start: datetime | None = None,
|
||||
*,
|
||||
flag: bool = False,
|
||||
is_404: bool = False,
|
||||
) -> None:
|
||||
"""Append one abuse hit with the full request path."""
|
||||
self.data.abuse.append(
|
||||
AbuseHit(
|
||||
start=start or datetime.now(UTC),
|
||||
path=path,
|
||||
ip=ip,
|
||||
ua=ua,
|
||||
ua_pretty=_compact_user_agent(ua),
|
||||
flag=flag,
|
||||
is_404=is_404,
|
||||
)
|
||||
)
|
||||
|
||||
def classify_abuse(
|
||||
self,
|
||||
ip: str,
|
||||
ua: str,
|
||||
path: str,
|
||||
*,
|
||||
flag: bool = False,
|
||||
is_404: bool = False,
|
||||
) -> None:
|
||||
"""Classify an IP as a scanner/abuser and record the triggering hit.
|
||||
|
||||
All earlier crawler hits from the same IP (persisted and pending)
|
||||
are moved to the abuse list — a random-UA scanner must not pollute
|
||||
the crawler stats of the legitimate bots it impersonates.
|
||||
"""
|
||||
if ip not in self.data.abuse_ips:
|
||||
self.data.abuse_ips[ip] = True
|
||||
moved = [h for h in self.data.crawlers if h.ip == ip]
|
||||
if moved:
|
||||
self.data.crawlers = [h for h in self.data.crawlers if h.ip != ip]
|
||||
for h in moved:
|
||||
self._abuse_hit(
|
||||
h.ip, h.ua,
|
||||
h.entry + (f"?{h.query}" if h.query else ""),
|
||||
start=h.start,
|
||||
)
|
||||
pending = [h for h in self.pending_crawlers if h.ip == ip]
|
||||
if pending:
|
||||
self.pending_crawlers = [h for h in self.pending_crawlers if h.ip != ip]
|
||||
for h in pending:
|
||||
self._abuse_hit(
|
||||
h.ip, h.ua,
|
||||
h.entry + (f"?{h.query}" if h.query else ""),
|
||||
start=h.start,
|
||||
)
|
||||
self._abuse_hit(ip, ua, path, flag=flag, is_404=is_404)
|
||||
self._save()
|
||||
|
||||
def track_404(self, ip: str, ua: str, path: str) -> None:
|
||||
"""Record a 404 response for ``path`` (full path, query included).
|
||||
|
||||
A telltale path (dot segment or *.php) classifies the IP as abuse
|
||||
immediately; enough plain 404s from one IP do too. Hits from
|
||||
already-classified IPs go straight to the abuse list.
|
||||
"""
|
||||
if ip in self.data.abuse_ips:
|
||||
self._abuse_hit(ip, ua, path, flag=_is_abuse_path(path), is_404=True)
|
||||
self._save()
|
||||
return
|
||||
if _is_abuse_path(path):
|
||||
self.classify_abuse(ip, ua, path, flag=True, is_404=True)
|
||||
return
|
||||
self.not_found_counts[ip] = self.not_found_counts.get(ip, 0) + 1
|
||||
if self.not_found_counts[ip] >= _ABUSE_404_THRESHOLD:
|
||||
self.classify_abuse(ip, ua, path, flag=True, is_404=True)
|
||||
|
||||
def _new_visit(
|
||||
self,
|
||||
entry: str,
|
||||
@@ -328,8 +497,7 @@ class Store:
|
||||
own_origin: str,
|
||||
ip: str,
|
||||
ua: str,
|
||||
entry: str,
|
||||
query: str = "",
|
||||
full_path: str,
|
||||
) -> None:
|
||||
"""Stash the entry referer/UTM tags and queue a pending crawler hit.
|
||||
|
||||
@@ -343,7 +511,17 @@ class Store:
|
||||
Every document GET is also queued as a pending crawler hit. If a ping
|
||||
from the same (IP, UA) pair arrives within ``_CRAWLER_TIMEOUT``, the
|
||||
hit is discarded; otherwise it is flushed to ``data.crawlers``.
|
||||
|
||||
GETs from IPs already classified as abuse are recorded as abuse hits
|
||||
with the full request path (query string included).
|
||||
"""
|
||||
entry = full_path.split("?")[0]
|
||||
query = full_path.split("?", 1)[1] if "?" in full_path else ""
|
||||
if ip in self.data.abuse_ips:
|
||||
self._flush_crawlers()
|
||||
self._abuse_hit(ip, ua, full_path, is_404=False, flag=False)
|
||||
self._save()
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
self._flush_crawlers(now)
|
||||
if referer:
|
||||
@@ -365,31 +543,73 @@ class Store:
|
||||
)
|
||||
)
|
||||
|
||||
def _add_read(self, ip: str, ua: str, path: str, seconds: int) -> None:
|
||||
"""Add ``seconds`` of reading time for ``path`` to the current visit."""
|
||||
if seconds <= 0:
|
||||
return
|
||||
index = self.sessions.get((ip, ua))
|
||||
if index is None or index >= len(self.data.visits):
|
||||
return
|
||||
visit = self.data.visits[index]
|
||||
visit.read[path] = visit.read.get(path, 0) + seconds
|
||||
|
||||
def ping(
|
||||
self,
|
||||
from_: str,
|
||||
to: str,
|
||||
to: str | None,
|
||||
ip: str,
|
||||
ua: str,
|
||||
accept_language: str = "",
|
||||
hide: bool = False,
|
||||
read: int = 0,
|
||||
) -> int | None:
|
||||
"""Record a client navigation ping ({from, to} from pagerite.js).
|
||||
"""Record a client navigation ping ({from, to, read} from pagerite.js).
|
||||
|
||||
``to`` is an internal path ("/...") or an https URL for exit links; a
|
||||
missing/empty ``to`` means the page is being closed and only the
|
||||
``read`` time should be recorded. The transition is always counted when
|
||||
``to`` is present; the trail only grows on first sight of a page within
|
||||
the visit. ``read`` is the active time (seconds) spent on ``from_``.
|
||||
|
||||
``to`` is an internal path ("/...") or an https URL for exit links;
|
||||
anything else is ignored. The transition is always counted; the trail
|
||||
only grows on first sight of a page within the visit.
|
||||
A ping with no known session starts a fresh visit, consuming the
|
||||
referer and UTM tags stashed by the document GET if there are any.
|
||||
|
||||
``hide`` is set by admin clients: the ping cancels pending crawler
|
||||
hits as usual, and any existing visit for this (IP, UA) session is
|
||||
removed from the stats (the admin browsed anonymously before logging
|
||||
in). Nothing new is recorded.
|
||||
|
||||
Pings from IPs classified as abuse are ignored entirely.
|
||||
|
||||
Returns the index of the new visit when one is created, so callers
|
||||
can enrich it later with non-blocking lookups (host, geoip country).
|
||||
"""
|
||||
self._flush_crawlers()
|
||||
key = (ip, ua)
|
||||
if hide:
|
||||
# Admin ping: cancel pending crawler hits and scrub the session.
|
||||
self.pending_crawlers = [
|
||||
hit for hit in self.pending_crawlers if not (hit.ip == ip and hit.ua == ua)
|
||||
]
|
||||
index = self.sessions.pop(key, None)
|
||||
if index is not None and index < len(self.data.visits):
|
||||
self._remove_visit(index)
|
||||
self._save()
|
||||
return None
|
||||
if ip in self.data.abuse_ips:
|
||||
return None
|
||||
# A real visitor ping cancels any pending crawler hits from this
|
||||
# (IP, UA) pair.
|
||||
self.pending_crawlers = [
|
||||
hit for hit in self.pending_crawlers if not (hit.ip == ip and hit.ua == ua)
|
||||
]
|
||||
fr_path = _internal_path(from_) if from_ else ""
|
||||
if fr_path and read > 0:
|
||||
self._add_read(ip, ua, fr_path, read)
|
||||
if not to:
|
||||
if read > 0:
|
||||
self._save()
|
||||
return None
|
||||
if to.startswith("/") and not to.startswith("//"):
|
||||
target = _internal_path(to) or ""
|
||||
else:
|
||||
@@ -398,7 +618,7 @@ class Store:
|
||||
return None
|
||||
key = (ip, ua)
|
||||
index = self.sessions.get(key)
|
||||
fr = (_internal_path(from_) or "(direct)") if from_ else "(direct)"
|
||||
fr = fr_path or "(direct)"
|
||||
if index is None or index >= len(self.data.visits):
|
||||
# No known session: the initial ping of a fresh page load (or
|
||||
# missing data after a server restart) — start a visit.
|
||||
|
||||
+39
-6
@@ -126,13 +126,21 @@ class GeoIP:
|
||||
return ""
|
||||
|
||||
def city(self, ip: str) -> str:
|
||||
"""City name for ``ip``, or "" when unavailable."""
|
||||
"""City name for ``ip``, or "" when unavailable.
|
||||
|
||||
GeoIP sometimes appends district names in parentheses (e.g.
|
||||
"Berlin (Bezirk Tempelhof-Schöneberg)"); those are stripped before
|
||||
the value is stored.
|
||||
"""
|
||||
if not ip or self._reader is None:
|
||||
return ""
|
||||
try:
|
||||
rec = self._reader.get(ip)
|
||||
if rec:
|
||||
return (rec.get("city") or {}).get("names", {}).get("en", "")
|
||||
city = (rec.get("city") or {}).get("names", {}).get("en", "")
|
||||
if city:
|
||||
city = re.sub(r"\s*\([^)]*\)", "", city).strip()
|
||||
return city
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
@@ -605,6 +613,12 @@ def _client_ip(request: Request) -> str:
|
||||
return forwarded or (request.client.host if request.client else "")
|
||||
|
||||
|
||||
def _query_suffix(request: Request) -> str:
|
||||
"""The request's query string as a "?..." suffix, or "" when absent."""
|
||||
query = str(request.url.query)
|
||||
return f"?{query}" if query else ""
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _cached_ptr(ip: str) -> str:
|
||||
"""Reverse-DNS lookup with in-RAM LRU cache. Returns the host name or ""."""
|
||||
@@ -683,7 +697,11 @@ class AnalyticsPing(BaseModel):
|
||||
"""Navigation ping from pagerite.js (see docs/analytics.md)."""
|
||||
|
||||
fr: str = ""
|
||||
to: str
|
||||
to: str | None = None
|
||||
#: 1 from admin clients: scrub the session instead of recording it.
|
||||
hide: int = 0
|
||||
#: Active reading time on ``fr`` (ms), if any.
|
||||
read: int = 0
|
||||
|
||||
|
||||
@app.get("/_a", response_model=None)
|
||||
@@ -716,6 +734,8 @@ async def analytics_ping(ping: AnalyticsPing, request: Request) -> None:
|
||||
ip,
|
||||
request.headers.get("user-agent", ""),
|
||||
request.headers.get("accept-language", ""),
|
||||
hide=bool(ping.hide),
|
||||
read=ping.read,
|
||||
)
|
||||
if index is not None:
|
||||
asyncio.create_task(_enrich_visit(index, ip))
|
||||
@@ -725,7 +745,8 @@ def _track_entry(path: str, request: Request) -> None:
|
||||
"""Stash the referer/UTM tags and queue a pending crawler hit for the GET.
|
||||
|
||||
Nothing is counted on the GET itself — the client's /_a ping starts the
|
||||
visit, so bots and admin browsing never register as visits.
|
||||
visit, so bots never register as visits. (Admin clients ping too, but
|
||||
with hide=1, which scrubs their session instead of recording it.)
|
||||
|
||||
The devserver's health probe (``GET /?from=devserver.py`` from
|
||||
``127.0.0.1``) is ignored: it is not real traffic and would otherwise be
|
||||
@@ -739,13 +760,13 @@ def _track_entry(path: str, request: Request) -> None:
|
||||
):
|
||||
return
|
||||
own_origin = f"https://{urlparse(str(request.base_url)).netloc}"
|
||||
full_path = f"{request.url.path}{_query_suffix(request)}"
|
||||
analytics_store.track_entry(
|
||||
request.headers.get("referer", ""),
|
||||
own_origin,
|
||||
_client_ip(request),
|
||||
request.headers.get("user-agent", ""),
|
||||
"/" if path == "" else f"/{path}",
|
||||
str(request.url.query),
|
||||
full_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -968,6 +989,13 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
||||
if path and _is_reserved(path):
|
||||
# Invalid slug shape: not a content URL, let FastAPI return its
|
||||
# built-in 404 instead of rendering an editable article page.
|
||||
# Scanner telltales (dotpaths like /.env, *.php) classify the IP
|
||||
# as abuse in analytics.
|
||||
analytics_store.track_404(
|
||||
_client_ip(request),
|
||||
request.headers.get("user-agent", ""),
|
||||
f"/{path}{_query_suffix(request)}",
|
||||
)
|
||||
raise HTTPException(404)
|
||||
chain = resolve(data.menu, path)
|
||||
node = chain[-1] if chain else None
|
||||
@@ -1011,5 +1039,10 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
||||
if item.published:
|
||||
return RedirectResponse(f"/{slug}")
|
||||
if _is_trackable_path(path):
|
||||
analytics_store.track_404(
|
||||
_client_ip(request),
|
||||
request.headers.get("user-agent", ""),
|
||||
f"/{path}{_query_suffix(request)}",
|
||||
)
|
||||
_track_entry(path, request)
|
||||
return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html), 404)
|
||||
|
||||
Reference in New Issue
Block a user