import { FAVICON } from "../types"; import { THEME_HEAD, THEME_CSS, THEME_SCRIPT, CARD_VERSION } from "./theme"; import { escHtml as esc } from "../utils"; export interface SnapshotPoint { ts: number; // millisecond timestamp total: number; with_handles: number; with_avatars: number; } export interface StatsData { total: number; hiddenCount: number; rows: { hour: number; searches: number; total_ms: number; cache_hits: number; cache_ms: number }[]; totalSearches: number; avgLatency: number; avgCacheLatency: number; handlePct: string; avatarPct: string; snapshots: SnapshotPoint[]; trafficSources: { domain: string; hits: number }[]; /** the whole traffic_sources table, so the pie can draw what the ranked rows leave out */ trafficAll: { domains: number; hits: number }; trafficSeries: { hour: number; domain: string; hits: number }[]; trafficSeries5m: { bucket: number; domain: string; hits: number }[]; trafficDaily: { day: number; domain: string; hits: number }[]; requestSeries: { hour: number; domain: string; endpoint: string; status: number; hits: number }[]; requestSeries5m: { bucket: number; domain: string; endpoint: string; status: number; hits: number }[]; requestDaily: { day: number; domain: string; endpoint: string; status: number; hits: number }[]; /** view restored from the query string — drives the link preview */ view?: { apps: string[]; range: string; origin: string }; } /** cached/cold latency as two atomic pairs. * * This was one inline string — "6.2 ms cached · 173 ms * cold" — so at narrow widths the last tag wrapped by itself and * the card rendered a lone orphaned "cold" on its own line. Each number+tag is * now one nowrap unit, so they either sit on one line or wrap as whole pairs. */ function latencyHtml(cached: number, cold: number): string { const pair = (n: string, tag: string) => `${n}${tag}`; const parts = []; if (cached > 0) parts.push(pair(`${cached.toFixed(1)} ms`, "cached")); parts.push(pair(`${cold.toFixed(0)} ms`, "cold")); return parts.join('·'); } /** Link-preview metadata that describes THIS view, not the page in general. * * A shared comparison is only worth sharing if the unfurl says what is being * compared, so the title and description are built from the same numbers the * chart draws, and the card image is handed the same params. */ function ogTags(d: StatsData): string { const view = d.view ?? { apps: [], range: '7d', origin: 'https://typeahead.waow.tech' }; const RANGE_SECS = { '24h': 86400, '7d': 604800, '30d': 2592000, 'all': 2592000 } as const; const RANGE_WORDS = { '24h': 'last 24 hours', '7d': 'last 7 days', '30d': 'last 30 days', 'all': 'last 30 days' } as const; type Range = keyof typeof RANGE_SECS; const isRange = (r: string): r is Range => Object.hasOwn(RANGE_SECS, r); const range: Range = isRange(view.range) ? view.range : '7d'; const secs = RANGE_SECS[range]; const cutoff = Math.floor(Date.now() / 1000 / 3600) - secs / 3600; const by = new Map(); let all = 0; for (const r of d.trafficSeries) { if (r.hour < cutoff) continue; by.set(r.domain, (by.get(r.domain) ?? 0) + r.hits); all += r.hits; } const apps = view.apps.filter(a => by.has(a)); const q = new URLSearchParams(); if (apps.length) q.set('apps', apps.join(',')); if (view.range !== '7d') q.set('range', view.range); const qs = q.toString(); const pageUrl = view.origin + '/stats' + (qs ? '?' + qs : ''); q.set('v', String(CARD_VERSION)); const imgUrl = view.origin + '/og-image?' + q.toString(); let title: string, desc: string; if (apps.length) { const rows = apps.map(a => ({ a, hits: by.get(a) ?? 0 })).sort((x, y) => y.hits - x.hits); title = rows.map(r => r.a).join(' vs ') + ' · typeahead traffic'; const parts = rows.map(r => `${r.a} ${r.hits.toLocaleString('en-US')}` + (all > 0 ? ` (${((r.hits / all) * 100).toFixed(1)}%)` : '')); const lead = rows.length >= 2 && rows[1]!.hits > 0 ? ` — ${rows[0]!.a} ${(rows[0]!.hits / rows[1]!.hits < 10 ? (rows[0]!.hits / rows[1]!.hits).toFixed(2) : Math.round(rows[0]!.hits / rows[1]!.hits))}× ${rows[1]!.a} by request volume` : ''; desc = `search traffic over the ${RANGE_WORDS[range]}: ${parts.join(' · ')}${lead}`; } else { title = 'typeahead — stats'; desc = `${d.total.toLocaleString('en-US')} actors indexed, ${d.handlePct}% with a resolved handle. Live traffic, latency and coverage for community actor search on atproto.`; } const m = (p: string, c: string) => ``; const nm = (p: string, c: string) => ``; return [ nm('description', desc), m('og:type', 'website'), m('og:site_name', 'typeahead'), m('og:title', title), m('og:description', desc), m('og:url', pageUrl), m('og:image', imgUrl), m('og:image:width', '1200'), m('og:image:height', '630'), m('og:image:alt', title), nm('twitter:card', 'summary_large_image'), nm('twitter:title', title), nm('twitter:description', desc), nm('twitter:image', imgUrl), ``, `${esc(title)}`, ].join('\n '); } export function statsPage(d: StatsData): string { const sorted = [...d.rows].reverse(); // oldest first // Window by TIME, not bucket count: metric buckets are 5-min (hour*300 = unix // seconds) and SPARSE (only buckets with a search exist, ~64/day not 288), so // "last N buckets" ≠ "last N days". const nowSec = Math.floor(Date.now() / 1000); // SSR renders the 7d view so the page is correct without JS and the value // agrees with the active button on load. // Aggregate the metric buckets HERE, not in the browser. // // The client only ever reduced this array to four numbers per range — it is // never plotted. Shipping every 5-minute bucket to do that made the buckets // 72% of a 1 MB page (762 KB, ~19k rows) and put the page's weight on a curve // that grows with the site's age: the query is LIMIT 100000, so left alone it // heads for several MB. Sixteen numbers instead. const ACTIVITY_SECS = { '24h': 86400, '7d': 604800, '30d': 2592000, 'all': null } as const; const activity: Record = {}; for (const [range, secs] of Object.entries(ACTIVITY_SECS)) { const a = { searches: 0, cacheHits: 0, totalMs: 0, cacheMs: 0 }; for (const r of sorted) { // buckets are 5-min (hour * 300 = unix seconds) and SPARSE — only buckets // with a search exist — so windows are by TIME, never by bucket count if (secs !== null && r.hour * 300 < nowSec - secs) continue; a.searches += r.searches; a.cacheHits += r.cache_hits ?? 0; a.totalMs += r.total_ms ?? 0; a.cacheMs += r.cache_ms ?? 0; } activity[range] = a; } // Inlining JSON into a ", so a single "<" in attacker-controlled data closes // the element and everything after it is parsed as HTML. Domains reach this page // straight from the X-Client / Origin request headers, which made this a stored // XSS: one request with X-Client: `; }