Something went wrong. Try again.
GET /xrpc/tech.waow.typeahead.searchActors typeahead.waow.tech
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791import { 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 <span>cached</span> · 173 ms * <span>cold</span>" — 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) => `<span class="lat-pair"><span class="lat-n">${n}</span><span class="lat-t">${tag}</span></span>`; const parts = []; if (cached > 0) parts.push(pair(`${cached.toFixed(1)} ms`, "cached")); parts.push(pair(`${cold.toFixed(0)} ms`, "cold")); return parts.join('<span class="lat-sep">·</span>');}
/** 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<string, number>(); 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) => `<meta property="${p}" content="${esc(c)}">`; const nm = (p: string, c: string) => `<meta name="${p}" content="${esc(c)}">`; 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), `<link rel="canonical" href="${esc(pageUrl)}">`, `<title>${esc(title)}</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<string, { searches: number; cacheHits: number; totalMs: number; cacheMs: number }> = {}; 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 <script> is not the same as serialising it. JSON.stringify // does NOT escape "</script>", 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: </script><script>... and the payload ran for // every visitor to /stats. Escaping "<" is enough and keeps the value valid JSON. const jsonScript = <T extends object>(v: T) => JSON.stringify(v).replace(/</g, "\\u003c");
const activityJson = jsonScript(activity); // SSR renders the 7d view so the page is correct without JS and the value // agrees with the active button on load. const initialTotal = activity['7d']!.searches + activity['7d']!.cacheHits;
// The trend chart plots these, so unlike the metrics they cannot collapse to // a scalar — but 5000 hourly points render into ~620px, so most were being // thrown away at draw time anyway. Keep every hour for the last 30 days (the // two windowed views), then every 24th sample beyond it for "all". Real // samples, never averages, so a hover still reports a value that existed. const HOURLY_KEEP_MS = 30 * 86400_000; const cutoff = Date.now() - HOURLY_KEEP_MS; const lastIdx = d.snapshots.length - 1; const thinned = d.snapshots.filter((s, i) => s.ts >= cutoff || i % 24 === 0 || i === lastIdx); const snapshotJson = jsonScript(thinned);
const PIE_COLORS = [ // Ordered so adjacent slices never sit next to a neighbour on the wheel — // the old set had two violets and two ambers that read as the same colour on // a 90px line. Mid-tone rather than pastel or deep, since the page renders // on both a light and a dark background. '#14b8a6', '#3b82f6', '#a855f7', '#f59e0b', '#f43f5e', '#06b6d4', '#65a30d', '#f97316', '#ec4899', ]; const SPECIAL_DOMAINS = { unknown: '#94a3b8', homepage: '#64748b' } satisfies Record<string, string>; const isSpecial = (d: string): d is keyof typeof SPECIAL_DOMAINS => Object.hasOwn(SPECIAL_DOMAINS, d); const PIE_TOP_N = 12; // Every percentage on the page divides by the WHOLE table, and the ring // draws the whole table too: the top N as named slices plus one neutral // "everything else" slice for the rest. Before 2026-09-04 the ring held // only the top 12 and the legend divided by the 50 rows fetched, so the // largest slice read as 50.7% of the ring, was labelled 44.5%, and was // 43.6% of all traffic. The rest slice is selectable like any source: its // history is the sum of every source outside the named twelve, computed // on the client from the same per-domain series (see seriesFor). const trafficListed = d.trafficSources.reduce((s, r) => s + r.hits, 0); const trafficGrandTotal = Math.max(d.trafficAll.hits, trafficListed); const pieSlice = d.trafficSources.slice(0, PIE_TOP_N); const pieShown = pieSlice.reduce((s, r) => s + r.hits, 0); const restHits = trafficGrandTotal - pieShown; const restDomains = Math.max(d.trafficAll.domains, d.trafficSources.length) - pieSlice.length; const rest = restHits > 0 && restDomains > 0 ? { hits: restHits, domains: restDomains } : null; const REST_LABEL = 'everything else'; const trafficJson = jsonScript(rest ? [...pieSlice, { domain: REST_LABEL, hits: rest.hits, rest: true }] : pieSlice); // per-domain hourly series for every recorded domain (bounded: ~50 domains × // ≤720 hourly rows), compact form: domain -> [[hour, hits], ...] const seriesByDomain: Record<string, [number, number][]> = {}; for (const r of d.trafficSeries) { (seriesByDomain[r.domain] ??= []).push([r.hour, r.hits]); } const trafficSeriesJson = jsonScript(seriesByDomain); // raw 5-min buckets (last 48h) for the 24h view const seriesByDomain5m: Record<string, [number, number][]> = {}; for (const r of d.trafficSeries5m) { (seriesByDomain5m[r.domain] ??= []).push([r.bucket, r.hits]); } const trafficSeries5mJson = jsonScript(seriesByDomain5m); // daily grain, full history — this is why "all" has no cap: resolution // decays with age (5-min → hourly → daily) but no app's history is dropped const seriesByDomainDaily: Record<string, [number, number][]> = {}; for (const r of d.trafficDaily) { (seriesByDomainDaily[r.domain] ??= []).push([r.day, r.hits]); } const trafficSeriesDailyJson = jsonScript(seriesByDomainDaily);
// request_buckets, summed down to the two other ways of slicing the same // volume: by endpoint (all apps, all statuses) and by status class (all // apps, all endpoints). Same compact shape as the traffic maps, one per // grain, so the chart reads them through the same code path. const statusClass = (code: number) => `${Math.floor(code / 100)}xx`; type Grain = Record<string, [number, number][]>; type RequestGrains = { endpoint: Grain; status: Grain }; const sliceRequests = <R extends { domain: string; endpoint: string; status: number; hits: number }>( rows: R[], t: (r: R) => number, ): RequestGrains => { const out: RequestGrains = { endpoint: {}, status: {} }; const acc = { endpoint: new Map<string, Map<number, number>>(), status: new Map<string, Map<number, number>>() }; for (const r of rows) { for (const [dim, key] of [["endpoint", r.endpoint], ["status", statusClass(r.status)]] as const) { const m = acc[dim].get(key) ?? new Map<number, number>(); m.set(t(r), (m.get(t(r)) ?? 0) + r.hits); acc[dim].set(key, m); } } for (const dim of ["endpoint", "status"] as const) { for (const [key, m] of acc[dim]) out[dim][key] = [...m.entries()].sort((a, b) => a[0] - b[0]); } return out; }; const requestSeriesJson = jsonScript({ hourly: sliceRequests(d.requestSeries, (r) => r.hour), m5: sliceRequests(d.requestSeries5m, (r) => r.bucket), daily: sliceRequests(d.requestDaily, (r) => r.day), });
// It used to be that NO name was a link, because the selection handler skips // anchors so the link could still navigate — which meant the largest part of // every legend entry and table row did not select. The split that survives: // in the TABLE the name is a link again (the row still selects via the rank, // hits, and % cells — the selection handler skips anchors); in the LEGEND // the whole entry stays a selection target, since it has no other surface. // Only dotted hostnames link: X-Client values can be bare app names // ("pckt", "lazurite") and https://pckt is not a place. const nameLink = (domain: string) => isSpecial(domain) || !domain.includes('.') ? esc(domain) : `<a href="https://${esc(domain)}" target="_blank" rel="noopener" class="legend-link">${esc(domain)}</a>`;
const pieLegendHtml = pieSlice.map((r, i) => { const color = isSpecial(r.domain) ? SPECIAL_DOMAINS[r.domain] : PIE_COLORS[i % PIE_COLORS.length]; const pct = trafficGrandTotal > 0 ? ((r.hits / trafficGrandTotal) * 100).toFixed(1) : '0'; return `<span data-domain="${esc(r.domain)}"><span class="ldot" style="background:${color}"></span>${esc(r.domain)} (${pct}%)</span>`; }).join('') + (rest ? `<span class="rest" data-domain="${REST_LABEL}"><span class="ldot rest-dot"></span>${REST_LABEL} · ${rest.domains} sources (${((rest.hits / trafficGrandTotal) * 100).toFixed(1)}%)</span>` : '');
// Every source is in the table, but only the first page is visible at // load: the rest carry a page number and `hidden`, and the button below // the table reveals a page at a time. Selecting a folded source from the // URL (?apps=) unfolds down to its row (see the client script). const TS_PAGE = 50; const trafficTableRows = d.trafficSources.map((r, i) => { const pct = trafficGrandTotal > 0 ? ((r.hits / trafficGrandTotal) * 100).toFixed(1) : '0'; const page = Math.floor(i / TS_PAGE); const fold = page > 0 ? ` data-page="${page}" hidden` : ''; return `<tr data-domain="${esc(r.domain)}"${fold}><td class="ts-rank">${i + 1}</td><td class="ts-domain">${nameLink(r.domain)}</td><td class="ts-hits">${r.hits.toLocaleString()}</td><td class="ts-pct">${pct}%</td></tr>`; }).join(''); const trafficFolded = Math.max(0, d.trafficSources.length - TS_PAGE); const trafficMoreHtml = trafficFolded > 0 ? `<button type="button" class="ts-more" id="ts-more" data-page-size="${TS_PAGE}">show ${Math.min(TS_PAGE, trafficFolded)} more · ${trafficFolded} remaining</button>` : '';
return `<!doctype html><html lang="en"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> ${ogTags(d)} ${FAVICON} ${THEME_HEAD} <style>${THEME_CSS} body { display: flex; justify-content: center; padding: calc(1.5rem + env(safe-area-inset-top)) 1rem calc(2rem + env(safe-area-inset-bottom)); } .container { max-width: 620px; width: 100%; } .header { display: flex; align-items: baseline; justify-content: space-between; gap: 0.4rem; margin-bottom: 0.4rem; } h1 { font-size: 1.1rem; font-weight: 400; color: var(--muted); } .subtitle { font-size: 0.8rem; color: var(--dim); margin-bottom: 1.5rem; }
.chart-wrap, .activity-wrap, .pie-wrap { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 1rem; margin-bottom: 1.25rem; position: relative; } .chart-wrap h2, .activity-wrap h2, .pie-wrap h2 { font-size: 0.75rem; font-weight: 400; color: var(--muted); margin-bottom: 0.6rem; } .ranged-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.6rem; } .ranged-header h2 { margin-bottom: 0; } .range-btns { display: inline-flex; gap: 0.2rem; } .range-btns button { background: transparent; border: 1px solid var(--line); color: var(--dim); padding: 0.35rem 0.65rem; font-size: 0.72rem; cursor: pointer; border-radius: 6px; font-family: inherit; min-height: 34px; } .range-btns button:hover { color: var(--muted); border-color: var(--line-2); } .range-btns button.active { background: var(--panel-2); color: var(--text); border-color: var(--line-2); } #trend { width: 100%; height: 200px; display: block; touch-action: none; } .legend { display: flex; gap: 1.2rem; margin-top: 0.6rem; font-size: 0.7rem; color: var(--muted); } .legend span { display: flex; align-items: center; gap: 0.35rem; } .ldot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } .chart-tip, .pie-tip, .series-tip { display: none; position: fixed; background: var(--tip-bg); border: 1px solid var(--line-2); border-radius: 8px; padding: 0.5rem 0.7rem; font-size: 0.72rem; color: var(--text); pointer-events: none; z-index: 50; line-height: 1.6; white-space: nowrap; box-shadow: 0 4px 12px rgba(0,0,0,0.35); } .ct-time { color: var(--dim); font-size: 0.65rem; } .ct-row { display: flex; align-items: center; gap: 0.35rem; } .ct-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
svg { width: 100%; height: auto; } polyline { fill: none; stroke: var(--accent); stroke-width: 1.5; }
.summary { display: grid; gap: 0.8rem; margin-bottom: 1.25rem; } /* metrics nested in the activity card: the card already provides the border and padding, so don't draw a second frame inside it. */ .activity-wrap .summary { margin-bottom: 0; } .activity-wrap .metric { border: none; padding: 0; background: none; } .summary-2 { grid-template-columns: repeat(2, 1fr); } .summary-3 { grid-template-columns: repeat(3, 1fr); } .metric { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 0.8rem 0.9rem; } .metric .metric-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.4rem 0.5rem; margin-bottom: 0.2rem; } .metric .metric-head .label { margin-bottom: 0; } .metric .label { font-size: 0.7rem; color: var(--muted); margin-bottom: 0.2rem; } /* latency renders as atomic number+tag pairs so a tag can never orphan onto its own line the way "cold" did. */ .value.latency { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.4rem; font-size: 1.3rem; } .lat-pair { display: inline-flex; align-items: baseline; gap: 0.28rem; white-space: nowrap; } .lat-t { opacity: 0.4; font-size: 0.85rem; } .lat-sep { opacity: 0.3; } .metric .label[data-tip] { cursor: default; position: relative; border-bottom: 1px dotted var(--faint); display: inline-block; } .metric .label[data-tip]:hover::after, .metric .label[data-tip]:focus::after { content: attr(data-tip); position: absolute; left: 0; top: 1.6em; width: 200px; padding: 0.4rem 0.5rem; background: var(--panel-2); border: 1px solid var(--line-2); border-radius: 6px; font-size: 0.95em; color: var(--muted); line-height: 1.4; z-index: 20; white-space: normal; } .metric .value { font-size: 1.15rem; color: var(--text); }
/* cursor:pointer is not decoration here — iOS Safari only synthesises a click on a non-interactive element that looks clickable. The legend and the table rows both carry it, which is exactly why they worked on a phone and the donut did not. */ #pie { display: block; margin: 0 auto; width: 280px; height: 280px; touch-action: none; cursor: pointer; } .pie-legend { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; margin-top: 0.7rem; font-size: 0.7rem; color: var(--muted); justify-content: center; } .pie-legend span[data-domain] { display: flex; align-items: center; gap: 0.3rem; line-height: 1.4; padding: 0.35rem 0.5rem; border-radius: 999px; min-height: 30px; border: 1px solid transparent; } .pie-legend span.sel { border-color: var(--line-2); background: var(--panel-2); } .traffic-series { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--line); } .traffic-series h2 { font-size: 0.72rem; font-weight: 400; color: var(--muted); margin-bottom: 0; } .traffic-series .ranged-header { margin-bottom: 0.5rem; } /* The plot RESERVES its height so swapping the chart for the empty state cannot move the page. Previously the empty state was content-sized while the svg was responsive (viewBox 600x90, height:auto), so hovering a domain with no traffic in the window made everything below lurch up. */ .tseries-plot { position: relative; aspect-ratio: 600 / 135; } .tseries-plot > svg, .tseries-plot > #tseries-empty { position: absolute; inset: 0; width: 100%; height: 100%; } #tseries-empty { display: flex; align-items: center; justify-content: center; color: var(--faint); font-size: 0.75rem; text-align: center; } #tseries { cursor: crosshair; touch-action: pan-y; } #tseries polyline { vector-effect: non-scaling-stroke; fill: none; stroke-width: 1.5; } /* the fat transparent twin under each line is the click/tap target — a 1.5px stroke is unhittable, especially with a finger */ #tseries polyline.hit { stroke-width: 12; stroke: transparent; cursor: pointer; } #tseries polyline.baseline { stroke-dasharray: 3 3; stroke-width: 1; opacity: 0.75; } #tseries-guide { stroke: var(--line-2); stroke-width: 1; vector-effect: non-scaling-stroke; } #tseries-dots circle { fill: var(--bg); stroke-width: 1.5; vector-effect: non-scaling-stroke; } /* Reserves one chip row at all times. Without it, hovering the donut (which shows a preview chip) or making a first selection grew the header by a row and shoved the chart down — the chart appeared to jump while the pointer was nowhere near it. */ /* flex-basis 0, so the row never gets pushed under the heading by its own content — that wrap was the chart "jumping" when a chip appeared. It still reserves one row height at all times, which covers the donut's hover preview and any single selection. */ /* always its own full-width row under the title, in EVERY view — the row used to sit beside the title in one view and below it in the others, so switching views threw the view switch from one side of the card to the other. Same place, same shape, whatever is selected. */ #tseries-chips { display: flex; flex-wrap: wrap; gap: 0.5rem 0.45rem; align-items: center; min-height: 34px; flex-basis: 100%; min-width: 0; justify-content: flex-start; margin: 0.15rem 0 0.35rem; } .ts-chip { display: inline-flex; align-items: center; gap: 0.4rem; background: var(--panel-2); border: 1px solid var(--line-2); color: var(--text); padding: 0.35rem 0.5rem 0.35rem 0.6rem; font-size: 0.72rem; cursor: pointer; border-radius: 999px; font-family: inherit; min-height: 34px; } .ts-chip .x { color: var(--faint); font-size: 0.95rem; line-height: 1; } .ts-chip:hover { border-color: var(--line); } .ts-chip:hover .x { color: var(--text); } .ts-chip .ldot.dash { background: none !important; border: 1.5px dashed var(--dim); } .ts-chip.clear { color: var(--muted); background: transparent; padding: 0.35rem 0.7rem; } .ts-chip.preview { background: transparent; border-style: dashed; color: var(--muted); cursor: default; pointer-events: none; } /* an available-but-unselected line. Same chip, quieter: the endpoint and status views have no legend below to tap, so the chip row IS the picker */ .ts-chip.pick { background: transparent; border-color: var(--line); color: var(--muted); } .ts-chip.pick:hover { color: var(--text); border-color: var(--line-2); } .ts-chip.pick .ldot { opacity: 0.55; } .dim-btns button { padding: 0.25rem 0.5rem; font-size: 0.66rem; min-height: 28px; } #tseries-dim { margin-left: auto; } .empty-card { color: var(--dim); font-size: 0.8rem; padding: 2rem 0; text-align: center; } .pie-cta { margin-top: 0.6rem; font-size: 0.7rem; color: var(--dim); text-align: center; } .pie-cta a { color: var(--blue); text-decoration: none; } .tseries-axis { display: flex; justify-content: space-between; font-size: 0.65rem; color: var(--dim); margin-top: 0.25rem; font-variant-numeric: tabular-nums; } .tseries-foot { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-top: 0.5rem; } .tseries-hint { font-size: 0.65rem; color: var(--faint); } .ts-foot-btns { display: flex; align-items: center; gap: 0.3rem; flex-shrink: 0; } .ts-menu-wrap { position: relative; flex-shrink: 0; } #tseries-menu-btn { background: transparent; border: 1px solid var(--line); color: var(--dim); padding: 0.2rem 0.5rem 0.35rem; font-size: 0.8rem; line-height: 1; cursor: pointer; border-radius: 6px; font-family: inherit; min-height: 34px; min-width: 34px; } #tseries-menu-btn:hover, #tseries-menu-btn[aria-expanded="true"] { color: var(--text); border-color: var(--line-2); } .ts-menu { position: absolute; right: 0; bottom: calc(100% + 0.3rem); z-index: 40; background: var(--tip-bg); border: 1px solid var(--line-2); border-radius: 8px; padding: 0.25rem; display: flex; flex-direction: column; min-width: 140px; box-shadow: 0 4px 12px rgba(0,0,0,0.35); } .ts-menu[hidden] { display: none; } .ts-menu button { background: transparent; border: 0; color: var(--muted); text-align: left; padding: 0.5rem 0.6rem; font-size: 0.7rem; font-family: inherit; cursor: pointer; border-radius: 5px; white-space: nowrap; } .ts-menu button:hover { background: var(--hover); color: var(--text); } /* the integral row — area under each curve over the plotted window */ /* One line, always. It used to wrap — so a long domain, or a second selection, silently changed the height of everything below the chart. Overflow scrolls sideways instead of growing downward. */ .tseries-totals { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.15rem 0.6rem; font-size: 0.68rem; color: var(--muted); margin-top: 0.5rem; min-height: 1.5em; } .tseries-totals .sig { color: var(--dim); font-size: 0.85rem; cursor: default; position: relative; border-bottom: 1px dotted var(--faint); } .tseries-totals .sig:hover::after, .tseries-totals .sig:focus::after { content: attr(data-tip); position: absolute; left: 0; top: 1.8em; width: 190px; background: var(--tip-bg); border: 1px solid var(--line-2); border-radius: 6px; padding: 0.45rem 0.6rem; font-size: 0.65rem; color: var(--muted); z-index: 30; line-height: 1.5; } .tseries-totals .tot { display: inline-flex; align-items: baseline; gap: 0.3rem; } .tseries-totals .tot b { font-weight: 400; color: var(--text); font-variant-numeric: tabular-nums; } .tseries-totals .tot .pct { color: var(--faint); } .tseries-totals .lead { color: var(--text); } .pie-legend span[data-domain] { cursor: pointer; } .pie-legend span.rest { display: flex; align-items: center; gap: 0.3rem; line-height: 1.4; color: var(--muted); } .pie-legend .rest-dot { background: var(--dim); } .pie-legend span.sel { color: var(--bright); } .pie-legend span.sel .ldot { outline: 1.5px solid var(--bright); outline-offset: 1px; } .ts-table tbody tr[data-domain] { cursor: pointer; } .ts-table tbody tr[data-domain]:hover td { background: var(--hover); } .ts-table tbody tr.sel td { background: color-mix(in srgb, var(--accent) 12%, var(--panel)); }
.ts-table-wrap { margin-top: 1rem; max-height: 280px; overflow-y: auto; -webkit-overflow-scrolling: touch; border: 1px solid var(--line); border-radius: 8px; } .ts-table-wrap::-webkit-scrollbar { width: 8px; } .ts-table-wrap::-webkit-scrollbar-track { background: var(--bg); } .ts-table-wrap::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 4px; } .ts-table { width: 100%; border-collapse: collapse; font-size: 0.72rem; color: var(--text); table-layout: fixed; } .ts-table thead th { position: sticky; top: 0; background: var(--panel-2); color: var(--dim); font-weight: 400; text-align: left; padding: 0.45rem 0.7rem; border-bottom: 1px solid var(--line); font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; } .ts-table td { padding: 0.5rem 0.7rem; border-bottom: 1px solid var(--line); } .ts-table tr:last-child td { border-bottom: none; } .ts-rank { color: var(--dim); width: 2.2rem; } .ts-domain { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .legend-link { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--faint); } .legend-link:hover { color: var(--text); border-bottom-color: var(--muted); } .ts-hits { text-align: right; font-variant-numeric: tabular-nums; color: var(--text); width: 4.5rem; } .ts-pct { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); width: 3.2rem; } .ts-summary { font-size: 0.7rem; color: var(--dim); margin-top: 0.6rem; text-align: center; } .ts-more { display: block; width: 100%; margin-top: 0.5rem; min-height: 34px; background: transparent; border: 1px solid var(--line); border-radius: 6px; color: var(--dim); font: inherit; font-size: 0.7rem; cursor: pointer; } .ts-more:hover { color: var(--muted); border-color: var(--line-2); }
footer { padding-top: 1.5rem; border-top: 1px solid var(--line); font-size: 0.7rem; color: var(--faint); display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 0.4rem 0.6rem; } footer a { color: var(--dim); text-decoration: none; padding: 0.35rem 0.15rem; } footer a:hover { color: var(--muted); }
@media (max-width: 640px) { /* narrow: the heading alone nearly fills the line, so the chips get their own row rather than fighting it for space */ body { padding-left: 0.75rem; padding-right: 0.75rem; } #trend { height: 170px; } .legend { gap: 0.8rem; font-size: 0.65rem; flex-wrap: wrap; } .summary-2, .summary-3 { grid-template-columns: 1fr 1fr; } #pie { width: 230px; height: 230px; } .pie-legend { gap: 0.3rem 0.6rem; font-size: 0.68rem; } .tseries-plot { aspect-ratio: auto; height: 72px; } .ts-table-wrap { max-height: 320px; } .ts-table { font-size: 0.7rem; } .ts-table thead th { padding: 0.4rem 0.5rem; font-size: 0.62rem; } .ts-table td { padding: 0.5rem 0.5rem; } .ts-rank { width: 1.8rem; } .ts-hits { width: 3.6rem; } .ts-pct { width: 2.8rem; } } </style></head><body> <div class="container"> <div class="header"> <h1><a href="/" class="wordmark">typeahead</a> stats</h1> </div> <p class="subtitle">index health and search activity</p>
<div class="chart-wrap"> <div class="ranged-header"> <h2>actors indexed</h2> <span class="range-btns" id="trend-range"> <button data-range="7d">7d</button> <button data-range="30d">30d</button> <button data-range="all" class="active">all</button> </span> </div> ${d.snapshots.length > 1 ? `<canvas id="trend" role="img" aria-label="actors indexed over time"></canvas>` : `<div class="empty-card">collecting data — check back soon</div>`} <div class="legend"> ${[ { color: 'var(--accent)', label: `total (${d.total.toLocaleString()})`, value: d.total }, { color: 'var(--violet)', label: 'with avatars', value: d.snapshots[d.snapshots.length - 1]?.with_avatars ?? 0 }, { color: 'var(--blue)', label: 'with handles', value: d.snapshots[d.snapshots.length - 1]?.with_handles ?? 0 }, ].sort((a, b) => b.value - a.value) .map(r => `<span><span class="ldot" style="background:${r.color}"></span> ${r.label}</span>`) .join('\n ')} </div> </div> <div class="chart-tip" id="chart-tip"></div>
<div class="summary summary-3"> <div class="metric"> <div class="label" tabindex="0" data-tip="% of indexed actors with a resolved handle (vs bare DID only)">handle coverage</div> <div class="value">${d.handlePct}%</div> </div> <div class="metric"> <div class="label" tabindex="0" data-tip="% of indexed actors with a profile image">avatar coverage</div> <div class="value">${d.avatarPct}%</div> </div> <div class="metric"> <div class="label" tabindex="0" data-tip="indexed actors carrying a bluesky !hide, !takedown, !suspend or spam label as last observed by the refresh cron. PDS-level suspensions are deleted from the index, not hidden, so this is not an enforcement count">hidden by moderation</div> <div class="value">${d.hiddenCount.toLocaleString()}</div> </div> </div>
<!-- ONE window control for the whole activity section. There used to be three independent ones (searches sparkline, latency, traffic series) plus a duplicate line chart: "searches / 5 min" and "traffic over time" both plotted volume over time, and the donut's total was computed over a different window than the searches count sitting next to it. --> <div class="activity-wrap"> <div class="ranged-header"> <h2>activity</h2> <span class="range-btns" id="activity-range"> <button data-range="24h">24h</button> <button data-range="7d" class="active">7d</button> <button data-range="30d">30d</button> <button data-range="all">all</button> </span> </div> <div class="summary summary-2"> <div class="metric"> <div class="label" id="searches-label">total searches (7 days)</div> <div class="value" id="searches-value">${initialTotal.toLocaleString()}</div> </div> <div class="metric"> <div class="label" tabindex="0" data-tip="cached (edge) vs uncached (fly replica)">avg latency</div> <div class="value latency" id="latency-value">${latencyHtml(d.avgCacheLatency, d.avgLatency)}</div> </div> </div> <!-- the series lives INSIDE this card, directly under the control that drives it. It used to sit below the donut, several hundred pixels from a selector that silently governed it. --> <div class="traffic-series"> <div class="ranged-header"> <h2 id="tseries-label">traffic over time — all sources</h2> <span class="range-btns dim-btns" id="tseries-dim" aria-label="slice by"> <button data-dim="source" class="active">source</button> <button data-dim="endpoint">endpoint</button> <button data-dim="status">status</button> </span> <div id="tseries-chips"></div> </div> <div class="tseries-plot"> <svg viewBox="0 0 600 135" preserveAspectRatio="none" id="tseries"> <g id="tseries-lines"></g> <line id="tseries-guide" y1="0" y2="135" style="display:none" /> <g id="tseries-dots"></g> </svg> <div id="tseries-empty" style="display:none">no traffic in this window</div> </div> <div class="tseries-axis"><span id="tseries-t0"></span><span id="tseries-t1"></span></div> <div class="tseries-totals" id="tseries-totals"></div> <div class="tseries-foot"> <span class="tseries-hint" id="tseries-hint">tap a source below to add or remove it</span> <div class="ts-foot-btns"> <div class="ts-menu-wrap"> <button id="tseries-menu-btn" aria-haspopup="menu" aria-expanded="false" title="chart actions">…</button> <div class="ts-menu" id="tseries-menu" role="menu" hidden> <button role="menuitem" data-act="link">copy link to this view</button> <button role="menuitem" data-act="copy">copy image</button> <button role="menuitem" data-act="png">download png</button> <button role="menuitem" data-act="csv">download csv</button> </div> </div> </div> </div> </div> </div>
<div class="pie-wrap"> <h2>traffic sources</h2> ${d.trafficSources.length > 0 ? `<canvas id="pie" role="img" aria-label="traffic sources by app"></canvas> <div class="pie-legend">${pieLegendHtml}</div> ${rest ? `<div class="ts-summary">top ${PIE_TOP_N} named above · ${d.trafficSources.length} sources below</div>` : ''} <div class="ts-table-wrap"> <table class="ts-table"> <thead><tr><th>#</th><th>domain</th><th style="text-align:right">hits</th><th style="text-align:right">%</th></tr></thead> <tbody>${trafficTableRows}</tbody> </table> </div> ${trafficMoreHtml}` : `<div class="empty-card">collecting data — check back soon</div>`} <div class="pie-cta"> want to show up here? <a href="/docs">switch from bluesky’s typeahead →</a> </div> </div> <div class="pie-tip" id="pie-tip"></div>
<footer> <a href="/">← home</a> · <a href="/docs">docs</a> <button class="theme-btn" id="theme-toggle" type="button">theme: auto</button> </footer> </div> <div class="series-tip" id="series-tip"></div> <script> /* Canvas cannot take a CSS variable, so theme tokens are resolved here and re-read on every draw. Everything below used to be literal hex — which is why the axis labels and the donut's centre text were near-invisible in light mode, and why the trend series drifted out of step with the palette. */ function cssColor(v) { const m = /^var\\((--[\\w-]+)\\)$/.exec(v); return m ? tok(m[1], v) : v; } function tok(name, fallback) { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; } const COLORS = { total: 'var(--accent)', handles: 'var(--blue)', avatars: 'var(--violet)' };
function fmtNum(n) { if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; if (n >= 1e3) return (n / 1e3).toFixed(0) + 'k'; return n.toLocaleString(); }
/* --- actors indexed trend chart --- */ const allSnaps = ${snapshotJson}; let snaps = allSnaps; // the currently-shown window; chart + hover read this let trendFitY = false; const canvas = document.getElementById('trend'); const chartTip = document.getElementById('chart-tip'); const TREND_MS = { '7d': 604800000, '30d': 2592000000 }; // missing key (all) = everything
function applyTrendRange(range) { document.querySelectorAll('#trend-range button').forEach(b => b.classList.toggle('active', b.dataset.range === range)); const ms = TREND_MS[range]; const sliced = ms == null ? allSnaps : allSnaps.filter(s => s.ts >= Date.now() - ms); snaps = sliced.length >= 2 ? sliced : allSnaps; trendFitY = ms != null; // windowed views fit the y-axis so growth is visible drawChart(null); }
document.getElementById('trend-range')?.addEventListener('click', e => { const btn = e.target.closest('button[data-range]'); if (btn) applyTrendRange(btn.dataset.range); });
function drawChart(hoverIdx) { if (!canvas || snaps.length < 2) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const W = canvas.clientWidth, H = canvas.clientHeight; canvas.width = W * dpr; canvas.height = H * dpr; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, W, H);
const pad = { t: 16, r: 12, b: 24, l: 12 }; const cw = W - pad.l - pad.r, ch = H - pad.t - pad.b; const n = snaps.length;
let yMax = 0, yMin = Infinity; for (const s of snaps) { if (s.total > yMax) yMax = s.total; if (s.with_avatars < yMin) yMin = s.with_avatars; } if (!trendFitY || !isFinite(yMin)) yMin = 0; const ySpan = (yMax - yMin) || 1; yMax = yMax + ySpan * 0.08; yMin = trendFitY ? Math.max(0, yMin - ySpan * 0.08) : 0;
// x is mapped by ACTUAL TIME, not array index — snapshots are not evenly // spaced (irregular hourly gaps + outage gaps), and the date axis below is // time-based, so index-based points would desync from the labels/hover. const t0ms = snaps[0].ts; const tNms = snaps[n - 1].ts; const span = (tNms - t0ms) || 1; const toX = ts => pad.l + ((ts - t0ms) / span) * cw; const toY = v => pad.t + ch - ((v - yMin) / (yMax - yMin)) * ch;
const series = [ { key: 'with_avatars', color: COLORS.avatars }, { key: 'with_handles', color: COLORS.handles }, { key: 'total', color: COLORS.total }, ];
for (const s of series) { const pts = snaps.map((d) => [toX(d.ts), toY(d[s.key])]); const trace = () => { ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); };
// glow ctx.save(); trace(); ctx.shadowColor = cssColor(s.color); ctx.shadowBlur = 18; ctx.strokeStyle = cssColor(s.color); ctx.lineWidth = 2; ctx.globalAlpha = 0.07; ctx.stroke(); ctx.restore();
// medium glow ctx.save(); trace(); ctx.shadowColor = cssColor(s.color); ctx.shadowBlur = 6; ctx.strokeStyle = cssColor(s.color); ctx.lineWidth = 1; ctx.globalAlpha = 0.15; ctx.stroke(); ctx.restore();
// core line trace(); ctx.strokeStyle = cssColor(s.color); ctx.lineWidth = 1.2; ctx.globalAlpha = 0.55; ctx.stroke(); ctx.globalAlpha = 1; }
// x-axis { const axisY = H - pad.b; ctx.beginPath(); ctx.moveTo(pad.l, axisY); ctx.lineTo(W - pad.r, axisY); ctx.strokeStyle = tok('--line'); ctx.lineWidth = 1; ctx.stroke();
ctx.font = '10px system-ui, sans-serif'; ctx.fillStyle = tok('--dim'); ctx.textAlign = 'center'; const spanDays = span / 86400000;
// walk midnights const d = new Date(t0ms); d.setUTCMinutes(0, 0, 0); d.setUTCHours(0); d.setUTCDate(d.getUTCDate() + 1); const minGap = W < 500 ? 65 : 50; let prevX = -Infinity;
while (d.getTime() <= tNms) { const frac = (d.getTime() - t0ms) / (tNms - t0ms); const x = pad.l + frac * cw; if (x >= pad.l && x <= W - pad.r && x - prevX >= minGap) { ctx.beginPath(); ctx.moveTo(x, axisY); ctx.lineTo(x, axisY + 4); ctx.strokeStyle = tok('--line-2'); ctx.lineWidth = 1; ctx.stroke(); const label = spanDays > 14 ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }) : d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }); ctx.globalAlpha = 0.4; ctx.fillText(label, x, axisY + 15); ctx.globalAlpha = 1; prevX = x; } d.setUTCDate(d.getUTCDate() + 1); } }
// hover crosshair + dots if (hoverIdx != null && hoverIdx >= 0 && hoverIdx < n) { const x = toX(snaps[hoverIdx].ts); ctx.beginPath(); ctx.moveTo(x, pad.t); ctx.lineTo(x, H - pad.b); ctx.strokeStyle = tok('--line-2'); ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.stroke(); ctx.setLineDash([]);
const snap = snaps[hoverIdx]; for (const s of series) { const y = toY(snap[s.key]); ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fillStyle = tok('--panel'); ctx.globalAlpha = 0.9; ctx.fill(); ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.strokeStyle = cssColor(s.color); ctx.lineWidth = 1.5; ctx.globalAlpha = 0.8; ctx.stroke(); ctx.globalAlpha = 1; } } }
function chartHover(clientX, clientY) { if (!canvas || snaps.length < 2) return; const rect = canvas.getBoundingClientRect(); const mx = clientX - rect.left; const pad = { l: 12, r: 12 }; const cw = rect.width - pad.l - pad.r; const frac = Math.max(0, Math.min(1, (mx - pad.l) / cw)); // map cursor → time, then pick the snapshot nearest in time (x is // time-based, so nearest-by-index would not match the cursor position) const t0ms = snaps[0].ts; const span = (snaps[snaps.length - 1].ts - t0ms) || 1; const targetTs = t0ms + frac * span; let idx = 0, best = Infinity; for (let i = 0; i < snaps.length; i++) { const dd = Math.abs(snaps[i].ts - targetTs); if (dd < best) { best = dd; idx = i; } } drawChart(idx);
const snap = snaps[idx]; const t = new Date(snap.ts); const time = t.toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; chartTip.innerHTML = '<div class="ct-time">' + time + '</div>' const tipRows = [ { color: COLORS.total, value: snap.total, label: 'actors' }, { color: COLORS.avatars, value: snap.with_avatars, label: 'with avatars' }, { color: COLORS.handles, value: snap.with_handles, label: 'with handles' }, ].sort((a, b) => b.value - a.value); chartTip.innerHTML = '<div class="ct-time">' + time + '</div>' + tipRows.map(r => '<div class="ct-row"><span class="ct-dot" style="background:' + r.color + '"></span> ' + fmtNum(r.value) + ' ' + r.label + '</div>').join(''); chartTip.style.display = 'block'; const tx = clientX + 14, ty = clientY - 10; chartTip.style.left = (tx + chartTip.offsetWidth > window.innerWidth ? clientX - chartTip.offsetWidth - 10 : tx) + 'px'; chartTip.style.top = ty + 'px'; }
if (canvas) { drawChart(null); window.addEventListener('resize', () => drawChart(null));
canvas.addEventListener('mousemove', e => chartHover(e.clientX, e.clientY)); canvas.addEventListener('mouseleave', () => { chartTip.style.display = 'none'; drawChart(null); }); canvas.addEventListener('touchmove', e => { e.preventDefault(); const t = e.touches[0]; chartHover(t.clientX, t.clientY); }); canvas.addEventListener('touchend', () => { chartTip.style.display = 'none'; drawChart(null); }); }
/* --- traffic sources pie chart --- */ const PIE_COLORS = ${jsonScript(PIE_COLORS)}; const traffic = ${trafficJson}; const trafficGrandTotal = ${trafficGrandTotal}; const pieCanvas = document.getElementById('pie'); const pieTip = document.getElementById('pie-tip');
/* --- per-app traffic history: click pins a domain, hover the line for detail --- */ const trafficSeries = ${trafficSeriesJson}; // hourly, 30d: domain -> [[hour, hits], ...] const trafficSeries5m = ${trafficSeries5mJson}; // 5-min, 48h: domain -> [[bucket, hits], ...] const trafficSeriesDaily = ${trafficSeriesDailyJson}; // daily, full history: domain -> [[day, hits], ...] // the same volume sliced two other ways (every request the worker answered, // not just search cache misses): {hourly,m5,daily} -> {endpoint,status} -> key -> [[t, hits], ...] const requestSeries = ${requestSeriesJson}; const tseriesDim = document.getElementById('tseries-dim'); const tseriesLines = document.getElementById('tseries-lines'); const tseriesDots = document.getElementById('tseries-dots'); const tseriesGuide = document.getElementById('tseries-guide'); const tseriesChips = document.getElementById('tseries-chips'); const tseriesLabel = document.getElementById('tseries-label'); const tseriesEl = document.getElementById('tseries'); const tseriesEmpty = document.getElementById('tseries-empty'); // never declared before, so every trace threw ReferenceError and the tooltip // never appeared — an id with a hyphen gets no implicit global const seriesTip = document.getElementById('series-tip'); const tseriesTotals = document.getElementById('tseries-totals'); const menuBtn = document.getElementById('tseries-menu-btn'); const tseriesHint = document.getElementById('tseries-hint'); const tsMenu = document.getElementById('tseries-menu'); const tseriesT0 = document.getElementById('tseries-t0'); const tseriesT1 = document.getElementById('tseries-t1'); const TS_W = 600, TS_H = 135, TS_PAD = 6; const SVGNS = 'http://www.w3.org/2000/svg'; let pinned = []; // click-selected domains, in selection order let hovered = null; // pie-hover preview; only active while nothing is pinned let showBaseline = true; // the dotted all-sources line, for relative scale let tsView = null; // {t0Sec, stepSec, step, toY, series[]} — the trace reads this // which way the volume is sliced. 'source' is the original per-app chart // (search cache misses, from traffic_buckets); the other two read // request_buckets, which counts every answer the worker gave — so their // total is larger and includes the ingester's own admin traffic. let tsDim = 'source'; const DIMS = { source: { all: 'all sources', title: 'traffic over time', hint: 'tap a source below to add or remove it' }, endpoint: { all: 'all requests', title: 'requests by endpoint', hint: 'tap an endpoint to add or remove it' }, status: { all: 'all requests', title: 'requests by status', hint: 'tap a status class to add or remove it' }, }; const ENDPOINT_ORDER = ['search', 'getProfiles', 'request-indexing', 'page', 'admin/ingest', 'admin/delete', 'admin', 'health', 'debug', 'other']; // Okabe–Ito: distinguishable under deuteranopia/protanopia/tritanopia and // on both backgrounds. Hue is still not the only cue — status lines also // carry a dash pattern (below), and every tooltip row and total is named. const ENDPOINT_COLORS = { search: '#009E73', getProfiles: '#0072B2', 'request-indexing': '#CC79A7', page: '#56B4E9', 'admin/ingest': '#E69F00', 'admin/delete': '#D55E00', admin: '#8c8c8c', health: '#F0E442', debug: '#b0b0b0', other: '#666666' }; const STATUS_ORDER = ['2xx', '3xx', '4xx', '5xx']; const STATUS_COLORS = { '2xx': '#009E73', '3xx': '#56B4E9', '4xx': '#E69F00', '5xx': '#D55E00' }; // solid / dotted / dashed / dash-dot: tells 4xx from 5xx without colour const STATUS_DASH = { '2xx': '', '3xx': '1.5 3', '4xx': '6 3', '5xx': '8 3 2 3' }; function dimMap(stepSec) { const g = stepSec === 300 ? requestSeries.m5 : stepSec === 86400 ? requestSeries.daily : requestSeries.hourly; return (g && g[tsDim]) || {}; } // every key this dim can plot, in a fixed order — the chip row is built from this function dimKeys() { const present = new Set(); for (const g of ['hourly', 'm5', 'daily']) for (const k in ((requestSeries[g] || {})[tsDim] || {})) present.add(k); const order = tsDim === 'status' ? STATUS_ORDER : ENDPOINT_ORDER; return [...order.filter(k => present.has(k)), ...[...present].filter(k => !order.includes(k)).sort()]; } function keyColor(key) { if (tsDim === 'source') return domainColor(key); if (key == null) return tok('--accent'); const table = tsDim === 'status' ? STATUS_COLORS : ENDPOINT_COLORS; return table[key] || '#94a3b8'; }
const SPECIAL_SERIES = ${jsonScript(SPECIAL_DOMAINS)}; const REST_KEY = ${JSON.stringify(REST_LABEL)}; // the named sources are the pie's real slices; everything outside them is REST_KEY const NAMED = new Set(traffic.filter(r => !r.rest).map(r => r.domain)); function domainColor(domain) { if (domain == null) return tok('--accent'); if (domain === REST_KEY) return tok('--dim', '#8a938c'); if (SPECIAL_SERIES[domain]) return SPECIAL_SERIES[domain]; const idx = traffic.findIndex(r => r.domain === domain); return idx >= 0 ? PIE_COLORS[idx % PIE_COLORS.length] : tok('--accent'); } let tsRange = '7d'; // range picks both the window and the finest data that stays readable const TS_RANGES = { '24h': { secs: 86400, stepSec: 300, grain: '5 min' }, '7d': { secs: 604800, stepSec: 3600, grain: 'hourly' }, '30d': { secs: 2592000, stepSec: 3600, grain: 'hourly' }, // no window and no cap: daily rollups are kept forever // (resolution decays with age, history never drops) 'all': { secs: null, stepSec: 86400, grain: 'daily' } }; // KV materializes hourly; until the first run ships traffic_daily, "all" // falls back to the hourly series so the chart is never empty if (Object.keys(trafficSeriesDaily).length === 0) { TS_RANGES['all'] = { secs: 2592000, stepSec: 3600, grain: 'hourly' }; } function seriesFor(domain, stepSec) { const source = tsDim !== 'source' ? dimMap(stepSec) : stepSec === 300 ? trafficSeries5m : stepSec === 86400 ? trafficSeriesDaily : trafficSeries; if (domain != null && domain !== REST_KEY) return source[domain] || []; // null sums every source; REST_KEY sums every source the pie does not name const sums = new Map(); for (const dom in source) { if (domain === REST_KEY && NAMED.has(dom)) continue; for (const [t, hits] of source[dom]) sums.set(t, (sums.get(t) || 0) + hits); } return [...sums.entries()].sort((a, b) => a[0] - b[0]); } const MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; function fmtSec(sec, withTime) { const t = new Date(sec * 1000); const day = MON[t.getUTCMonth()] + ' ' + t.getUTCDate(); if (!withTime) return day; const hh = String(t.getUTCHours()).padStart(2, '0'); const mm = String(t.getUTCMinutes()).padStart(2, '0'); return day + ' ' + hh + ':' + mm; } // What gets drawn, in draw order. With nothing pinned that's just the total. // With apps pinned the total becomes a dashed reference line, because an app // series read alone has no sense of scale — 40 hits/hr looks identical // whether it's most of the traffic or a rounding error. It is dismissable // (click the line or its chip) since it also compresses everything under it. function activeSpecs() { const all = DIMS[tsDim].all; if (pinned.length === 0) { return hovered != null && tsDim === 'source' ? [{ domain: hovered, name: hovered, color: domainColor(hovered) }] : [{ domain: null, name: all, color: keyColor(null) }]; } const specs = pinned.map(d => ({ domain: d, name: d, color: keyColor(d) })); if (showBaseline) specs.unshift({ domain: null, name: all, color: 'var(--dim)', baseline: true }); return specs; } function showSeries() { if (!tseriesLines) return; const cfg = TS_RANGES[tsRange]; const nowUnit = Math.floor(Date.now() / 1000 / cfg.stepSec); const minUnit = cfg.secs == null ? -Infinity : nowUnit - cfg.secs / cfg.stepSec; const specs = activeSpecs(); const raws = specs.map(s => seriesFor(s.domain, cfg.stepSec).filter(([t]) => t >= minUnit)); // Deliberately short: what is plotted is named by the chips, and the true // span is printed on the axis below. This used to append the domain, "all // sources", and "· last 30d" — enough text to overflow the header on a // phone to say what two other elements already said. if (tseriesLabel) tseriesLabel.textContent = DIMS[tsDim].title + ' · ' + cfg.grain; if (tseriesHint) tseriesHint.textContent = DIMS[tsDim].hint; renderChips(); const nonEmpty = raws.filter(r => r.length > 0); const empty = nonEmpty.length === 0 || Math.max(...raws.map(r => r.length)) < 2; if (tseriesEl) tseriesEl.style.display = empty ? 'none' : 'block'; if (tseriesEmpty) tseriesEmpty.style.display = empty ? 'flex' : 'none'; tseriesLines.replaceChildren(); if (empty) { tsView = null; renderTotals(); if (tseriesT0) tseriesT0.textContent = ''; if (tseriesT1) tseriesT1.textContent = ''; return; } // ONE grid for every line — a shared t0/tN and a shared max. Per-series // extents would put two lines on silently different axes, which is worse // than not drawing the comparison at all. const t0 = Math.min(...nonEmpty.map(r => r[0][0])); const tN = Math.max(...nonEmpty.map(r => r[r.length - 1][0])); const nPts = Math.min(tN - t0 + 1, 800); const series = specs.map((s, i) => { const byUnit = new Map(raws[i]); const counts = []; // fill gaps with 0 so quiet periods read as quiet, not skipped for (let k = 0; k < nPts; k++) counts.push(byUnit.get(t0 + k) || 0); return { ...s, counts }; }); const max = Math.max(1, ...series.map(s => Math.max(...s.counts))); const step = nPts > 1 ? TS_W / (nPts - 1) : 0; const toY = c => TS_PAD + (1 - c / max) * (TS_H - 2 * TS_PAD); for (const s of series) { const pts = s.counts.map((c, i) => (i * step).toFixed(1) + ',' + toY(c).toFixed(1)).join(' '); if (s.baseline) { const hit = document.createElementNS(SVGNS, 'polyline'); hit.setAttribute('points', pts); hit.setAttribute('class', 'hit'); hit.addEventListener('click', e => { e.stopPropagation(); showBaseline = false; showSeries(); syncUrl(); }); tseriesLines.appendChild(hit); } const line = document.createElementNS(SVGNS, 'polyline'); line.setAttribute('points', pts); if (s.baseline) line.setAttribute('class', 'baseline'); else if (tsDim === 'status' && STATUS_DASH[s.domain]) line.setAttribute('stroke-dasharray', STATUS_DASH[s.domain]); line.style.stroke = s.color; line.style.pointerEvents = 'none'; tseriesLines.appendChild(line); } const showClock = cfg.secs <= 604800; if (tseriesT0) tseriesT0.textContent = fmtSec(t0 * cfg.stepSec, showClock); if (tseriesT1) tseriesT1.textContent = fmtSec((tN + 1) * cfg.stepSec, showClock); tsView = { t0Sec: t0 * cfg.stepSec, stepSec: cfg.stepSec, step, toY, series }; renderTotals(); } function chip(name, color, dashed, onClear) { const b = document.createElement('button'); b.className = 'ts-chip'; b.title = 'remove from chart'; const dot = document.createElement('span'); dot.className = 'ldot' + (dashed ? ' dash' : ''); if (!dashed) dot.style.background = color; const label = document.createElement('span'); label.textContent = name; // domains are caller-controlled — never innerHTML b.append(dot, label); if (onClear) { const x = document.createElement('span'); x.className = 'x'; x.textContent = '×'; b.appendChild(x); b.addEventListener('click', onClear); } return b; } function renderChips() { if (!tseriesChips) return; tseriesChips.replaceChildren(); if (tsDim !== 'source') { // no legend below to tap in these views, so the row lists every line // this slice can draw: selected ones solid with an ×, the rest quiet if (pinned.length && showBaseline) { tseriesChips.appendChild(chip(DIMS[tsDim].all, null, true, () => { showBaseline = false; showSeries(); syncUrl(); })); } const keys = dimKeys(); for (const k of keys) { const sel = pinned.includes(k); const c = chip(k, keyColor(k), false, () => applySelection(k)); if (!sel) { c.classList.add('pick'); c.title = 'add to chart'; c.querySelector('.x').textContent = '+'; } tseriesChips.appendChild(c); } if (pinned.length >= 2) { const clear = document.createElement('button'); clear.className = 'ts-chip clear'; clear.textContent = 'clear'; clear.addEventListener('click', () => applySelection(null)); tseriesChips.appendChild(clear); } else if (pinned.length === 0 && keys.length >= 2) { const all = document.createElement('button'); all.className = 'ts-chip clear'; all.textContent = 'select all'; all.addEventListener('click', () => { pinned = keys.slice(); showBaseline = true; showSeries(); syncUrl(); }); tseriesChips.appendChild(all); } return; } if (pinned.length === 0) { // hover preview: name the line without pretending it's a filter if (hovered != null) { const c = chip(hovered, domainColor(hovered), false, null); c.classList.add('preview'); tseriesChips.appendChild(c); } return; } if (showBaseline) { tseriesChips.appendChild(chip('all sources', null, true, () => { showBaseline = false; showSeries(); syncUrl(); })); } for (const d of pinned) { tseriesChips.appendChild(chip(d, domainColor(d), false, () => applySelection(d))); } // with every tap additive, there has to be one obvious way back to nothing if (pinned.length >= 2) { const clear = document.createElement('button'); clear.className = 'ts-chip clear'; clear.textContent = 'clear'; clear.addEventListener('click', () => applySelection(null)); tseriesChips.appendChild(clear); } } // The area under each curve. Buckets are counts per fixed-width interval, so // the integral over the window is just their sum — no trapezoid needed, and // pretending otherwise would invent precision the data doesn't have. function renderTotals() { if (!tseriesTotals) return; tseriesTotals.replaceChildren(); if (!tsView) return; const t0u = tsView.t0Sec / tsView.stepSec, n = tsView.series[0].counts.length; // measured over the same window even when the baseline line is hidden, // so dismissing it doesn't silently change what the percentages mean const all = seriesFor(null, tsView.stepSec) .filter(([t]) => t >= t0u && t < t0u + n).reduce((s, r) => s + r[1], 0); // unfiltered, the one series IS the total — report it rather than leaving // a reserved-but-blank row const apps = tsView.series.filter(s => pinned.length === 0 || !s.baseline) .map(s => ({ name: s.name, color: s.color, isTotal: s.domain == null, sum: s.counts.reduce((a, b) => a + b, 0) })) .sort((a, b) => b.sum - a.sum); const sig = document.createElement('span'); sig.className = 'sig'; sig.tabIndex = 0; sig.textContent = '∫'; sig.dataset.tip = 'area under each curve — total requests over the window shown. ' + 'requests, not users: apps call the endpoint at different rates.'; tseriesTotals.appendChild(sig); for (const a of apps) { const el = document.createElement('span'); el.className = 'tot'; const dot = document.createElement('span'); dot.className = 'ldot'; dot.style.background = a.color; const name = document.createElement('span'); name.textContent = a.name; const num = document.createElement('b'); num.textContent = a.sum.toLocaleString(); el.append(dot, name, num); if (all > 0 && !a.isTotal) { // "100%" of itself says nothing const pct = document.createElement('span'); pct.className = 'pct'; pct.textContent = '(' + (a.sum / all * 100).toFixed(1) + '%)'; el.appendChild(pct); } tseriesTotals.appendChild(el); } // A ratio, stated as a ratio. This said "X leads Y", which frames request // volume as a scoreboard — but an app that calls the endpoint on every // keystroke, or retries a bad auth flow, generates more requests without // more people using it. The number is real; the ranking it implied wasn't. if (apps.length >= 2 && apps[0].sum > 0 && apps[1].sum > 0) { const lead = document.createElement('span'); lead.className = 'lead'; const r = apps[0].sum / apps[1].sum; lead.textContent = apps[0].name + ' ' + (r < 10 ? r.toFixed(2) : Math.round(r)) + '× ' + apps[1].name + ' by request volume'; tseriesTotals.appendChild(lead); } }
function cssColor(v) { const m = /^var\\((--[\\w-]+)\\)$/.exec(v); return m ? getComputedStyle(document.documentElement).getPropertyValue(m[1]).trim() : v; } // Drawn from tsView rather than rasterising the live SVG: the SVG takes its // dash pattern, widths and colours from page CSS, none of which survives // serialisation, and a shared image wants a title and legend anyway. function renderCard() { if (!tsView) return null; const css = getComputedStyle(document.documentElement); const v = n => css.getPropertyValue(n).trim(); const font = getComputedStyle(document.body).fontFamily; const apps = tsView.series; const W = 680, PAD = 24, PLOT_T = 82, PLOT_H = 150; const rank = apps.filter(a => !a.baseline) .map(a => ({ name: a.name, sum: a.counts.reduce((x, c) => x + c, 0) })) .sort((a, b) => b.sum - a.sum); const leadLine = rank.length >= 2 && rank[0].sum > 0 && rank[1].sum > 0 ? rank[0].name + ' ' + (r => r < 10 ? r.toFixed(2) : Math.round(r))(rank[0].sum / rank[1].sum) + '× ' + rank[1].name + ' by request volume' : null; const H = PLOT_T + PLOT_H + 34 + apps.length * 20 + (leadLine ? 22 : 0) + 16; const s = 2; const cv = document.createElement('canvas'); cv.width = W * s; cv.height = H * s; const g = cv.getContext('2d'); g.scale(s, s); g.fillStyle = v('--panel') || v('--bg'); g.fillRect(0, 0, W, H); g.font = '15px ' + font; g.fillStyle = v('--text'); g.fillText('typeahead · ' + DIMS[tsDim].title, PAD, 36); g.font = '11px ' + font; g.fillStyle = v('--dim'); g.fillText(tseriesLabel.textContent.replace(DIMS[tsDim].title + ' · ', '') + ' · ' + tseriesT0.textContent + ' → ' + tseriesT1.textContent + ' UTC', PAD, 56); const n = apps[0].counts.length; const max = Math.max(1, ...apps.map(a => Math.max(...a.counts))); const plotW = W - PAD * 2, step = n > 1 ? plotW / (n - 1) : 0; const y = c => PLOT_T + (1 - c / max) * PLOT_H; g.strokeStyle = v('--line'); g.lineWidth = 1; g.beginPath(); g.moveTo(PAD, y(0)); g.lineTo(W - PAD, y(0)); g.stroke(); for (const a of apps) { g.beginPath(); a.counts.forEach((c, i) => i ? g.lineTo(PAD + i * step, y(c)) : g.moveTo(PAD, y(c))); g.strokeStyle = cssColor(a.color); g.lineWidth = a.baseline ? 1 : 1.5; g.setLineDash(a.baseline ? [3, 3] : []); g.stroke(); } g.setLineDash([]); let ly = PLOT_T + PLOT_H + 30; g.font = '11px ' + font; for (const a of apps) { const sum = a.counts.reduce((x, c) => x + c, 0); g.beginPath(); g.arc(PAD + 4, ly - 4, 4, 0, Math.PI * 2); if (a.baseline) { // hollow + dashed, same as the line it labels g.strokeStyle = cssColor(a.color); g.lineWidth = 1.5; g.setLineDash([2, 2]); g.stroke(); g.setLineDash([]); } else { g.fillStyle = cssColor(a.color); g.fill(); } g.fillStyle = v('--muted'); g.fillText(a.name, PAD + 16, ly); g.fillStyle = v('--text'); g.fillText(sum.toLocaleString() + ' hits', PAD + 220, ly); ly += 20; } if (leadLine) { g.fillStyle = v('--dim'); g.fillText('∫ ' + leadLine, PAD, ly + 6); } return cv; } function cardBlob() { const cv = renderCard(); return cv && new Promise(res => cv.toBlob(res, 'image/png')); } function download(blob, name) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); // revoking in the same tick has historically raced the download start setTimeout(() => URL.revokeObjectURL(url), 10_000); } function fileName(ext) { return 'typeahead-traffic-' + tsRange + '.' + ext; } async function copyImage() { const blob = await cardBlob(); if (!blob) return; // clipboard image write needs a secure context and isn't in every browser; // fall back to a download rather than failing silently try { await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); flashMenu('copied'); } catch { download(blob, fileName('png')); } } function flashMenu(msg) { if (!menuBtn) return; const prev = menuBtn.textContent; menuBtn.textContent = msg; setTimeout(() => { menuBtn.textContent = prev; }, 1200); } // Exports exactly what is plotted — same window, same grain, same series, // including the dashed total when it's shown. A CSV that quietly differed // from the chart above it would be worse than no export at all. function exportCsv() { if (!tsView) return; const q = v => /[",\\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; const lines = [['time_utc', ...tsView.series.map(s => q(s.name))].join(',')]; for (let i = 0; i < tsView.series[0].counts.length; i++) { const t = new Date((tsView.t0Sec + i * tsView.stepSec) * 1000).toISOString().replace('.000Z', 'Z'); lines.push([t, ...tsView.series.map(s => s.counts[i])].join(',')); } download(new Blob([lines.join('\\n') + '\\n'], { type: 'text/csv' }), fileName('csv')); } const ACTIONS = { link: async () => { try { await navigator.clipboard.writeText(location.href); flashMenu('copied'); } catch { flashMenu('copy failed'); } }, copy: copyImage, png: async () => { const b = await cardBlob(); if (b) download(b, fileName('png')); }, csv: exportCsv, }; function setMenu(open) { if (!tsMenu) return; tsMenu.hidden = !open; menuBtn.setAttribute('aria-expanded', String(open)); } menuBtn?.addEventListener('click', e => { e.stopPropagation(); setMenu(tsMenu.hidden); }); tsMenu?.addEventListener('click', e => { const act = e.target.dataset?.act; if (!act) return; setMenu(false); ACTIONS[act](); }); document.addEventListener('click', () => setMenu(false)); document.addEventListener('keydown', e => { if (e.key === 'Escape') setMenu(false); }); /* --- the view is addressable: ?apps=a,b&range=7d&total=0 --- Query params, not path segments: this is a filter over one page, not a different resource, and /stats/:app would have to invent an encoding for "two apps, 30 days, no baseline". replaceState rather than pushState so a shared link is always the current view without burying the previous page under one history entry per click. */ // Snapshot the incoming query BEFORE anything writes to it: initialising the // range calls syncUrl(), which with nothing pinned yet would delete ?apps= // out of the URL a moment before restore tried to read it. const BOOT = new URLSearchParams(location.search); window.__boot = BOOT; function syncUrl() { const p = new URLSearchParams(location.search); if (tsDim === 'source') p.delete('view'); else p.set('view', tsDim); // pinned keys live under 'apps' for sources (shared links predate the other // views) and 'sel' otherwise, so a link never restores an endpoint as an app p.delete('apps'); p.delete('sel'); if (tsDim === 'source') { if (pinned.length) p.set('apps', pinned.join(',')); } else { // default (everything on) is the bare URL; '-' is the one state that // needs a marker: nothing pinned, just the total const keys = dimKeys(); const allOn = pinned.length === keys.length && keys.every(k => pinned.includes(k)); if (pinned.length === 0) p.set('sel', '-'); else if (!allOn) p.set('sel', pinned.join(',')); } if (tsRange === '7d') p.delete('range'); else p.set('range', tsRange); if (pinned.length && !showBaseline) p.set('total', '0'); else p.delete('total'); const q = p.toString(); history.replaceState(null, '', location.pathname + (q ? '?' + q : '') + location.hash); } // Only domains we actually hold a series for. A link naming a domain that // has since aged out of traffic_buckets would otherwise render a chip // pointing at an empty line. const KNOWN = new Set([...Object.keys(trafficSeries), ...Object.keys(trafficSeries5m), ...Object.keys(trafficSeriesDaily), REST_KEY]); window.__restoreView = () => { const p = BOOT; const view = p.get('view'); if (DIMS[view] && view !== 'source') { tsDim = view; setActiveDimBtn(); const keys = dimKeys(); const sel = p.get('sel'); pinned = sel == null ? keys.slice() : sel === '-' ? [] : [...new Set(sel.split(',').map(s => s.trim()).filter(s => keys.includes(s)))]; showBaseline = p.get('total') !== '0'; showSeries(); syncUrl(); return; } const apps = (p.get('apps') || '').split(',').map(s => s.trim()).filter(s => KNOWN.has(s)); if (!apps.length) { syncUrl(); return; } pinned = [...new Set(apps)]; showBaseline = p.get('total') !== '0'; unfoldTo(pinned); document.querySelectorAll('.pie-legend span[data-domain], .ts-table tbody tr[data-domain]').forEach(el => el.classList.toggle('sel', pinned.includes(el.dataset.domain))); showSeries(); syncUrl(); }; // driven by the shared #activity-range, not a selector of its own window.__setTsRange = (range) => { if (!TS_RANGES[range]) return; tsRange = range; showSeries(); syncUrl(); }; /* Every tap TOGGLES. No modes, no modifier keys. This was "plain click replaces, shift-click adds", with a "compare" button standing in for shift on touch. It failed for the reason modes usually fail: the indicator sat in the chart header while the thing you tap is a screen-length below it, so you could not see which mode you were in — and re-tapping a selected source silently removed it. Toggling always is what a chart legend does everywhere else, it needs nothing held or discovered, and every accumulation is visible in the chips and one tap to undo. */ const tseriesWrap = document.querySelector('.traffic-series'); function setActiveDimBtn() { if (tseriesWrap) tseriesWrap.dataset.dim = tsDim; if (!tseriesDim) return; tseriesDim.querySelectorAll('button[data-dim]').forEach(b => b.classList.toggle('active', b.dataset.dim === tsDim)); } function setDim(dim) { if (!DIMS[dim] || dim === tsDim) return; tsDim = dim; // the picker views start with every line ON — the first thing you see is // the whole breakdown, and 'clear' is one tap if you want to pick instead pinned = dim === 'source' ? [] : dimKeys(); hovered = null; showBaseline = true; document.querySelectorAll('.pie-legend span[data-domain], .ts-table tbody tr[data-domain]').forEach(el => el.classList.remove('sel')); setActiveDimBtn(); showSeries(); syncUrl(); } if (tseriesDim) tseriesDim.addEventListener('click', e => { const b = e.target.closest('button[data-dim]'); if (b) setDim(b.dataset.dim); }); function applySelection(domain) { if (domain == null) pinned = []; else if (pinned.includes(domain)) pinned = pinned.filter(d => d !== domain); else pinned = [...pinned, domain]; if (pinned.length === 0) showBaseline = true; // fresh start next time document.querySelectorAll('.pie-legend span[data-domain], .ts-table tbody tr[data-domain]').forEach(el => el.classList.toggle('sel', pinned.includes(el.dataset.domain))); showSeries(); syncUrl(); } document.querySelectorAll('.pie-legend span[data-domain], .ts-table tbody tr[data-domain]').forEach(el => { el.addEventListener('click', e => { if (e.target.closest('a')) return; // domain links still navigate setDim('source'); // a source tap is a source-view action applySelection(el.dataset.domain); }); });
/* the table past the first page is folded: rows carry data-page and hidden, and each tap of the button reveals one more page. Nothing is fetched — every source is already in the document — so a folded source selected from the URL just needs its page (and those above it) shown. */ const tsMore = document.getElementById('ts-more'); const tsFolded = () => document.querySelectorAll('.ts-table tbody tr[data-page][hidden]'); function revealPage(page) { document.querySelectorAll('.ts-table tbody tr[data-page]').forEach(tr => { if (Number(tr.dataset.page) <= page) tr.hidden = false; }); if (!tsMore) return; const left = tsFolded().length; if (left === 0) { tsMore.hidden = true; return; } const size = Number(tsMore.dataset.pageSize) || 50; tsMore.textContent = 'show ' + Math.min(size, left) + ' more · ' + left + ' remaining'; } function unfoldTo(domains) { let deepest = 0; document.querySelectorAll('.ts-table tbody tr[data-page]').forEach(tr => { if (domains.includes(tr.dataset.domain)) deepest = Math.max(deepest, Number(tr.dataset.page)); }); if (deepest > 0) revealPage(deepest); } if (tsMore) tsMore.addEventListener('click', () => { const next = tsFolded()[0]; if (next) revealPage(Number(next.dataset.page)); });
// trace the line: crosshair dot + timestamp/value tooltip (mouse or finger) function tseriesTrace(clientX, clientY) { if (!tsView) return; const rect = tseriesEl.getBoundingClientRect(); const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); const nPts = tsView.series[0].counts.length; const idx = Math.round(frac * (nPts - 1)); // one dot per line, one row per line — reading a value off the wrong // series is the obvious way a multi-line chart misleads tseriesDots.replaceChildren(); const rows = []; for (const s of tsView.series) { const c = s.counts[idx]; const dot = document.createElementNS(SVGNS, 'circle'); dot.setAttribute('r', '2.5'); dot.setAttribute('cx', (idx * tsView.step).toFixed(1)); dot.setAttribute('cy', tsView.toY(c).toFixed(1)); dot.style.stroke = s.color; tseriesDots.appendChild(dot); rows.push({ name: s.name, color: s.color, c, baseline: s.baseline }); } if (tseriesGuide) { tseriesGuide.setAttribute('x1', (idx * tsView.step).toFixed(1)); tseriesGuide.setAttribute('x2', (idx * tsView.step).toFixed(1)); tseriesGuide.style.display = 'block'; } seriesTip.replaceChildren(); const when = document.createElement('div'); when.className = 'ct-time'; when.textContent = fmtSec(tsView.t0Sec + idx * tsView.stepSec, true) + ' UTC'; seriesTip.appendChild(when); for (const r of rows) { const row = document.createElement('div'); row.className = 'ct-row'; const dot = document.createElement('span'); dot.className = 'ct-dot'; dot.style.background = r.color; if (r.baseline) { dot.style.background = 'none'; dot.style.border = '1.5px dashed var(--dim)'; } const txt = document.createElement('span'); // always named: a lone dot beside a number says nothing about WHICH line // it is, and the user reading this may not see the dot's colour at all txt.textContent = r.name + ' — ' + r.c + (r.c === 1 ? ' hit' : ' hits'); row.append(dot, txt); seriesTip.appendChild(row); } seriesTip.style.display = 'block'; // keep the tip above the finger/cursor and inside the viewport const tx = Math.min(clientX + 12, window.innerWidth - seriesTip.offsetWidth - 8); seriesTip.style.left = Math.max(8, tx) + 'px'; seriesTip.style.top = (clientY - 40) + 'px'; } function tseriesTraceEnd() { seriesTip.style.display = 'none'; if (tseriesDots) tseriesDots.replaceChildren(); if (tseriesGuide) tseriesGuide.style.display = 'none'; } if (tseriesEl) { tseriesEl.addEventListener('mousemove', e => tseriesTrace(e.clientX, e.clientY)); tseriesEl.addEventListener('mouseleave', tseriesTraceEnd); tseriesEl.addEventListener('touchstart', e => { const t = e.touches[0]; tseriesTrace(t.clientX, t.clientY); }, { passive: true }); tseriesEl.addEventListener('touchmove', e => { const t = e.touches[0]; tseriesTrace(t.clientX, t.clientY); }, { passive: true }); tseriesEl.addEventListener('touchend', tseriesTraceEnd); } showSeries();
if (pieCanvas && traffic.length > 0) { const pieTotal = traffic.reduce((s, r) => s + r.hits, 0);
const SPECIAL = ${jsonScript(SPECIAL_DOMAINS)}; function getColor(i) { if (traffic[i].rest) return tok('--dim', '#8a938c'); return SPECIAL[traffic[i].domain] || PIE_COLORS[i % PIE_COLORS.length]; }
// build segments: { start, end, color, domain, hits } const segments = []; let angle = -Math.PI / 2; for (let i = 0; i < traffic.length; i++) { const sweep = (traffic[i].hits / pieTotal) * Math.PI * 2; segments.push({ start: angle, end: angle + sweep, color: getColor(i), domain: traffic[i].domain, hits: traffic[i].hits, rest: !!traffic[i].rest }); angle += sweep; }
let animProgress = 0; let hoverIdx = -1; const animStart = performance.now(); const animDur = 800;
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function drawPie() { const pctx = pieCanvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const W = pieCanvas.clientWidth, H = pieCanvas.clientHeight; pieCanvas.width = W * dpr; pieCanvas.height = H * dpr; pctx.scale(dpr, dpr); pctx.clearRect(0, 0, W, H);
const cx = W / 2, cy = H / 2; const outerR = Math.min(W, H) / 2 - 8; const innerR = outerR * 0.6; const maxAngle = -Math.PI / 2 + animProgress * Math.PI * 2;
for (let i = 0; i < segments.length; i++) { const seg = segments[i]; const drawStart = seg.start; const drawEnd = Math.min(seg.end, maxAngle); if (drawEnd <= drawStart) continue;
const grow = hoverIdx === i ? 4 : 0; const alpha = hoverIdx >= 0 && hoverIdx !== i ? 0.4 : 1;
pctx.globalAlpha = alpha; pctx.beginPath(); pctx.arc(cx, cy, outerR + grow, drawStart, drawEnd); pctx.arc(cx, cy, innerR - (grow ? 2 : 0), drawEnd, drawStart, true); pctx.closePath(); pctx.fillStyle = seg.color; pctx.fill(); }
// center text pctx.globalAlpha = 1; pctx.fillStyle = tok('--muted'); pctx.font = '600 ' + (outerR * 0.22) + 'px system-ui, sans-serif'; pctx.textAlign = 'center'; pctx.textBaseline = 'middle'; pctx.fillText(fmtNum(pieTotal), cx, cy - outerR * 0.06); pctx.font = (outerR * 0.11) + 'px system-ui, sans-serif'; pctx.fillStyle = tok('--dim'); pctx.fillText('hits', cx, cy + outerR * 0.14); }
function animLoop(now) { const t = Math.min((now - animStart) / animDur, 1); animProgress = easeOutCubic(t); drawPie(); if (t < 1) requestAnimationFrame(animLoop); } requestAnimationFrame(animLoop);
function hitTest(clientX, clientY) { const rect = pieCanvas.getBoundingClientRect(); const mx = clientX - rect.left - rect.width / 2; const my = clientY - rect.top - rect.height / 2; const dist = Math.sqrt(mx * mx + my * my); const outerR = Math.min(rect.width, rect.height) / 2 - 8; const innerR = outerR * 0.6; if (dist < innerR || dist > outerR + 8) return -1; let a = Math.atan2(my, mx); if (a < -Math.PI / 2) a += Math.PI * 2; for (let i = 0; i < segments.length; i++) { if (a >= segments[i].start && a < segments[i].end) return i; } return -1; }
function pieHover(clientX, clientY, isTouch) { const idx = hitTest(clientX, clientY); if (idx !== hoverIdx) { hoverIdx = idx; drawPie(); // Hover previews the history — but only for a real pointer. A finger // does not hover, and previewing on touch was actively misleading: // dragging across the donut swapped a preview chip in and out, which // looks exactly like selecting and then losing the selection. if (!isTouch && pinned.length === 0) { hovered = idx >= 0 ? segments[idx].domain : null; showSeries(); } } if (idx >= 0) { const seg = segments[idx]; const denom = trafficGrandTotal > 0 ? trafficGrandTotal : pieTotal; const pct = ((seg.hits / denom) * 100).toFixed(1); pieTip.innerHTML = '<strong>' + seg.domain + '</strong><br>' + seg.hits.toLocaleString() + (seg.hits === 1 ? ' hit' : ' hits') + ' (' + pct + '%)'; pieTip.style.display = 'block'; const tx = clientX + 14, ty = clientY - 10; pieTip.style.left = (tx + pieTip.offsetWidth > window.innerWidth ? clientX - pieTip.offsetWidth - 10 : tx) + 'px'; pieTip.style.top = ty + 'px'; } else { pieTip.style.display = 'none'; } }
pieCanvas.addEventListener('mousemove', e => pieHover(e.clientX, e.clientY, false)); pieCanvas.addEventListener('mouseleave', () => { hoverIdx = -1; drawPie(); pieTip.style.display = 'none'; if (pinned.length === 0) { hovered = null; showSeries(); } });
/* Selecting from the donut is handled on touchend, not left to the click the browser may or may not synthesise afterwards. The legend and table are ordinary elements; this is a canvas, and it was the one surface where a tap did nothing — you could only ever move the hover preview around, which read as "it keeps replacing my selection". tapHandledAt swallows the synthetic click if it does arrive, so a single tap can never toggle twice. */ let tapStart = null, tapHandledAt = 0; pieCanvas.addEventListener('touchstart', e => { const t = e.touches[0]; tapStart = { x: t.clientX, y: t.clientY, at: Date.now() }; }, { passive: true }); pieCanvas.addEventListener('touchmove', e => { e.preventDefault(); const t = e.touches[0]; // a drag is tracing the chart, not choosing a slice if (tapStart && Math.hypot(t.clientX - tapStart.x, t.clientY - tapStart.y) > 10) tapStart = null; pieHover(t.clientX, t.clientY, true); }); pieCanvas.addEventListener('touchend', e => { hoverIdx = -1; drawPie(); pieTip.style.display = 'none'; const t = e.changedTouches && e.changedTouches[0]; if (tapStart && t && Date.now() - tapStart.at < 700) { const idx = hitTest(t.clientX, t.clientY); tapStart = null; if (idx >= 0) { hovered = null; tapHandledAt = Date.now(); applySelection(segments[idx].domain); return; } } tapStart = null; if (pinned.length === 0) { hovered = null; showSeries(); } }); pieCanvas.addEventListener('click', e => { if (Date.now() - tapHandledAt < 700) return; // already handled as a tap const idx = hitTest(e.clientX, e.clientY); if (idx < 0) return; const dom = segments[idx].domain; hovered = null; applySelection(dom); }); window.addEventListener('resize', () => drawPie()); }
/* --- activity: one range selector drives searches, latency, and the series --- */ const ACTIVITY = ${activityJson}; // per-range totals, aggregated server-side // window by TIME (bucket is 5-min; hour*300 = unix seconds), not bucket // count — buckets are sparse so count-based slicing made 30d == all. const RANGE_LABEL = { '24h': '24 hours', '7d': '7 days', '30d': '30 days', 'all': 'all time' }; // long windows aggregate 5-min buckets so the line stays readable const AGG_SECS = { '30d': 3600, 'all': 21600 }; const AGG_LABEL = { 300: '5 min', 3600: 'hour', 21600: '6 h' };
const searchesValue = document.getElementById('searches-value'); const searchesLabel = document.getElementById('searches-label'); const latencyValue = document.getElementById('latency-value');
/* ONE handler for the whole activity section. Previously total searches, avg latency, and the traffic chart each had their own selector, so the page could show 24h latency next to a 7d search count next to a 30d chart — three windows, no indication they differed. */ function applyActivityRange(range) { document.querySelectorAll('#activity-range button') .forEach(b => b.classList.toggle('active', b.dataset.range === range)); const label = RANGE_LABEL[range] || range; const { searches, cacheHits, totalMs, cacheMs } = ACTIVITY[range] || ACTIVITY['all']; if (searchesValue) searchesValue.textContent = (searches + cacheHits).toLocaleString(); if (searchesLabel) searchesLabel.textContent = 'total searches (' + label + ')';
if (latencyValue) { const cold = searches > 0 ? totalMs / searches : 0; const cached = cacheHits > 0 ? cacheMs / cacheHits : 0; const pair = (n, t) => '<span class="lat-pair"><span class="lat-n">' + n + '</span><span class="lat-t">' + t + '</span></span>'; const parts = []; if (cached > 0) parts.push(pair(cached.toFixed(1) + ' ms', 'cached')); parts.push(pair(cold.toFixed(0) + ' ms', 'cold')); latencyValue.innerHTML = parts.join('<span class="lat-sep">·</span>'); }
window.__setTsRange?.(range); }
document.getElementById('activity-range')?.addEventListener('click', e => { const btn = e.target.closest('button[data-range]'); if (btn) applyActivityRange(btn.dataset.range); });
// default 7d: SSR renders the 7d view; this makes the JS slice the single // source of truth so server and client always agree. ?range= overrides it — // validated against the buttons that exist, so a hand-edited link falls back // to the default instead of rendering an undefined window. const wantRange = window.__boot?.get('range'); const hasRange = !!document.querySelector('#activity-range button[data-range="' + CSS.escape(wantRange || '') + '"]'); applyActivityRange(hasRange ? wantRange : '7d'); // after the range, so restoring apps plots against the right window window.__restoreView?.(); ${THEME_SCRIPT} </script></body></html>`;}