Neater abuse table formatting.

This commit is contained in:
2026-08-21 22:31:37 +00:00
parent 0798e24d24
commit 51a6a16221
3 changed files with 93 additions and 82 deletions
+10 -5
View File
@@ -87,8 +87,11 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with
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.
theirs) in a separate "Abuse" table. Identical paths are collapsed into
one entry with their hit count; flagged paths that triggered classification
are lifted to the top, followed by other 404s and then document GETs from
the abuser. Raw User-Agent strings are shown one per line with their
occurrence counts, and the full lists are click-to-copy.
## Visits and sessions
@@ -144,9 +147,11 @@ Each `AbuseHit` record:
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.
hits are grouped by IP alone. In the Abuse table identical paths are
collapsed with their counts; flagged paths that triggered classification are
lifted to the top, followed by other 404s and then document GETs from the
abuser. Within each category paths are sorted by count descending, then by
their earliest hit.
## Aggregates
+45 -62
View File
@@ -151,7 +151,7 @@ function countryName(code) {
<tr v-for="(v, i) in visitRows" :key="i">
<td class="trail">
<TrailLink v-if="v.refererStep" :step="v.refererStep" @close="$emit('close')" />
<span v-if="v.utm && v.utm !== '—'" class="utm-tag" :title="v.utmTitle">{{ v.utm }}</span>
<span v-if="v.utm && v.utm !== '—'" class="utm-tag small muted" :title="v.utmTitle">{{ v.utm }}</span>
<TrailLink v-for="(s, si) in v.trail" :key="si" :step="s" @close="$emit('close')" />
</td>
<td class="ip-locale-cell" :class="{ 'host-cell': v.isHost }">
@@ -159,10 +159,10 @@ function countryName(code) {
<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-if="v.city && v.city !== '—'"><small class="city-name muted">{{ v.city }}</small></template>
<template v-else-if="!flagSvg(v.country)"></template>
</div>
<div class="ip-line"><span class="clickable-ip"
<div class="ip-line"><span class="clickable-ip small muted"
:title="v.ip"
@click="copyIp(v.ip, $event)">{{ v.ipDisplay }}</span></div>
</div>
@@ -172,7 +172,7 @@ function countryName(code) {
</div>
</div>
</td>
<td class="last-seen"
<td class="last-seen muted"
:title="v.lastSeenLocal"
@click="copyList(v.lastSeenIso, $event)">{{ v.lastSeen }}</td>
</tr>
@@ -199,12 +199,12 @@ function countryName(code) {
<TrailLink v-for="(s, si) in c.pages" :key="si" :step="s" :count="s.count" @close="$emit('close')" />
</td>
<td class="ip-ua-cell">
<div><span class="clickable-ip"
<div><span class="clickable-ip small muted"
: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="last-seen"
<td class="last-seen muted"
:title="c.lastSeenLocal"
@click="copyList(c.lastSeenIso, $event)">{{ c.lastSeen }}</td>
</tr>
@@ -229,30 +229,28 @@ function countryName(code) {
<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 class="abuse-items">
<span v-for="(p, pi) in a.paths.slice(0, ABUSE_MAX_LINES)" :key="pi"
class="inline-item">
<small v-if="p.count > 1" class="muted">{{ formatCount(p.count) }}×</small>{{ p.path }}
</span>
<small v-if="a.paths.length > ABUSE_MAX_LINES" class="muted">+{{ a.paths.length - ABUSE_MAX_LINES }} more</small>
</div>
</td>
<td class="ip-ua-cell">
<div><span class="clickable-ip"
<div><span class="clickable-ip small muted"
: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">
<span v-for="(u, ui) in a.uas.slice(0, ABUSE_MAX_LINES)" :key="ui"
class="inline-item">
<small v-if="u.count > 1" class="muted">{{ formatCount(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>
</span>
<small v-if="a.uas.length > ABUSE_MAX_LINES" class="muted">+{{ a.uas.length - ABUSE_MAX_LINES }} more</small>
</div>
</td>
<td class="last-seen"
<td class="last-seen muted"
:title="a.lastSeenLocal"
@click="copyList(a.lastSeenIso, $event)">{{ a.lastSeen }}</td>
</tr>
@@ -333,6 +331,15 @@ function countryName(code) {
margin-top: 1.8rem;
}
.analytics-view a {
color: var(--text);
text-decoration: none;
}
.analytics-view a:hover { color: var(--accent); }
.analytics-view :deep(.muted) { color: var(--muted); }
.analytics-view :deep(.small) { font-size: 0.75em; }
.totals {
display: flex;
gap: 2rem;
@@ -372,14 +379,9 @@ function countryName(code) {
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 {
max-width: 20rem;
overflow-wrap: break-word;
@@ -392,14 +394,9 @@ function countryName(code) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
text-decoration: none;
vertical-align: bottom;
}
.visit-table .trail a:hover,
.visit-table .trail-link:hover { color: var(--accent); }
.visit-table .trail > * + * {
margin-left: 0.5rem;
}
@@ -410,8 +407,6 @@ function countryName(code) {
padding: 0.05rem 0.4rem;
border: 1px solid var(--line);
border-radius: 0.25rem;
font-size: 0.75em;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -423,29 +418,26 @@ function countryName(code) {
max-width: 22rem;
}
.visit-table .clickable-list .list-line {
.visit-table .abuse-items,
.visit-table .abuse-uas-list {
display: flex;
flex-wrap: wrap;
gap: 0.15rem 0.5rem;
align-items: baseline;
}
.visit-table .abuse-uas-list {
justify-content: flex-end;
}
.visit-table .inline-item {
max-width: 18rem;
min-width: 0;
white-space: nowrap;
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);
font-size: 0.75em;
}
.visit-table .clickable-ip {
font-size: 0.75em;
word-break: keep-all;
hyphens: none;
}
.visit-table .clickable-ip,
@@ -455,12 +447,6 @@ function countryName(code) {
position: relative;
}
.visit-table .clickable-ip:hover,
.visit-table .clickable-list:hover,
.visit-table .last-seen:hover {
color: var(--accent);
}
.visit-table .ip-locale-cell {
width: 36ch;
max-width: 36ch;
@@ -547,9 +533,6 @@ function countryName(code) {
white-space: nowrap;
}
.visit-table .abuse-uas-list {
text-align: right;
}
.visit-table .copy-popup {
position: absolute;
+38 -15
View File
@@ -367,43 +367,59 @@ 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.
* the full paths probed. Identical paths are collapsed into one entry
* with their hit count. Flagged paths (the ones that triggered abuse
* classification) are lifted to the top, followed by other 404s, then
* document GETs from the abuser. Within each category paths are sorted by
* count descending, then earliest first. 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: [],
pathCounts: new Map(),
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,
const path = a.path || ''
const existing = g.pathCounts.get(path) || {
path,
count: 0,
firstStart: start,
flag: a.flag || false,
is_404: a.is_404 || false,
})
}
existing.count += 1
if (start < existing.firstStart) existing.firstStart = start
if (a.flag) existing.flag = true
if (!a.is_404) existing.is_404 = false
g.pathCounts.set(path, existing)
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
const totalHits = (g) => {
let n = 0
for (const p of g.pathCounts.values()) n += p.count
return n
}
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 paths = [...g.pathCounts.values()].sort(
(a, b) =>
pathCategory(a) - pathCategory(b) ||
b.count - a.count ||
a.firstStart - b.firstStart,
)
const uas = [...g.uaCounts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
@@ -411,8 +427,15 @@ export function formatAbuseRows(abuse, now = Date.now()) {
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'),
paths: paths.map((p) => ({
path: p.path,
count: p.count,
flag: p.flag,
is_404: p.is_404,
})),
allPaths: paths
.map((p) => (p.count > 1 ? `${p.count}× ${p.path}` : p.path))
.join('\n'),
uas: uas.map(([ua, count]) => ({ ua, count })),
allUas: uas
.map(([ua, count]) => (count > 1 ? `${count}× ${ua}` : ua))