analytics: unify visitor cell across visits, crawlers and abuse tables

This commit is contained in:
2026-08-21 23:32:46 +00:00
parent fdb3e42d6f
commit 375b4b6bdb
3 changed files with 56 additions and 64 deletions
+17 -37
View File
@@ -162,15 +162,12 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
</table> </table>
</div> </div>
<p v-else class="empty">no visits recorded yet</p> <p v-else class="empty">no visits recorded yet</p>
</section>
<section>
<h2>Crawlers</h2>
<div v-if="crawlerRows.length" class="visit-table-wrap"> <div v-if="crawlerRows.length" class="visit-table-wrap">
<table class="visit-table"> <table class="visit-table">
<thead> <thead>
<tr> <tr>
<th>pages</th> <th>pages crawled</th>
<th>visitor</th> <th>visitor</th>
<th class="last-seen">last seen</th> <th class="last-seen">last seen</th>
</tr> </tr>
@@ -199,15 +196,12 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
</table> </table>
</div> </div>
<p v-else class="empty">no crawler hits recorded yet</p> <p v-else class="empty">no crawler hits recorded yet</p>
</section>
<section v-if="abuseRows.length"> <div v-if="abuseRows.length" class="visit-table-wrap">
<h2>Abuse</h2>
<div class="visit-table-wrap">
<table class="visit-table"> <table class="visit-table">
<thead> <thead>
<tr> <tr>
<th>paths</th> <th>paths abused</th>
<th>visitor</th> <th>visitor</th>
<th class="last-seen">last seen</th> <th class="last-seen">last seen</th>
</tr> </tr>
@@ -224,19 +218,18 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
<small v-if="a.paths.length > ABUSE_MAX_LINES" class="muted">+{{ a.paths.length - ABUSE_MAX_LINES }} more</small> <small v-if="a.paths.length > ABUSE_MAX_LINES" class="muted">+{{ a.paths.length - ABUSE_MAX_LINES }} more</small>
</div> </div>
</td> </td>
<td class="ip-ua-cell"> <VisitorCell
<div><span class="clickable-ip small muted" :ip="a.ip"
:title="a.ip" :ip-display="a.ipDisplay"
@click="copyIp(a.ip, $event)">{{ a.ipDisplay }}</span></div> :ua="a.ua"
<div class="abuse-uas-list clickable-list" :ua-raw="a.uaRaw"
@click="copyList(a.allUas, $event)"> :country="a.country"
<span v-for="(u, ui) in a.uas.slice(0, ABUSE_MAX_LINES)" :key="ui" :city="a.city"
class="inline-item"> :lang="a.lang"
<small v-if="u.count > 1" class="muted">{{ formatCount(u.count) }}×</small>{{ u.ua }} :lang-display="a.langDisplay"
</span> :is-host="a.isHost"
<small v-if="a.uas.length > ABUSE_MAX_LINES" class="muted">+{{ a.uas.length - ABUSE_MAX_LINES }} more</small> :variant-count="a.clientCount"
</div> />
</td>
<td class="last-seen muted" <td class="last-seen muted"
:title="a.lastSeenLocal" :title="a.lastSeenLocal"
@click="copyList(a.lastSeenIso, $event)">{{ a.lastSeen }}</td> @click="copyList(a.lastSeenIso, $event)">{{ a.lastSeen }}</td>
@@ -363,7 +356,7 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
} }
.visit-table .last-seen { .visit-table .last-seen {
width: 7.5rem; width: 5rem;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: pointer;
@@ -405,18 +398,13 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
max-width: 22rem; max-width: 22rem;
} }
.visit-table .abuse-items, .visit-table .abuse-items {
.visit-table .abuse-uas-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.15rem 0.5rem; gap: 0.15rem 0.5rem;
align-items: baseline; align-items: baseline;
} }
.visit-table .abuse-uas-list {
justify-content: flex-end;
}
.visit-table .inline-item { .visit-table .inline-item {
max-width: 18rem; max-width: 18rem;
min-width: 0; min-width: 0;
@@ -434,14 +422,6 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
position: relative; position: relative;
} }
.visit-table .ip-ua-cell {
width: 22ch;
max-width: 22ch;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.visit-table :deep(.copy-popup) { .visit-table :deep(.copy-popup) {
position: absolute; position: absolute;
bottom: calc(100% + 0.25rem); bottom: calc(100% + 0.25rem);
+9 -3
View File
@@ -1,7 +1,9 @@
<script setup> <script setup>
// Visitor metadata cell shared by the recent-visits and crawlers tables. // Visitor metadata cell shared by the recent-visits, crawlers, and abuse tables.
// Displays IP/network/host, country flag/city, UA, and language when available. // Displays IP/network/host, country flag/city, UA, and language when available.
// Clicking the IP copies the full address to the clipboard. // Clicking the IP copies the full address to the clipboard.
// ``variantCount`` overrides the UA line to warn when multiple client
// fingerprints share the same IP (e.g. a scanner rotating UAs).
import { computed } from 'vue' import { computed } from 'vue'
import * as flagSvgs from 'country-flag-icons/string/3x2' import * as flagSvgs from 'country-flag-icons/string/3x2'
import { copyIp, formatLang } from './analytics/format.js' import { copyIp, formatLang } from './analytics/format.js'
@@ -16,6 +18,7 @@ const props = defineProps({
lang: { type: String, default: '' }, lang: { type: String, default: '' },
langDisplay: { type: String, default: '' }, langDisplay: { type: String, default: '' },
isHost: { type: Boolean, default: false }, isHost: { type: Boolean, default: false },
variantCount: { type: Number, default: 1 },
}) })
const hasCountry = computed(() => !!(props.country && props.country !== '—')) const hasCountry = computed(() => !!(props.country && props.country !== '—'))
@@ -54,8 +57,11 @@ function countryName(code) {
</div> </div>
</div> </div>
<div class="visitor-row"> <div class="visitor-row">
<div class="ua-line"><small class="muted" :title="uaRaw">{{ ua || '—' }}</small></div> <div class="ua-line">
<div v-if="showLang" class="locale-lang"><small class="muted">{{ langValue }}</small></div> <small v-if="variantCount > 1" class="muted variant-hint">{{ variantCount }} client variations</small>
<small v-else class="muted" :title="uaRaw">{{ ua || '—' }}</small>
</div>
<div v-if="showLang && variantCount <= 1" class="locale-lang"><small class="muted">{{ langValue }}</small></div>
</div> </div>
</div> </div>
</td> </td>
+30 -24
View File
@@ -395,15 +395,15 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
} }
/** /**
* Group abuse hits by IP (never by UA — scanners randomize theirs to * Group abuse hits by IP and format each group as a row with the full paths
* masquerade as legitimate crawlers) and format each group as a row with * probed. Identical paths are collapsed into one entry with their hit count.
* the full paths probed. Identical paths are collapsed into one entry * Flagged paths (the ones that triggered abuse classification) are lifted to
* with their hit count. Flagged paths (the ones that triggered abuse * the top, followed by other 404s, then document GETs from the abuser. Within
* classification) are lifted to the top, followed by other 404s, then * each category paths are sorted by count descending, then earliest first.
* document GETs from the abuser. Within each category paths are sorted by * Rows are sorted by most recent hit first. Visitor metadata comes from the
* count descending, then earliest first. UAs are shown raw, one per line, * latest client hash seen for the IP; ``clientCount`` tells the visitor cell
* with their occurrence counts. Paths are shown verbatim (query string * how many distinct client variations the IP produced. Paths are shown
* included), not resolved against the page tree. * verbatim (query string included), not resolved against the page tree.
* ``clients`` maps client hashes to client records. * ``clients`` maps client hashes to client records.
*/ */
export function formatAbuseRows(abuse, clients, now = Date.now()) { export function formatAbuseRows(abuse, clients, now = Date.now()) {
@@ -414,12 +414,15 @@ export function formatAbuseRows(abuse, clients, now = Date.now()) {
const g = groups.get(ip) || { const g = groups.get(ip) || {
ip, ip,
pathCounts: new Map(), pathCounts: new Map(),
rawUas: [], clientHashes: new Set(),
uaCounts: new Map(),
lastStart: 0, lastStart: 0,
lastClient: a.client,
} }
const start = new Date(a.start).getTime() const start = new Date(a.start).getTime()
if (start > g.lastStart) g.lastStart = start if (start > g.lastStart) {
g.lastStart = start
g.lastClient = a.client
}
const path = a.path || '' const path = a.path || ''
const existing = g.pathCounts.get(path) || { const existing = g.pathCounts.get(path) || {
path, path,
@@ -433,9 +436,7 @@ export function formatAbuseRows(abuse, clients, now = Date.now()) {
if (a.flag) existing.flag = true if (a.flag) existing.flag = true
if (!a.is_404) existing.is_404 = false if (!a.is_404) existing.is_404 = false
g.pathCounts.set(path, existing) g.pathCounts.set(path, existing)
const ua = client.ua || '(no UA)' g.clientHashes.add(a.client)
g.rawUas.push(ua)
g.uaCounts.set(ua, (g.uaCounts.get(ua) || 0) + 1)
groups.set(ip, g) groups.set(ip, g)
} }
const totalHits = (g) => { const totalHits = (g) => {
@@ -444,7 +445,7 @@ export function formatAbuseRows(abuse, clients, now = Date.now()) {
return n return n
} }
return [...groups.values()] return [...groups.values()]
.sort((a, b) => totalHits(b) - totalHits(a) || b.lastStart - a.lastStart) .sort((a, b) => b.lastStart - a.lastStart)
.slice(0, 10) .slice(0, 10)
.map((g) => { .map((g) => {
const pathCategory = (p) => (p.flag ? 0 : p.is_404 ? 1 : 2) const pathCategory = (p) => (p.flag ? 0 : p.is_404 ? 1 : 2)
@@ -454,8 +455,9 @@ export function formatAbuseRows(abuse, clients, now = Date.now()) {
b.count - a.count || b.count - a.count ||
a.firstStart - b.firstStart, a.firstStart - b.firstStart,
) )
const uas = [...g.uaCounts.entries()] const client = (clients || {})[g.lastClient] || {}
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) const host = client.host || ''
const isHost = !!host
return { return {
lastSeen: formatWhen(g.lastStart, now), lastSeen: formatWhen(g.lastStart, now),
lastSeenIso: formatWhenIso(g.lastStart), lastSeenIso: formatWhenIso(g.lastStart),
@@ -469,12 +471,16 @@ export function formatAbuseRows(abuse, clients, now = Date.now()) {
allPaths: paths allPaths: paths
.map((p) => (p.count > 1 ? `${p.count}× ${p.path}` : p.path)) .map((p) => (p.count > 1 ? `${p.count}× ${p.path}` : p.path))
.join('\n'), .join('\n'),
uas: uas.map(([ua, count]) => ({ ua, count })), clientCount: g.clientHashes.size,
allUas: uas ip: client.ip || g.ip,
.map(([ua, count]) => (count > 1 ? `${count}× ${ua}` : ua)) ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip || g.ip) || client.ip || g.ip || '—',
.join('\n'), isHost,
ip: g.ip, ua: client.ua_pretty || client.ua || '—',
ipDisplay: hostIP(g.ip) || g.ip || '', uaRaw: client.ua || '',
lang: client.lang || '—',
langDisplay: formatLang(client.lang),
country: client.country || '—',
city: client.city || '—',
total: totalHits(g), total: totalHits(g),
} }
}) })