analytics: shared Client struct, grouped abuse paths, unified visitor cell
This commit is contained in:
+31
-145
@@ -16,8 +16,8 @@ import {
|
||||
formatCrawlerRows,
|
||||
formatVisitRows,
|
||||
} from './analytics/format.js'
|
||||
import * as flagSvgs from 'country-flag-icons/string/3x2'
|
||||
import TrailLink from './TrailLink.vue'
|
||||
import VisitorCell from './VisitorCell.vue'
|
||||
import TransitionGraph from './TransitionGraph.vue'
|
||||
import VisitorCharts from './VisitorCharts.vue'
|
||||
|
||||
@@ -91,23 +91,12 @@ watch(range, (r) => {
|
||||
history.replaceState(null, '', url)
|
||||
})
|
||||
|
||||
const visitRows = computed(() => formatVisitRows(visits.value, pageTree.value, now.value))
|
||||
const clients = computed(() => data.value?.clients || {})
|
||||
const visitRows = computed(() => formatVisitRows(visits.value, clients.value, pageTree.value, now.value))
|
||||
const crawlers = computed(() => data.value?.crawlers || [])
|
||||
const crawlerRows = computed(() => formatCrawlerRows(crawlers.value, pageTree.value, now.value))
|
||||
const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], now.value))
|
||||
const crawlerRows = computed(() => formatCrawlerRows(crawlers.value, clients.value, pageTree.value, now.value))
|
||||
const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], clients.value, now.value))
|
||||
|
||||
function flagSvg(code) {
|
||||
return flagSvgs[code?.toUpperCase()] || ''
|
||||
}
|
||||
|
||||
function countryName(code) {
|
||||
if (!code) return ''
|
||||
try {
|
||||
return new Intl.DisplayNames(['en'], { type: 'region' }).of(code.toUpperCase())
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -154,24 +143,17 @@ function countryName(code) {
|
||||
<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 }">
|
||||
<div class="ip-locale-rows">
|
||||
<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 muted">{{ v.city }}</small></template>
|
||||
<template v-else-if="!flagSvg(v.country)">—</template>
|
||||
</div>
|
||||
<div class="ip-line"><span class="clickable-ip small muted"
|
||||
:title="v.ip"
|
||||
@click="copyIp(v.ip, $event)">{{ v.ipDisplay }}</span></div>
|
||||
</div>
|
||||
<div class="ip-locale-row">
|
||||
<div class="ua-line"><small class="muted" :title="v.uaRaw">{{ v.ua }}</small></div>
|
||||
<div v-if="v.lang && v.lang !== '—'" class="locale-lang"><small class="muted">{{ v.langDisplay }}</small></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<VisitorCell
|
||||
:ip="v.ip"
|
||||
:ip-display="v.ipDisplay"
|
||||
:ua="v.ua"
|
||||
:ua-raw="v.uaRaw"
|
||||
:country="v.country"
|
||||
:city="v.city"
|
||||
:lang="v.lang"
|
||||
:lang-display="v.langDisplay"
|
||||
:is-host="v.isHost"
|
||||
/>
|
||||
<td class="last-seen muted"
|
||||
:title="v.lastSeenLocal"
|
||||
@click="copyList(v.lastSeenIso, $event)">{{ v.lastSeen }}</td>
|
||||
@@ -189,7 +171,7 @@ function countryName(code) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>pages</th>
|
||||
<th>ip / ua</th>
|
||||
<th>visitor</th>
|
||||
<th class="last-seen">last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -198,12 +180,17 @@ function countryName(code) {
|
||||
<td class="trail">
|
||||
<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 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>
|
||||
<VisitorCell
|
||||
:ip="c.ip"
|
||||
:ip-display="c.ipDisplay"
|
||||
:ua="c.ua"
|
||||
:ua-raw="c.uaRaw"
|
||||
:country="c.country"
|
||||
:city="c.city"
|
||||
:lang="c.lang"
|
||||
:lang-display="c.langDisplay"
|
||||
:is-host="c.isHost"
|
||||
/>
|
||||
<td class="last-seen muted"
|
||||
:title="c.lastSeenLocal"
|
||||
@click="copyList(c.lastSeenIso, $event)">{{ c.lastSeen }}</td>
|
||||
@@ -221,7 +208,7 @@ function countryName(code) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>paths</th>
|
||||
<th>ip / uas</th>
|
||||
<th>visitor</th>
|
||||
<th class="last-seen">last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -440,20 +427,13 @@ function countryName(code) {
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
.visit-table .clickable-ip,
|
||||
.visit-table :deep(.clickable-ip),
|
||||
.visit-table .clickable-list,
|
||||
.visit-table .last-seen {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.visit-table .ip-locale-cell {
|
||||
width: 36ch;
|
||||
max-width: 36ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.visit-table .ip-ua-cell {
|
||||
width: 22ch;
|
||||
max-width: 22ch;
|
||||
@@ -462,79 +442,7 @@ function countryName(code) {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.visit-table .host-cell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.visit-table .ip-locale-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.visit-table .ip-locale-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.visit-table .ip-locale-row > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.visit-table .ip-locale-row .locale-line,
|
||||
.visit-table .ip-locale-row .ip-line,
|
||||
.visit-table .ip-locale-row .ua-line {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.visit-table .ip-locale-row .locale-lang {
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.visit-table .ip-locale-row .locale-line {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.visit-table .ip-locale-row .ip-line {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.visit-table .ip-locale-row .ua-line {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.visit-table .locale-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.visit-table .city-name {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.visit-table .ua-line {
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.visit-table .copy-popup {
|
||||
.visit-table :deep(.copy-popup) {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.25rem);
|
||||
left: 50%;
|
||||
@@ -549,28 +457,6 @@ function countryName(code) {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.visit-table .locale-line .flag {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.visit-table .locale-line .flag :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.visit-table .locale-line .city-name {
|
||||
margin-left: 0.3rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.crawler-top-uas {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.6rem;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { formatCount } from './analytics/format.js'
|
||||
|
||||
defineProps({
|
||||
step: { type: Object, required: true },
|
||||
count: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a class="trail-link"
|
||||
:href="step.path"
|
||||
:title="count > 1 ? `${step.title} (${count} hits)` : step.title"
|
||||
:target="step.external ? '_blank' : undefined"
|
||||
:rel="step.external ? 'noopener' : undefined"
|
||||
@click="(e) => { if (!step.external) $emit('close') }">
|
||||
<small v-if="count > 1" class="muted">{{ formatCount(count) }}×</small>
|
||||
<span :class="{ desat: step.home }">{{ step.slug }}</span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desat {
|
||||
filter: saturate(0);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup>
|
||||
// Visitor metadata cell shared by the recent-visits and crawlers tables.
|
||||
// Displays IP/network/host, country flag/city, UA, and language when available.
|
||||
// Clicking the IP copies the full address to the clipboard.
|
||||
import { computed } from 'vue'
|
||||
import * as flagSvgs from 'country-flag-icons/string/3x2'
|
||||
import { copyIp, formatLang } from './analytics/format.js'
|
||||
|
||||
const props = defineProps({
|
||||
ip: { type: String, default: '' },
|
||||
ipDisplay: { type: String, default: '—' },
|
||||
ua: { type: String, default: '' },
|
||||
uaRaw: { type: String, default: '' },
|
||||
country: { type: String, default: '' },
|
||||
city: { type: String, default: '' },
|
||||
lang: { type: String, default: '' },
|
||||
langDisplay: { type: String, default: '' },
|
||||
isHost: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const hasCountry = computed(() => !!(props.country && props.country !== '—'))
|
||||
const hasCity = computed(() => !!(props.city && props.city !== '—'))
|
||||
const hasLocale = computed(() => hasCountry.value || hasCity.value)
|
||||
const langValue = computed(() => props.langDisplay || formatLang(props.lang))
|
||||
const showLang = computed(() => langValue.value && langValue.value !== '—')
|
||||
|
||||
function flagSvg(code) {
|
||||
return flagSvgs[code?.toUpperCase()] || ''
|
||||
}
|
||||
|
||||
function countryName(code) {
|
||||
if (!code) return ''
|
||||
try {
|
||||
return new Intl.DisplayNames(['en'], { type: 'region' }).of(code.toUpperCase())
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td class="visitor-cell" :class="{ 'host-cell': isHost }">
|
||||
<div class="visitor-rows">
|
||||
<div class="visitor-row">
|
||||
<div class="locale-line">
|
||||
<span v-if="flagSvg(country)" class="flag" v-html="flagSvg(country)" :title="countryName(country) || country"></span>
|
||||
<template v-if="hasCity"><small class="city-name muted">{{ city }}</small></template>
|
||||
<template v-else-if="!hasLocale">—</template>
|
||||
</div>
|
||||
<div class="ip-line">
|
||||
<span class="clickable-ip small muted"
|
||||
:title="ip"
|
||||
@click="copyIp(ip, $event)">{{ ipDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="visitor-row">
|
||||
<div class="ua-line"><small class="muted" :title="uaRaw">{{ ua || '—' }}</small></div>
|
||||
<div v-if="showLang" class="locale-lang"><small class="muted">{{ langValue }}</small></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.visitor-cell {
|
||||
width: 18em;
|
||||
max-width: 18em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.visitor-cell.host-cell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.visitor-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.visitor-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.visitor-row > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.locale-line,
|
||||
.ip-line,
|
||||
.ua-line {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.locale-line {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.ip-line {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ua-line {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.locale-lang {
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.city-name {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.flag {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.flag :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -305,32 +305,52 @@ export function formatCounts(entries) {
|
||||
|
||||
/**
|
||||
* Count distinct User-Agent strings among crawler hits, most common first.
|
||||
* Returns an array of [ua, count] pairs.
|
||||
* Returns an array of [ua, count] pairs. ``clients`` maps client hashes to
|
||||
* client records.
|
||||
*/
|
||||
export function countCrawlerUas(crawlers) {
|
||||
export function countCrawlerUas(crawlers, clients) {
|
||||
const counts = {}
|
||||
for (const c of crawlers || []) {
|
||||
const value = c.ua_pretty || c.ua || '(no UA)'
|
||||
const client = (clients || {})[c.client] || {}
|
||||
const value = client.ua_pretty || client.ua || '(no UA)'
|
||||
counts[value] = (counts[value] || 0) + 1
|
||||
}
|
||||
return Object.entries(counts).sort((a, b) => b[1] - a[1])
|
||||
}
|
||||
|
||||
/**
|
||||
* Group raw crawler hits by the same (ip, ua) pair we use to tell a real
|
||||
* visitor from a crawler, and format each group as a row showing every
|
||||
* internal page that crawler visited. Rows are sorted by total hits,
|
||||
* most active crawler first, rather than by most recent hit.
|
||||
* Reduce a reverse-DNS hostname to its right-most components that fit
|
||||
* within ``limit`` characters. This keeps the meaningful main domain
|
||||
* while avoiding absurdly long subdomains like ``xxx.yyy.zzz...provider.net``.
|
||||
*/
|
||||
export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
|
||||
export function mainDomain(host, limit = 24) {
|
||||
if (!host) return host
|
||||
const labels = host.split('.').filter(Boolean)
|
||||
if (!labels.length) return host
|
||||
const parts = [labels.pop()]
|
||||
while (labels.length) {
|
||||
const next = labels[labels.length - 1]
|
||||
const candidate = `${next}.${parts.join('.')}`
|
||||
if (candidate.length > limit) break
|
||||
parts.unshift(labels.pop())
|
||||
}
|
||||
return parts.join('.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Group raw crawler hits by client hash and format each group as a row showing
|
||||
* every internal page that crawler visited. Rows are sorted by total hits,
|
||||
* most active crawler first, rather than by most recent hit.
|
||||
* ``clients`` maps client hashes to client records.
|
||||
*/
|
||||
export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now()) {
|
||||
const titles = buildTitleMap(pageTree)
|
||||
const groups = new Map()
|
||||
for (const c of crawlers || []) {
|
||||
const key = `${c.ip}\0${c.ua}`
|
||||
const g = groups.get(key) || {
|
||||
ip: c.ip || '',
|
||||
ua: c.ua_pretty || c.ua || '—',
|
||||
uaRaw: c.ua || '',
|
||||
const client = (clients || {})[c.client] || {}
|
||||
const g = groups.get(c.client) || {
|
||||
clientHash: c.client,
|
||||
client,
|
||||
lastStart: 0,
|
||||
pages: new Map(),
|
||||
}
|
||||
@@ -339,7 +359,7 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
|
||||
if (c.entry?.startsWith('/')) {
|
||||
g.pages.set(c.entry, (g.pages.get(c.entry) || 0) + 1)
|
||||
}
|
||||
groups.set(key, g)
|
||||
groups.set(c.client, g)
|
||||
}
|
||||
const totalHits = (g) => {
|
||||
let n = 0
|
||||
@@ -349,19 +369,29 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
|
||||
return [...groups.values()]
|
||||
.sort((a, b) => totalHits(b) - totalHits(a) || b.lastStart - a.lastStart)
|
||||
.slice(0, 10)
|
||||
.map((g) => ({
|
||||
lastSeen: formatWhen(g.lastStart, now),
|
||||
lastSeenIso: formatWhenIso(g.lastStart),
|
||||
lastSeenLocal: formatWhenLocal(g.lastStart),
|
||||
pages: [...g.pages.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([path, count]) => ({ ...stepOf(path, titles), count })),
|
||||
ip: g.ip,
|
||||
ipDisplay: hostIP(g.ip) || g.ip || '—',
|
||||
ua: g.ua,
|
||||
uaRaw: g.uaRaw,
|
||||
total: totalHits(g),
|
||||
}))
|
||||
.map((g) => {
|
||||
const client = g.client || {}
|
||||
const host = client.host || ''
|
||||
const isHost = !!host
|
||||
return {
|
||||
lastSeen: formatWhen(g.lastStart, now),
|
||||
lastSeenIso: formatWhenIso(g.lastStart),
|
||||
lastSeenLocal: formatWhenLocal(g.lastStart),
|
||||
pages: [...g.pages.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([path, count]) => ({ ...stepOf(path, titles), count })),
|
||||
ip: client.ip || '',
|
||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
||||
isHost,
|
||||
ua: client.ua_pretty || client.ua || '—',
|
||||
uaRaw: client.ua || '',
|
||||
lang: client.lang || '—',
|
||||
langDisplay: formatLang(client.lang),
|
||||
country: client.country || '—',
|
||||
city: client.city || '—',
|
||||
total: totalHits(g),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -374,12 +404,15 @@ export function formatCrawlerRows(crawlers, pageTree, now = Date.now()) {
|
||||
* 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.
|
||||
* ``clients`` maps client hashes to client records.
|
||||
*/
|
||||
export function formatAbuseRows(abuse, now = Date.now()) {
|
||||
export function formatAbuseRows(abuse, clients, now = Date.now()) {
|
||||
const groups = new Map()
|
||||
for (const a of abuse || []) {
|
||||
const g = groups.get(a.ip) || {
|
||||
ip: a.ip || '',
|
||||
const client = (clients || {})[a.client] || {}
|
||||
const ip = client.ip || ''
|
||||
const g = groups.get(ip) || {
|
||||
ip,
|
||||
pathCounts: new Map(),
|
||||
rawUas: [],
|
||||
uaCounts: new Map(),
|
||||
@@ -400,10 +433,10 @@ export function formatAbuseRows(abuse, now = Date.now()) {
|
||||
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)'
|
||||
const ua = client.ua || '(no UA)'
|
||||
g.rawUas.push(ua)
|
||||
g.uaCounts.set(ua, (g.uaCounts.get(ua) || 0) + 1)
|
||||
groups.set(a.ip, g)
|
||||
groups.set(ip, g)
|
||||
}
|
||||
const totalHits = (g) => {
|
||||
let n = 0
|
||||
@@ -447,34 +480,17 @@ export function formatAbuseRows(abuse, now = Date.now()) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a reverse-DNS hostname to its right-most components that fit
|
||||
* within ``limit`` characters. This keeps the meaningful main domain
|
||||
* while avoiding absurdly long subdomains like ``xxx.yyy.zzz...provider.net``.
|
||||
*/
|
||||
function mainDomain(host, limit = 24) {
|
||||
if (!host) return host
|
||||
const labels = host.split('.').filter(Boolean)
|
||||
if (!labels.length) return host
|
||||
const parts = [labels.pop()]
|
||||
while (labels.length) {
|
||||
const next = labels[labels.length - 1]
|
||||
const candidate = `${next}.${parts.join('.')}`
|
||||
if (candidate.length > limit) break
|
||||
parts.unshift(labels.pop())
|
||||
}
|
||||
return parts.join('.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format raw visit records as rows for a technical table. Returns objects
|
||||
* with display strings; missing values become "—". ``trail`` starts with the
|
||||
* external referer (when present), then the entry page and any further internal
|
||||
* pages or external exit origins. Only the 20 most recent visits are shown.
|
||||
* ``clients`` maps client hashes to client records.
|
||||
*/
|
||||
export function formatVisitRows(visits, pageTree, now = Date.now()) {
|
||||
export function formatVisitRows(visits, clients, pageTree, now = Date.now()) {
|
||||
const titles = buildTitleMap(pageTree)
|
||||
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
||||
const client = (clients || {})[v.client] || {}
|
||||
const trail = [v.entry, ...(v.trail || [])]
|
||||
.map((p) => stepOf(p, titles))
|
||||
.filter(Boolean)
|
||||
@@ -485,24 +501,24 @@ export function formatVisitRows(visits, pageTree, now = Date.now()) {
|
||||
.map(([k, value]) => `${k}=${value}`)
|
||||
.join(', ')
|
||||
const dash = (s) => (s || '—')
|
||||
const host = v.host || ''
|
||||
const host = client.host || ''
|
||||
const isHost = !!host
|
||||
return {
|
||||
lastSeen: formatWhen(v.start, now),
|
||||
lastSeenIso: formatWhenIso(v.start),
|
||||
lastSeenLocal: formatWhenLocal(v.start),
|
||||
langDisplay: formatLang(v.lang),
|
||||
langDisplay: formatLang(client.lang),
|
||||
trail,
|
||||
refererStep: stepOf(v.referer, titles),
|
||||
referer: dash(v.referer),
|
||||
ip: v.ip || '',
|
||||
ipDisplay: isHost ? mainDomain(host) : hostIP(v.ip) || v.ip || '—',
|
||||
ip: client.ip || '',
|
||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
||||
isHost,
|
||||
lang: dash(v.lang),
|
||||
country: dash(v.country),
|
||||
city: dash(v.city),
|
||||
ua: v.ua_pretty || v.ua || '—',
|
||||
uaRaw: v.ua || '',
|
||||
lang: dash(client.lang),
|
||||
country: dash(client.country),
|
||||
city: dash(client.city),
|
||||
ua: client.ua_pretty || client.ua || '—',
|
||||
uaRaw: client.ua || '',
|
||||
utm: utm || '—',
|
||||
utmTitle,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user