/** * Formatters and aggregators for summary sections: totals and the recent * visit trail. */ /** * IPv4 unchanged, IPv6 returns the /64 network prefix in compact form. * Falls back to the original value when parsing fails. */ export const hostIP = (ip) => { try { if (!ip || !ip.includes(':')) return ip const strip = (s) => s.replace(/^\[|\]$/g, '') const norm = strip(new URL(`http://[${ip}]/`).hostname) const [l, r] = norm.split('::').map((s) => (s ? s.split(':') : [])) const full = r ? [...l, ...Array(8 - l.length - r.length).fill('0'), ...r] : l return strip( new URL(`http://[${full.slice(0, 4).join(':')}::]/`).hostname, ).replace(/::$/, '') } catch (e) { console.error('hostIP processing failed for:', ip, e) return ip } } function showCopiedFeedback(el) { if (!el || typeof document === 'undefined') return const popup = document.createElement('span') popup.textContent = 'Copied!' popup.className = 'copy-popup' popup.style.cssText = 'position:absolute;bottom:calc(100% + 0.25rem);left:50%;' + 'transform:translateX(-50%);padding:0.15rem 0.4rem;' + 'background:var(--text, CanvasText);color:var(--bg, Canvas);' + 'border-radius:0.25rem;font-size:0.75rem;white-space:nowrap;' + 'pointer-events:none;z-index:10;' el.classList.add('has-copy-popup') el.appendChild(popup) setTimeout(() => { popup.remove() el.classList.remove('has-copy-popup') }, 1200) } /** Copy the full IP to the clipboard and show a brief "Copied!" popup. */ export async function copyIp(ip, event) { if (!ip) return const el = event?.currentTarget try { await navigator.clipboard.writeText(ip) showCopiedFeedback(el) } catch { /* ignore */ } } /** Copy arbitrary text to the clipboard and show a brief "Copied!" popup. */ export async function copyList(text, event) { if (!text) return const el = event?.currentTarget try { await navigator.clipboard.writeText(text) showCopiedFeedback(el) } catch { /* ignore */ } } /** Total page views across every page and every bucket. */ export function calcTotalViews(views) { let n = 0 for (const buckets of Object.values(views || {})) { for (const c of Object.values(buckets)) n += c } return n } /** Build a path -> page title lookup from the site tree. */ function buildTitleMap(pageTree) { const titles = new Map() const walk = (items) => { for (const item of items || []) { titles.set(`/${item.path}`, item.title) walk(item.children) } } walk(pageTree) return titles } /** Last path segment for display; front page becomes a house icon. */ function slugOf(path) { return path === '/' ? '🏠' : path.split('/').pop() } /** Host name of an external https origin, with scheme stripped. */ function externalSlug(origin) { try { return new URL(origin).host } catch { return origin.replace(/^https?:\/\//, '') } } /** Format one trail step: an internal page or an external https origin. */ function stepOf(path, titles) { if (path?.startsWith('/')) { return { path, slug: slugOf(path), title: titles.get(path) || '', external: false } } if (path?.startsWith('https://')) { return { path, slug: externalSlug(path), title: 'External site', external: true, } } return null } /** * Human-readable relative timestamp. Adapted from cista-storage: uses * ``Intl.RelativeTimeFormat`` for short intervals and a compact date for * anything older than a week. */ export function formatWhen(ts, now = Date.now()) { const date = new Date(ts) const diff = date.getTime() - now const adiff = Math.abs(diff) const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) if (adiff <= 5000) return 'now' if (adiff <= 60000) { return formatter .format(Math.round(diff / 1000), 'second') .replace(' ago', '') .replaceAll(' ', '\u202F') } if (adiff <= 3600000) { return formatter .format(Math.round(diff / 60000), 'minute') .replace('utes', '') .replace('ute', '') .replaceAll(' ', '\u202F') } if (adiff <= 86400000) { return formatter .format(Math.round(diff / 3600000), 'hour') .replaceAll(' ', '\u202F') } if (adiff <= 604800000) { return formatter .format(Math.round(diff / 86400000), 'day') .replaceAll(' ', '\u202F') } let d = date .toLocaleDateString('en-ie', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', }) .replace('Sept', 'Sep') if (d.length === 14) d = d.replace(' ', ' \u2007') d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') d = d.slice(0, -4) + d.slice(-2) return d } /** Full UTC timestamp for tooltips, e.g. "2026-08-21 00:20:48 UTC". */ export function formatWhenTooltip(ts) { return new Date(ts).toISOString().replace('T', ' ').replace('Z', ' UTC') } /** Full local timestamp for tooltips, e.g. "21 Aug 2026, 17:38:48". */ export function formatWhenLocal(ts) { return new Date(ts).toLocaleString('en-ie', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', }) } /** Preserve locale case with the region/country subtag upper-cased. */ export function formatLang(value) { if (!value || value === '—') return value const parts = value.split('-') if (parts.length > 1) { parts[parts.length - 1] = parts[parts.length - 1].toUpperCase() } return parts.join('-') } /** ISO 8601 UTC timestamp without subseconds, e.g. "2026-08-21T00:20:48Z". */ export function formatWhenIso(ts) { return `${new Date(ts).toISOString().split('.')[0]}Z` } /** * Format recent visits for display, newest first. Each step is a linked slug * pointing to its article; external referers/origins are shown as their * domain name with the full origin as the link href. The link title shows the * article heading when known, or "External site" for origins. */ export function formatRecentVisits(visits, pageTree, limit = 50) { const titles = buildTitleMap(pageTree) return [...visits] .reverse() .map((v) => ({ when: new Date(v.start).toLocaleString(), steps: [v.referer, v.entry, ...(v.trail || [])] .map((p) => stepOf(p, titles)) .filter(Boolean), })) .filter((v) => v.steps.length) .slice(0, limit) } /** * Count distinct values of a visit field, sorted most-common first. * Returns an array of [value, count] pairs. */ export function countByField(visits, field) { const counts = {} for (const v of visits || []) { const value = v[field] if (!value) continue counts[value] = (counts[value] || 0) + 1 } return Object.entries(counts).sort((a, b) => b[1] - a[1]) } /** * Count UTM parameter occurrences across visits. Each distinct * ``parameter: value`` pair is counted separately. Returns [pair, count]. */ export function countUtmTags(visits) { const counts = {} for (const v of visits || []) { for (const [key, value] of Object.entries(v.utm || {})) { const label = `${key}: ${value}` counts[label] = (counts[label] || 0) + 1 } } return Object.entries(counts).sort((a, b) => b[1] - a[1]) } /** Format a list of [value, count] pairs for inline display. */ export function formatCounts(entries) { return entries.map(([value, count]) => `${value} (${count})`).join(', ') } /** * Count distinct User-Agent strings among crawler hits, most common first. * Returns an array of [ua, count] pairs. */ export function countCrawlerUas(crawlers) { const counts = {} for (const c of crawlers || []) { const value = c.ua_pretty || c.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. */ export function formatCrawlerRows(crawlers, 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 || '', lastStart: 0, pages: new Map(), } const start = new Date(c.start).getTime() if (start > g.lastStart) g.lastStart = start if (c.entry?.startsWith('/')) { g.pages.set(c.entry, (g.pages.get(c.entry) || 0) + 1) } groups.set(key, g) } const totalHits = (g) => { let n = 0 for (const c of g.pages.values()) n += c return n } 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]) => ({ path, slug: slugOf(path), title: titles.get(path) || '', count, })), ip: g.ip, ipDisplay: hostIP(g.ip) || g.ip || '—', ua: g.ua, uaRaw: g.uaRaw, total: totalHits(g), })) } /** * 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. */ 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: [], 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, flag: a.flag || false, is_404: a.is_404 || false, }) 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 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 uas = [...g.uaCounts.entries()] .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) return { 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'), uas: uas.map(([ua, count]) => ({ ua, count })), allUas: uas .map(([ua, count]) => (count > 1 ? `${count}× ${ua}` : ua)) .join('\n'), ip: g.ip, ipDisplay: hostIP(g.ip) || g.ip || '—', total: totalHits(g), } }) } /** * 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. */ export function formatVisitRows(visits, pageTree, now = Date.now()) { const titles = buildTitleMap(pageTree) return [...(visits || [])].reverse().slice(0, 20).map((v) => { const trail = [v.entry, ...(v.trail || [])] .map((p) => stepOf(p, titles)) .filter(Boolean) const utm = Object.entries(v.utm || {}) .map(([k, value]) => `${k}=${value}`) .join(', ') const dash = (s) => (s || '—') const host = v.host || '' const isHost = !!host return { lastSeen: formatWhen(v.start, now), lastSeenIso: formatWhenIso(v.start), lastSeenLocal: formatWhenLocal(v.start), langDisplay: formatLang(v.lang), trail, refererStep: stepOf(v.referer, titles), referer: dash(v.referer), ip: v.ip || '', ipDisplay: isHost ? mainDomain(host) : hostIP(v.ip) || v.ip || '—', isHost, lang: dash(v.lang), country: dash(v.country), city: dash(v.city), ua: v.ua_pretty || v.ua || '—', uaRaw: v.ua || '', utm: utm || '—', } }) }