analytics improvements:
- keep visitor charts y-axis minimum range at 10 - keep 'all' chart x-axis minimum span at 30 days - group crawler hits by (ip, ua) and list top pages visited, show crawler page load counts as N× prefix - store and display geoip city, keep geoip country overwrite - stream live updates over WebSocket /_api/ws/analytics - include family ring arcs in transition map crop bounds - remove top UA summary, limit crawlers to 10 and visits to 20 - human-readable relative timestamps with UTC tooltip
This commit is contained in:
@@ -1,16 +1,14 @@
|
||||
<script setup>
|
||||
// Analytics viewer rendered as a normal page inside #main. Fetches the raw
|
||||
// collected data from /_api/analytics (admin-gated by the auth proxy) and
|
||||
// Analytics viewer rendered as a normal page inside #main. Receives live
|
||||
// analytics data over /_api/ws/analytics (admin-gated by the auth proxy) and
|
||||
// renders totals, smoothed visit/views curves, a transition map, and recent
|
||||
// visit/crawler tables. Read-only.
|
||||
// See docs/analytics.md for the data format.
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { RANGES } from './analytics/time.js'
|
||||
import {
|
||||
calcTotalViews,
|
||||
copyIp,
|
||||
countCrawlerUas,
|
||||
formatCounts,
|
||||
formatCrawlerRows,
|
||||
formatVisitRows,
|
||||
} from './analytics/format.js'
|
||||
@@ -25,23 +23,54 @@ const props = defineProps({
|
||||
const data = ref(null)
|
||||
const pageTree = ref(null)
|
||||
const error = ref('')
|
||||
const now = ref(Date.now())
|
||||
let ws = null
|
||||
let reconnectTimeout = null
|
||||
let timeInterval = null
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/_api/analytics')
|
||||
if (!res.ok) throw new Error(res.statusText)
|
||||
data.value = await res.json()
|
||||
} catch {
|
||||
function connectAnalytics() {
|
||||
if (ws) return
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`)
|
||||
ws.onopen = () => { error.value = '' }
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
data.value = JSON.parse(event.data)
|
||||
} catch {
|
||||
error.value = 'analytics data could not be loaded'
|
||||
}
|
||||
}
|
||||
ws.onerror = () => {
|
||||
error.value = 'analytics data could not be loaded'
|
||||
}
|
||||
ws.onclose = () => {
|
||||
ws = null
|
||||
reconnectTimeout = setTimeout(connectAnalytics, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
connectAnalytics()
|
||||
now.value = Date.now()
|
||||
timeInterval = setInterval(() => { now.value = Date.now() }, 30000)
|
||||
// The site tree for the transition map (all pages in menu order). Not
|
||||
// fatal: without it the map falls back to transition endpoints only.
|
||||
// fatal: without it the map just narrows to pages seen in transitions.
|
||||
try {
|
||||
const res = await fetch('/_api/pages')
|
||||
if (res.ok) pageTree.value = await res.json()
|
||||
} catch { /* map just narrows to pages seen in transitions */ }
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (reconnectTimeout) clearTimeout(reconnectTimeout)
|
||||
if (timeInterval) clearInterval(timeInterval)
|
||||
if (ws) {
|
||||
ws.onclose = null
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
})
|
||||
|
||||
const visits = computed(() => data.value?.visits || [])
|
||||
const totalViews = computed(() => calcTotalViews(data.value?.views))
|
||||
|
||||
@@ -54,10 +83,9 @@ watch(range, (r) => {
|
||||
history.replaceState(null, '', url)
|
||||
})
|
||||
|
||||
const visitRows = computed(() => formatVisitRows(visits.value, pageTree.value))
|
||||
const visitRows = computed(() => formatVisitRows(visits.value, pageTree.value, now.value))
|
||||
const crawlers = computed(() => data.value?.crawlers || [])
|
||||
const crawlerRows = computed(() => formatCrawlerRows(crawlers.value))
|
||||
const topCrawlerUas = computed(() => countCrawlerUas(crawlers.value).slice(0, 10))
|
||||
const crawlerRows = computed(() => formatCrawlerRows(crawlers.value, pageTree.value, now.value))
|
||||
|
||||
function flagSvg(code) {
|
||||
return flagSvgs[code?.toUpperCase()] || ''
|
||||
@@ -115,10 +143,10 @@ function countryName(code) {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(v, i) in visitRows" :key="i">
|
||||
<td class="when">{{ v.when }}</td>
|
||||
<td class="when" :title="v.whenTooltip">{{ v.when }}</td>
|
||||
<td class="trail">
|
||||
<a v-for="(s, si) in v.trail" :key="si"
|
||||
:href="s.path" :title="s.title" @click="emit('close')">
|
||||
:href="s.path" :title="s.title" @click="$emit('close')">
|
||||
{{ s.slug }}
|
||||
</a>
|
||||
</td>
|
||||
@@ -131,6 +159,8 @@ function countryName(code) {
|
||||
<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>
|
||||
@@ -144,33 +174,32 @@ function countryName(code) {
|
||||
|
||||
<section>
|
||||
<h2>Crawlers</h2>
|
||||
<div v-if="topCrawlerUas.length" class="crawler-top-uas">
|
||||
<p><strong>top UAs:</strong> {{ formatCounts(topCrawlerUas) }}</p>
|
||||
</div>
|
||||
<div v-if="crawlerRows.length" class="visit-table-wrap">
|
||||
<table class="visit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>when</th>
|
||||
<th>entry</th>
|
||||
<th>pages</th>
|
||||
<th>ip</th>
|
||||
<th>ua</th>
|
||||
<th>referer</th>
|
||||
<th>query</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(c, i) in crawlerRows" :key="i">
|
||||
<td class="when">{{ c.when }}</td>
|
||||
<td>{{ c.entry }}</td>
|
||||
<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)` : ''}`"
|
||||
@click="$emit('close')">
|
||||
<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>
|
||||
<td class="ua" :title="c.uaRaw">{{ c.ua }}</td>
|
||||
<td>{{ c.referer }}</td>
|
||||
<td>{{ c.query }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -307,6 +336,12 @@ function countryName(code) {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.visit-table .trail small,
|
||||
.visit-table small.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.visit-table .clickable-ip {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -14,9 +14,9 @@ export const PAD_TOP = 14 // room above the highest point
|
||||
/**
|
||||
* Y always starts at 0; the max is a multiple of a 1-2-5 major step with at
|
||||
* most 5 intervals, so labeled ticks are always round and evenly divided.
|
||||
* Values are per-unit rates, so small scales are legitimate (a lone visit
|
||||
* smoothes to well under 1/unit) — the floor is 1, not 10. Minor lines
|
||||
* subdivide each major step in five when that yields integers.
|
||||
* A minimum range of 10 keeps tiny near-zero values (e.g. a single visit)
|
||||
* from being enlarged to a fractional scale; minor lines subdivide each
|
||||
* major step in five when that yields integers.
|
||||
*/
|
||||
export function yScale(maxValue) {
|
||||
let step = 1
|
||||
@@ -27,9 +27,9 @@ export function yScale(maxValue) {
|
||||
}
|
||||
}
|
||||
let max = Math.ceil(maxValue / step) * step
|
||||
if (max < 1) {
|
||||
max = 1
|
||||
step = 0.5
|
||||
if (max < 10) {
|
||||
max = 10
|
||||
step = 2
|
||||
}
|
||||
const minor = step >= 5 && step % 5 === 0 ? step / 5 : null
|
||||
return { max, step, minor }
|
||||
|
||||
@@ -62,6 +62,59 @@ function slugOf(path) {
|
||||
return path === '/' ? '🏠' : path.split('/').pop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable relative timestamp. Adapted from cista-storage: uses
|
||||
* ``Intl.RelativeTimeFormat`` for short intervals and a compact date for
|
||||
* anything older than a week.
|
||||
*/
|
||||
export function formatWhen(ts, now = Date.now()) {
|
||||
const date = new Date(ts)
|
||||
const diff = date.getTime() - now
|
||||
const adiff = Math.abs(diff)
|
||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
||||
if (adiff <= 5000) return 'now'
|
||||
if (adiff <= 60000) {
|
||||
return formatter
|
||||
.format(Math.round(diff / 1000), 'second')
|
||||
.replace(' ago', '')
|
||||
.replaceAll(' ', '\u202F')
|
||||
}
|
||||
if (adiff <= 3600000) {
|
||||
return formatter
|
||||
.format(Math.round(diff / 60000), 'minute')
|
||||
.replace('utes', '')
|
||||
.replace('ute', '')
|
||||
.replaceAll(' ', '\u202F')
|
||||
}
|
||||
if (adiff <= 86400000) {
|
||||
return formatter
|
||||
.format(Math.round(diff / 3600000), 'hour')
|
||||
.replaceAll(' ', '\u202F')
|
||||
}
|
||||
if (adiff <= 604800000) {
|
||||
return formatter
|
||||
.format(Math.round(diff / 86400000), 'day')
|
||||
.replaceAll(' ', '\u202F')
|
||||
}
|
||||
let d = date
|
||||
.toLocaleDateString('en-ie', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
.replace('Sept', 'Sep')
|
||||
if (d.length === 14) d = d.replace(' ', ' \u2007')
|
||||
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0')
|
||||
d = d.slice(0, -4) + d.slice(-2)
|
||||
return d
|
||||
}
|
||||
|
||||
/** Full UTC timestamp for tooltips, e.g. "2026-08-21 00:20:48 UTC". */
|
||||
export function formatWhenTooltip(ts) {
|
||||
return new Date(ts).toISOString().replace('T', ' ').replace('Z', ' UTC')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format recent visits for display, newest first. Each step is a linked slug
|
||||
* pointing to its article; external referers/origins and direct entries are
|
||||
@@ -133,31 +186,65 @@ export function countCrawlerUas(crawlers) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format raw crawler hit records as rows for a technical table. Missing
|
||||
* values become "—".
|
||||
* Group raw crawler hits by the same (ip, ua) pair we use to tell a real
|
||||
* visitor from a crawler, and format each group as a row showing every
|
||||
* internal page that crawler visited. Rows are sorted by total hits,
|
||||
* most active crawler first, rather than by most recent hit.
|
||||
*/
|
||||
export function formatCrawlerRows(crawlers) {
|
||||
const dash = (s) => (s || '—')
|
||||
return [...(crawlers || [])].reverse().map((c) => ({
|
||||
when: new Date(c.start).toLocaleString(),
|
||||
entry: dash(c.entry),
|
||||
ip: c.ip || '',
|
||||
ipDisplay: c.host || hostIP(c.ip) || c.ip || '—',
|
||||
ua: c.ua_pretty || c.ua || '—',
|
||||
uaRaw: c.ua || '',
|
||||
referer: dash(c.referer),
|
||||
query: dash(c.query),
|
||||
}))
|
||||
export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
|
||||
const titles = buildTitleMap(pageTree)
|
||||
const groups = new Map()
|
||||
for (const c of crawlers || []) {
|
||||
const key = `${c.ip}\0${c.ua}`
|
||||
const g = groups.get(key) || {
|
||||
ip: c.ip || '',
|
||||
ua: c.ua_pretty || c.ua || '—',
|
||||
uaRaw: c.ua || '',
|
||||
lastStart: 0,
|
||||
pages: new Map(),
|
||||
}
|
||||
const start = new Date(c.start).getTime()
|
||||
if (start > g.lastStart) g.lastStart = start
|
||||
if (c.entry?.startsWith('/')) {
|
||||
g.pages.set(c.entry, (g.pages.get(c.entry) || 0) + 1)
|
||||
}
|
||||
groups.set(key, g)
|
||||
}
|
||||
const totalHits = (g) => {
|
||||
let n = 0
|
||||
for (const c of g.pages.values()) n += c
|
||||
return n
|
||||
}
|
||||
return [...groups.values()]
|
||||
.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),
|
||||
pages: [...g.pages.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([path, count]) => ({
|
||||
path,
|
||||
slug: slugOf(path),
|
||||
title: titles.get(path) || '',
|
||||
count,
|
||||
})),
|
||||
ip: g.ip,
|
||||
ipDisplay: hostIP(g.ip) || g.ip || '—',
|
||||
ua: g.ua,
|
||||
uaRaw: g.uaRaw,
|
||||
total: totalHits(g),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Format raw visit records as rows for a technical table. Returns objects
|
||||
* with display strings; missing values become "—". ``trail`` joins page
|
||||
* titles (when known) with " -> ".
|
||||
* titles (when known) with " -> ". Only the 20 most recent visits are shown.
|
||||
*/
|
||||
export function formatVisitRows(visits, pageTree) {
|
||||
export function formatVisitRows(visits, pageTree, now = Date.now()) {
|
||||
const titles = buildTitleMap(pageTree)
|
||||
return [...(visits || [])].reverse().map((v) => {
|
||||
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
||||
const trail = [v.entry, ...(v.trail || [])]
|
||||
.filter((p) => p?.startsWith('/'))
|
||||
.map((p) => ({
|
||||
@@ -170,7 +257,8 @@ export function formatVisitRows(visits, pageTree) {
|
||||
.join(', ')
|
||||
const dash = (s) => (s || '—')
|
||||
return {
|
||||
when: new Date(v.start).toLocaleString(),
|
||||
when: formatWhen(v.start, now),
|
||||
whenTooltip: formatWhenTooltip(v.start),
|
||||
trail,
|
||||
referer: dash(v.referer),
|
||||
ip: v.ip || '',
|
||||
@@ -178,6 +266,7 @@ export function formatVisitRows(visits, pageTree) {
|
||||
host: dash(v.host),
|
||||
lang: dash(v.lang),
|
||||
country: dash(v.country),
|
||||
city: dash(v.city),
|
||||
ua: v.ua_pretty || v.ua || '—',
|
||||
uaRaw: v.ua || '',
|
||||
utm: utm || '—',
|
||||
|
||||
@@ -16,7 +16,7 @@ export const RANGES = {
|
||||
week: { label: 'week' },
|
||||
month: { label: 'month', span: 30 * DAY, bucket: 6 * HOUR },
|
||||
year: { label: 'year', span: 365 * DAY, bucket: DAY },
|
||||
all: { label: 'all', span: null, bucket: DAY },
|
||||
all: { label: 'all', span: null, bucket: DAY, minSpan: 30 * DAY },
|
||||
}
|
||||
|
||||
/** Monday 00:00 UTC of the week containing t (epoch day 0 was a Thursday). */
|
||||
@@ -89,16 +89,19 @@ export function weeklySeries(buckets) {
|
||||
/**
|
||||
* Rolling window for the non-week ranges (x max = now), counts converted
|
||||
* to per-day rates (the unit the month+ charts are read in).
|
||||
* Ranges without a fixed span use the full data reach, but never less than
|
||||
* their configured minSpan so the chart keeps a readable minimum x scale.
|
||||
*/
|
||||
export function rollingSeries(buckets, rangeKey) {
|
||||
const raw = rawTimes(buckets)
|
||||
const times = Object.keys(raw).map(Number)
|
||||
if (!times.length) return null
|
||||
const { span, bucket } = RANGES[rangeKey]
|
||||
const { span, bucket, minSpan = 0 } = RANGES[rangeKey]
|
||||
const t1 = Math.floor(Date.now() / bucket) * bucket + bucket
|
||||
const earliest = Math.floor(Math.min(...times) / bucket) * bucket
|
||||
const t0 = span != null
|
||||
? t1 - span
|
||||
: Math.floor(Math.min(...times) / bucket) * bucket
|
||||
: Math.min(earliest, t1 - minSpan)
|
||||
const points = []
|
||||
for (let t = t0; t < t1; t += bucket) {
|
||||
points.push({ t, count: sumRange(raw, t, t + bucket) })
|
||||
|
||||
@@ -231,11 +231,33 @@ function buildFamilyArcs(nodes, radius) {
|
||||
arcs.push({
|
||||
d: `M ${Math.cos(a0) * r} ${Math.sin(a0) * r} `
|
||||
+ `A ${r} ${r} 0 ${large} 1 ${Math.cos(a1) * r} ${Math.sin(a1) * r}`,
|
||||
r,
|
||||
a0,
|
||||
a1,
|
||||
})
|
||||
}
|
||||
return arcs
|
||||
}
|
||||
|
||||
/** Bounding box of a circular arc centred at the origin, sampled. */
|
||||
function arcBounds(r, a0, a1) {
|
||||
let x0 = Infinity
|
||||
let y0 = Infinity
|
||||
let x1 = -Infinity
|
||||
let y1 = -Infinity
|
||||
const steps = 36
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = a0 + (a1 - a0) * (i / steps)
|
||||
const x = Math.cos(t) * r
|
||||
const y = Math.sin(t) * r
|
||||
if (x < x0) x0 = x
|
||||
if (y < y0) y0 = y
|
||||
if (x > x1) x1 = x
|
||||
if (y > y1) y1 = y
|
||||
}
|
||||
return { x0, y0, x1, y1 }
|
||||
}
|
||||
|
||||
/** Collapse opposite transition directions into one unordered pair per page pair. */
|
||||
function aggregatePairs(internal) {
|
||||
const pairs = new Map() // unordered pair key -> [countAB, countBA]
|
||||
@@ -584,8 +606,9 @@ export function buildTransitionGraph(data, pageTree) {
|
||||
const pairs = aggregatePairs(internal)
|
||||
const { edges, flows } = buildInternalEdges(pairs, byPath)
|
||||
|
||||
// Tight bounding box of the actual page nodes; internal edges and arcs
|
||||
// stay within the node circles, so node bounds plus radius suffice.
|
||||
// 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
|
||||
// left side reaching around the right), so their geometry is included too.
|
||||
// External nodes extend the box below.
|
||||
const pad = 16
|
||||
const xs = nodes.map((n) => n.x)
|
||||
@@ -596,6 +619,14 @@ export function buildTransitionGraph(data, pageTree) {
|
||||
x1: Math.max(...xs) + TNODE_R + pad,
|
||||
y1: Math.max(...ys) + TNODE_R + pad,
|
||||
}
|
||||
for (const arc of arcs) {
|
||||
if (arc.a0 == null) continue
|
||||
const b = arcBounds(arc.r, arc.a0, arc.a1)
|
||||
bounds.x0 = Math.min(bounds.x0, b.x0)
|
||||
bounds.y0 = Math.min(bounds.y0, b.y0)
|
||||
bounds.x1 = Math.max(bounds.x1, b.x1)
|
||||
bounds.y1 = Math.max(bounds.y1, b.y1)
|
||||
}
|
||||
|
||||
const ext = buildExternal(external, byPath, radius, bounds)
|
||||
for (const xn of ext.extNodes) {
|
||||
|
||||
Reference in New Issue
Block a user