mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-27 04:49:43 +08:00
The telemetry database hit D1's 10 GB cap on 2026-08-11 and refused nearly every write for seven weeks: the dashboard kept showing Aug 9 as the latest day, and its activation query (a cohort join over raw events, ~55 s per week) stalled D1's single query lane until every panel failed. Root cause was the client: usage counters were aggregated per process and appended on exit, so every `serve` launch, CLI command and prompt hook uploaded its own `count: 1` line - 30.3M of the 30.8M event rows. - Client: merge count lines per (day, kind, name, client) on append, stale-claim recovery (which also bypassed the size cap) and send. - Ingest: usage counters ADD into usage_daily, one row per machine x day x tool (migration 0003), so storage no longer depends on upload frequency. - Rollup: reads usage from usage_daily, folds legacy usage rows out of events in 50k-row transactions, maintains machine_first_seen.first_index_day (0002), catches up on days a failed run missed, purges usage_daily with retention. 0004 drops the now-unused events_machine_day index. - Dashboard: ranges end today; rolled-up series return null past the rollup's last day instead of a phantom zero; no panel reads raw events (activation reads first_index_day); min/max lookups stay index-only; one retry on transient 5xx; banners when ingest stalls or the rollup falls behind. - scripts/backfill-rollup.sh re-runs the rollup over a day range. Already applied in production: DB 10 GB -> 467 MB, all usage counts kept, rollups current through 2026-10-02. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
472 lines
16 KiB
JavaScript
472 lines
16 KiB
JavaScript
/**
|
||
* The dashboard page: one filter row, a grid of panels, and a fetch per panel.
|
||
*
|
||
* Deliberate properties:
|
||
* - **One filter row, above everything it scopes.** Changing the range or
|
||
* hitting refresh re-queries every panel against the same slice; no panel
|
||
* carries its own time control.
|
||
* - **Panels fail alone.** Each one fetches, draws, and reports independently,
|
||
* so a 503 on one query leaves the other eighteen on screen instead of
|
||
* blanking the page.
|
||
* - **No client-side cache.** The only reuse is deduplicating identical URLs
|
||
* within a single render (four stat tiles read one /api/summary); that map is
|
||
* thrown away afterwards, so refresh really does re-ask. Anything longer-lived
|
||
* is the API's `Cache-Control` doing its job in the browser's own cache.
|
||
* - **No skeleton flash.** A refetch dims the previous render instead of tearing
|
||
* it down, so nothing jumps while new numbers land.
|
||
* - **Every chart has a table twin.** "Show numbers" reveals the same data as
|
||
* text, which is what keeps a value from being reachable only by hovering.
|
||
*/
|
||
|
||
import { PANELS } from './panels.js';
|
||
import { applyChartDefaults, shortDay } from './theme.js';
|
||
|
||
const RANGE_PRESETS = [
|
||
{ days: 7, label: 'Last 7 days' },
|
||
{ days: 14, label: 'Last 14 days' },
|
||
{ days: 30, label: 'Last 30 days' },
|
||
{ days: 90, label: 'Last 90 days' },
|
||
];
|
||
const DEFAULT_PRESET = 30;
|
||
const DAY_MS = 86_400_000;
|
||
|
||
const Chart = window.Chart;
|
||
|
||
/** Pause before the one retry a failed panel gets. */
|
||
const RETRY_DELAY_MS = 800;
|
||
|
||
/** Every fetch goes through here so an expired session lands on /login instead
|
||
* of failing silently mid-render. A 5xx or a dropped connection gets exactly one
|
||
* retry: D1 sheds queued queries under load and the second ask usually lands,
|
||
* while a real failure still reaches the panel a moment later. */
|
||
export async function api(path, { retry = true } = {}) {
|
||
let response;
|
||
try {
|
||
response = await fetch(path, { headers: { accept: 'application/json' } });
|
||
} catch (err) {
|
||
if (!retry) throw err;
|
||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
||
return api(path, { retry: false });
|
||
}
|
||
if (response.status === 401) {
|
||
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`;
|
||
throw new Error('session expired');
|
||
}
|
||
if (response.status >= 500 && retry) {
|
||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
||
return api(path, { retry: false });
|
||
}
|
||
if (!response.ok) {
|
||
const detail = await response.json().catch(() => null);
|
||
throw new Error(detail?.error ?? `responded ${response.status}`);
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Days
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const utcDay = (atMs) => new Date(atMs).toISOString().slice(0, 10);
|
||
const dayMs = (day) => Date.parse(`${day}T00:00:00Z`);
|
||
const addDays = (day, delta) => utcDay(dayMs(day) + delta * DAY_MS);
|
||
const isDay = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(dayMs(value));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// State
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const state = {
|
||
/**
|
||
* Today, as a UTC day — every preset ends here. Days are UTC because that is
|
||
* what every event and rollup is keyed on. Rolled-up numbers stop at the last
|
||
* day the nightly rollup has written; the API returns those later days as null,
|
||
* so a chart ending today shows a gap there rather than a zero.
|
||
*/
|
||
anchor: utcDay(Date.now()),
|
||
/** /api/meta, once it has answered: what each kind of number is current through. */
|
||
meta: null,
|
||
earliest: null,
|
||
preset: DEFAULT_PRESET,
|
||
custom: { from: null, to: null },
|
||
/** Panels whose table twin the reader has opened, kept across re-renders. */
|
||
openTables: new Set(),
|
||
renderToken: 0,
|
||
};
|
||
|
||
const charts = new Map();
|
||
|
||
function currentRange() {
|
||
if (state.preset === 'custom' && state.custom.from && state.custom.to) {
|
||
return { from: state.custom.from, to: state.custom.to };
|
||
}
|
||
const to = state.anchor;
|
||
return { from: addDays(to, -(state.preset - 1)), to };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DOM helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function el(tag, className, text) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
const $ = (root, role) => root.querySelector(`[data-role="${role}"]`);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Building the page
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function buildFilters() {
|
||
const bar = document.getElementById('filters');
|
||
const presets = $(bar, 'presets');
|
||
|
||
for (const preset of RANGE_PRESETS) {
|
||
const button = el('button', 'range', preset.label);
|
||
button.type = 'button';
|
||
button.dataset.days = String(preset.days);
|
||
button.addEventListener('click', () => {
|
||
state.preset = preset.days;
|
||
syncFilters();
|
||
render();
|
||
});
|
||
presets.append(button);
|
||
}
|
||
|
||
const from = $(bar, 'custom-from');
|
||
const to = $(bar, 'custom-to');
|
||
const apply = $(bar, 'custom-apply');
|
||
apply.addEventListener('click', () => {
|
||
if (!isDay(from.value) || !isDay(to.value)) {
|
||
setRangeSummary('Enter both dates as YYYY-MM-DD.');
|
||
return;
|
||
}
|
||
if (from.value > to.value) {
|
||
setRangeSummary('The start date must come before the end date.');
|
||
return;
|
||
}
|
||
state.preset = 'custom';
|
||
state.custom = { from: from.value, to: to.value };
|
||
syncFilters();
|
||
render();
|
||
});
|
||
|
||
$(bar, 'refresh').addEventListener('click', () => {
|
||
refreshMeta().finally(render);
|
||
});
|
||
}
|
||
|
||
function syncFilters() {
|
||
const bar = document.getElementById('filters');
|
||
for (const button of bar.querySelectorAll('button.range')) {
|
||
const selected = String(state.preset) === button.dataset.days;
|
||
button.classList.toggle('is-selected', selected);
|
||
button.setAttribute('aria-pressed', String(selected));
|
||
}
|
||
const { from, to } = currentRange();
|
||
$(bar, 'custom-from').value = from;
|
||
$(bar, 'custom-to').value = to;
|
||
}
|
||
|
||
function setRangeSummary(text) {
|
||
document.getElementById('range-summary').textContent = text;
|
||
}
|
||
|
||
function buildPanels() {
|
||
const grid = document.getElementById('grid');
|
||
for (const panel of PANELS) {
|
||
const section = el('section', `panel span-${panel.span}`);
|
||
section.id = `panel-${panel.id}`;
|
||
section.dataset.panel = panel.id;
|
||
section.dataset.state = 'loading';
|
||
|
||
const head = el('div', 'panel-head');
|
||
head.append(el('h2', null, panel.title));
|
||
const figure = el('p', 'panel-figure');
|
||
figure.dataset.role = 'figure';
|
||
head.append(figure);
|
||
section.append(head);
|
||
|
||
if (panel.note) section.append(el('p', 'panel-note', panel.note));
|
||
|
||
const body = el('div', 'panel-body');
|
||
body.dataset.role = 'body';
|
||
if (panel.kind === 'chart') {
|
||
const wrap = el('div', 'chart-wrap');
|
||
const canvas = document.createElement('canvas');
|
||
canvas.dataset.role = 'canvas';
|
||
// Chart.js renders to canvas, so the accessible copy is the table twin
|
||
// below — say so rather than leaving a bare graphic.
|
||
canvas.setAttribute('role', 'img');
|
||
canvas.setAttribute('aria-label', `${panel.title}. The same data is in the table below.`);
|
||
wrap.append(canvas);
|
||
body.append(wrap);
|
||
} else if (panel.kind === 'stat') {
|
||
const stat = el('div', 'stat');
|
||
stat.dataset.role = 'stat';
|
||
stat.append(el('p', 'stat-value'), el('p', 'stat-caption'));
|
||
body.append(stat);
|
||
} else if (panel.kind === 'funnel') {
|
||
const funnel = el('div', 'funnel');
|
||
funnel.dataset.role = 'funnel';
|
||
body.append(funnel);
|
||
}
|
||
|
||
const status = el('p', 'panel-state');
|
||
status.dataset.role = 'state';
|
||
body.append(status);
|
||
section.append(body);
|
||
|
||
const toggle = el('button', 'link', 'Show numbers');
|
||
toggle.type = 'button';
|
||
toggle.dataset.role = 'toggle';
|
||
toggle.setAttribute('aria-expanded', 'false');
|
||
const table = el('div', 'table-wrap');
|
||
table.dataset.role = 'table';
|
||
table.hidden = true;
|
||
toggle.addEventListener('click', () => {
|
||
const open = table.hidden;
|
||
table.hidden = !open;
|
||
toggle.textContent = open ? 'Hide numbers' : 'Show numbers';
|
||
toggle.setAttribute('aria-expanded', String(open));
|
||
if (open) state.openTables.add(panel.id);
|
||
else state.openTables.delete(panel.id);
|
||
});
|
||
section.append(toggle, table);
|
||
|
||
grid.append(section);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Drawing one panel
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function setState(section, name, message) {
|
||
section.dataset.state = name;
|
||
$(section, 'state').textContent = message ?? '';
|
||
}
|
||
|
||
function drawTable(section, spec) {
|
||
const host = $(section, 'table');
|
||
host.replaceChildren();
|
||
if (!spec) return;
|
||
|
||
const table = el('table');
|
||
const thead = el('thead');
|
||
const headRow = el('tr');
|
||
for (const column of spec.columns) {
|
||
const th = el('th', null, column);
|
||
th.scope = 'col';
|
||
headRow.append(th);
|
||
}
|
||
thead.append(headRow);
|
||
|
||
const tbody = el('tbody');
|
||
for (const row of spec.rows) {
|
||
const tr = el('tr');
|
||
row.forEach((cell, i) => {
|
||
const node = el(i === 0 ? 'th' : 'td', null, String(cell));
|
||
if (i === 0) node.scope = 'row';
|
||
tr.append(node);
|
||
});
|
||
tbody.append(tr);
|
||
}
|
||
table.append(thead, tbody);
|
||
host.append(table);
|
||
}
|
||
|
||
function drawStat(section, stat) {
|
||
const host = $(section, 'stat');
|
||
host.querySelector('.stat-value').textContent = stat.value;
|
||
host.querySelector('.stat-caption').textContent = stat.caption ?? '';
|
||
}
|
||
|
||
/**
|
||
* The two-stage conversion funnel, drawn as proportional bars rather than a
|
||
* chart: two bars and a percentage is the whole story, and a two-slice pie or a
|
||
* two-bar chart would be more chrome than data.
|
||
*/
|
||
function drawFunnel(section, funnel) {
|
||
const host = $(section, 'funnel');
|
||
host.replaceChildren();
|
||
|
||
for (const stage of funnel.stages) {
|
||
const row = el('div', 'funnel-stage');
|
||
const head = el('div', 'funnel-label');
|
||
head.append(el('span', null, stage.label), el('span', 'funnel-value', stage.value.toLocaleString('en-US')));
|
||
const track = el('div', 'funnel-track');
|
||
const fill = el('div', 'funnel-fill');
|
||
// Width is the datum, so it is set from JS rather than a style attribute —
|
||
// the CSP here allows no inline styles at all.
|
||
fill.style.width = `${Math.max(0, Math.min(1, stage.share)) * 100}%`;
|
||
track.append(fill);
|
||
row.append(head, track);
|
||
host.append(row);
|
||
}
|
||
|
||
const rate = funnel.rate === null ? '—' : `${(funnel.rate * 100).toFixed(1)}%`;
|
||
host.append(
|
||
el('p', 'funnel-summary', `${rate} converted · ${funnel.dropped.toLocaleString('en-US')} dropped off`),
|
||
);
|
||
}
|
||
|
||
function drawChart(section, panel, config) {
|
||
const canvas = $(section, 'canvas');
|
||
const existing = charts.get(panel.id);
|
||
if (existing) existing.destroy();
|
||
charts.set(panel.id, new Chart(canvas, config));
|
||
}
|
||
|
||
async function drawPanel(panel, request, token) {
|
||
const section = document.getElementById(`panel-${panel.id}`);
|
||
section.dataset.stale = 'true';
|
||
|
||
try {
|
||
const data = await request;
|
||
// A slower panel from a superseded render must never overwrite the current one.
|
||
if (token !== state.renderToken) return;
|
||
|
||
if (panel.empty?.(data)) {
|
||
setState(section, 'empty', 'Nothing in this range.');
|
||
drawTable(section, panel.table?.(data));
|
||
return;
|
||
}
|
||
|
||
if (panel.kind === 'stat') drawStat(section, panel.stat(data));
|
||
else if (panel.kind === 'funnel') drawFunnel(section, panel.funnel(data));
|
||
else drawChart(section, panel, panel.chart(data));
|
||
|
||
$(section, 'figure').textContent = panel.figure ? panel.figure(data) : '';
|
||
drawTable(section, panel.table?.(data));
|
||
setState(section, 'ready');
|
||
} catch (err) {
|
||
if (token !== state.renderToken) return;
|
||
// One panel's failure is one panel's problem: the message lands in the
|
||
// panel, the rest of the page keeps its data.
|
||
setState(section, 'error', `Could not load this panel — ${err.message ?? err}`);
|
||
const chart = charts.get(panel.id);
|
||
if (chart) {
|
||
chart.destroy();
|
||
charts.delete(panel.id);
|
||
}
|
||
} finally {
|
||
if (token === state.renderToken) section.dataset.stale = 'false';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Rendering everything
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function refreshMeta() {
|
||
// Re-read on every refresh, so a page left open overnight moves on to the new day.
|
||
state.anchor = utcDay(Date.now());
|
||
try {
|
||
const meta = await api('/api/meta');
|
||
state.meta = meta;
|
||
if (meta.today) state.anchor = meta.today;
|
||
state.earliest = meta.earliest_day ?? null;
|
||
} catch {
|
||
// A meta failure is not fatal: the presets still end today and every panel
|
||
// still answers. The status line is what says so.
|
||
state.meta = null;
|
||
}
|
||
for (const input of document.querySelectorAll('.custom-range input')) input.max = state.anchor;
|
||
syncFilters();
|
||
drawDataWarning();
|
||
}
|
||
|
||
const plural = (n, one, many) => `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;
|
||
|
||
/** What each kind of number is current through, as one line of fact. */
|
||
function dataThroughText() {
|
||
const meta = state.meta;
|
||
if (!meta) return 'Could not read how current the data is.';
|
||
const parts = [
|
||
meta.latest_rollup_day ? `Event counts through ${shortDay(meta.latest_rollup_day)}` : 'No event counts rolled up yet',
|
||
];
|
||
// The one number that shows an ingest collapse at a glance: production runs at
|
||
// thousands a day, so a handful here means events are not being stored.
|
||
parts.push(`${plural(meta.machines_yesterday ?? 0, 'machine', 'machines')} active yesterday`);
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
/**
|
||
* The stale-data warning. Silent when both writers are keeping up; otherwise says
|
||
* which one stopped, since when, and what that leaves the page unable to show.
|
||
*/
|
||
function drawDataWarning() {
|
||
const host = document.getElementById('data-warning');
|
||
host.replaceChildren();
|
||
const meta = state.meta;
|
||
const lines = [];
|
||
|
||
if (meta?.ingest_stalled) {
|
||
lines.push([
|
||
`No new events since ${shortDay(meta.latest_raw_day)}.`,
|
||
' The ingest worker at telemetry.getcodegraph.com is not storing anything; its logs and the D1 database are where to look.',
|
||
]);
|
||
}
|
||
if (meta?.rollup_behind) {
|
||
lines.push([
|
||
meta.latest_rollup_day
|
||
? `The nightly rollup has not run since ${shortDay(meta.latest_rollup_day)}.`
|
||
: 'The nightly rollup has never run.',
|
||
' Installs, indexing runs, tool calls and every breakdown stop there. Machine counts, new installs and retention are live.',
|
||
]);
|
||
}
|
||
|
||
for (const [lead, rest] of lines) {
|
||
const p = el('p');
|
||
p.append(el('strong', null, lead), document.createTextNode(rest));
|
||
host.append(p);
|
||
}
|
||
host.hidden = lines.length === 0;
|
||
}
|
||
|
||
async function render() {
|
||
const token = ++state.renderToken;
|
||
const { from, to } = currentRange();
|
||
const query = `from=${from}&to=${to}`;
|
||
|
||
setRangeSummary(`${shortDay(from)} – ${shortDay(to)}, ${to.slice(0, 4)} (UTC)`);
|
||
document.getElementById('data-through').textContent = dataThroughText();
|
||
|
||
// Deduplicate identical URLs within THIS render only — the four stat tiles
|
||
// share one /api/summary. Discarded when the render ends, so refresh refetches.
|
||
const inFlight = new Map();
|
||
const request = (path) => {
|
||
if (!inFlight.has(path)) inFlight.set(path, api(path));
|
||
return inFlight.get(path);
|
||
};
|
||
|
||
await Promise.allSettled(PANELS.map((panel) => drawPanel(panel, request(panel.source(query)), token)));
|
||
|
||
if (token === state.renderToken) {
|
||
document.getElementById('refreshed-at').textContent =
|
||
`Last refreshed ${new Date().toLocaleTimeString('en-US')}`;
|
||
document.body.dataset.ready = 'true';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Start
|
||
// ---------------------------------------------------------------------------
|
||
|
||
if (!Chart) {
|
||
document.getElementById('data-through').textContent =
|
||
'The chart library did not load — run `npm run vendor` and reload.';
|
||
} else {
|
||
applyChartDefaults(Chart);
|
||
buildFilters();
|
||
buildPanels();
|
||
syncFilters();
|
||
await refreshMeta();
|
||
await render();
|
||
}
|