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: