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:
2026-08-21 20:14:04 +00:00
parent f341d22aa0
commit 6eaa1c1a8b
11 changed files with 1119 additions and 159 deletions
+260 -45
View File
@@ -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;