analytics: unify visitor cell across visits, crawlers and abuse tables
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user