diff --git a/docs/analytics.md b/docs/analytics.md index 89dc78d..9221cdb 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -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 diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index 8ab37e8..15d565c 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -151,7 +151,7 @@ function countryName(code) { - {{ v.utm }} + {{ v.utm }} @@ -159,10 +159,10 @@ function countryName(code) {
- +
-
{{ v.ipDisplay }}
@@ -172,7 +172,7 @@ function countryName(code) { - {{ v.lastSeen }} @@ -199,12 +199,12 @@ function countryName(code) { -
{{ c.ipDisplay }}
{{ c.ua }}
- {{ c.lastSeen }} @@ -229,30 +229,28 @@ function countryName(code) { -
- {{ p.path }} -
-
- +{{ a.paths.length - ABUSE_MAX_LINES }} more +
+ + {{ formatCount(p.count) }}×{{ p.path }} + + +{{ a.paths.length - ABUSE_MAX_LINES }} more
-
{{ a.ipDisplay }}
-
+ {{ formatCount(u.count) }}×{{ u.ua }} -
-
- +{{ a.uas.length - ABUSE_MAX_LINES }} more -
+ + +{{ a.uas.length - ABUSE_MAX_LINES }} more
- {{ a.lastSeen }} @@ -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; diff --git a/frontend/src/analytics/format.js b/frontend/src/analytics/format.js index 5a14c05..1fc923b 100644 --- a/frontend/src/analytics/format.js +++ b/frontend/src/analytics/format.js @@ -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))