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.city }}
+ {{ v.city }}
—
- {{ 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))