diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index 77bfa92..35da069 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -23,6 +23,7 @@ import { formatVisitRows, } from './analytics/format.js' import TrailLink from './TrailLink.vue' +import RefererBadge from './RefererBadge.vue' import VisitorCell from './VisitorCell.vue' import TransitionGraph from './TransitionGraph.vue' import VisitorCharts from './VisitorCharts.vue' @@ -215,8 +216,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c - - {{ v.utm }} + @@ -253,7 +253,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c - + @@ -480,16 +480,11 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c color: var(--error, #c00); } -.visit-table .utm-tag { - display: inline-block; +/* The referer badge outgrows the 8rem trail-link cap (it carries the UTM + summary too); keep the inline-flex layout from the component. */ +.visit-table .trail a.referer-badge { + display: inline-flex; max-width: 100%; - padding: 0.05rem 0.4rem; - border: 1px solid var(--line); - border-radius: 0.25rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - vertical-align: bottom; } /* Same flag chip as the visitor cells (VisitorCell.vue); the flags here diff --git a/frontend/src/RefererBadge.vue b/frontend/src/RefererBadge.vue new file mode 100644 index 0000000..4221f0d --- /dev/null +++ b/frontend/src/RefererBadge.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/frontend/src/analytics/format.js b/frontend/src/analytics/format.js index 2731e73..2a7050a 100644 --- a/frontend/src/analytics/format.js +++ b/frontend/src/analytics/format.js @@ -177,6 +177,32 @@ function stepOf(path, titles) { return null } +/** + * Badge data combining a visit's/crawler's external referer origin with the + * visit's UTM tags: the origin as the badge link/label (the favicon is + * looked up by origin in the component), the known UTM values as a short + * inline summary, and a one-fact-per-line tooltip — the full origin URL on + * the first line, then every ``utm_*=value`` pair. Null when there is no + * external referer and no UTM tag (a plain direct visit). + */ +function refererBadgeOf(referer, titles, utmTags = {}) { + const step = stepOf(referer, titles) + const external = step?.external ? step : null + const known = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'] + const utm = known.map((k) => utmTags[k]).filter(Boolean).join(' · ') + if (!external && !utm) return null + return { + href: external?.origin || '', + label: external?.slug || '', + origin: external?.origin || '', + utm, + title: [ + ...(external ? [external.origin] : []), + ...Object.entries(utmTags).map(([k, value]) => `${k}=${value}`), + ].join('\n'), + } +} + /** * Human-readable relative timestamp. Adapted from cista-storage: uses * ``Intl.RelativeTimeFormat`` for short intervals and a compact date for @@ -369,9 +395,10 @@ export function mainDomain(host, limit = 24) { /** * 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 most recent hit - * first, with total hits as a tie-breaker. The group's ``refererStep`` is the - * latest external referer seen for the crawler — spiders often advertise - * their own site there — rendered with its favicon like visit referers. + * first, with total hits as a tie-breaker. The group's ``refererBadge`` is + * the latest external referer seen for the crawler — spiders often advertise + * their own site there — rendered as a badge with its favicon like visit + * referers. * ``clients`` maps client hashes to client records. */ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now(), site = { multilingual: false, primaryLang: '' }) { @@ -420,7 +447,7 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now(), lastSeen: formatWhen(g.lastStart, now), lastSeenIso: formatWhenIso(g.lastStart), lastSeenLocal: formatWhenLocal(g.lastStart), - refererStep: stepOf(g.referer, titles), + refererBadge: refererBadgeOf(g.referer, titles), pages: [...g.pages.entries()] .sort((a, b) => b[1].count - a[1].count) .map(([path, info]) => ({ ...stepOf(path, titles), count: info.count, status: info.status })), @@ -559,9 +586,10 @@ export function formatAbuseRows(abuse, clients, pageTree, now = Date.now()) { /** * 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; consecutive views of the same page (e.g. a + * with display strings; missing values become "—". The external referer + * (when present) and the UTM tags ride along as ``refererBadge``; ``trail`` + * holds the entry page and any further internal pages or external exit + * origins; consecutive views of the same page (e.g. a * language switch re-view) merge into one step that keeps the * consecutive-distinct rendered languages, summed read time, and the latest * status. On multilingual sites the rendered languages surface as flag @@ -599,12 +627,6 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit } } const distinctLangs = new Set(trail.flatMap((s) => s.langs)) - const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'] - const utmValues = utmKeys.map((k) => (v.utm || {})[k]).filter(Boolean) - const utm = utmValues.length ? utmValues.join(' · ') : '' - const utmTitle = Object.entries(v.utm || {}) - .map(([k, value]) => `${k}=${value}`) - .join(', ') const dash = (s) => (s || '—') const host = client.host || '' const isHost = !!host @@ -614,8 +636,7 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit lastSeenLocal: formatWhenLocal(v.start), langDisplay: formatLang(client.lang), trail, - refererStep: stepOf(v.referer, titles), - referer: dash(v.referer), + refererBadge: refererBadgeOf(v.referer, titles, v.utm), ip: client.ip || '', ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—', isHost, @@ -625,8 +646,6 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit ua: client.uarite?.pretty || client.ua || '—', uaRaw: client.ua || '', uaUrl: client.uarite?.url || '', - utm: utm || '—', - utmTitle, } if (site.multilingual && distinctLangs.size) { if (distinctLangs.size === 1) { diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index 97c64dd..4b4f4e1 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -26,6 +26,14 @@ /* Selection fill for page text and the CodeMirror editors; themes override when the accent tint clashes with accent-colored text. */ --selection-bg: color-mix(var(--accent) 30%, transparent); + /* Referer-badge chip in the analytics viewer: a fixed neutral palette, + deliberately NOT themed — the chip sits behind transparent favicons, so + black-on-transparent and white-on-transparent glyphs must both stay + legible on every theme (dark greys kill black glyphs, pure white kills + white ones); its text likewise stays dark on any theme. */ + --badge-bg: #c9d1d9; + --badge-text: #1f2328; + --badge-muted: #59636e; /* Code highlighting palette, consumed by pygments.css: complete light and dark sets (background included), resolved by light-dark() from the used color-scheme. A theme picks a set simply by declaring