Self-hosted telemetry on Cloudflare D1 + password-gated admin dashboard (CG-7) (#1497)
* feat(telemetry): D1 schema + migrations for raw events and daily rollups First step of replacing PostHog with self-hosted telemetry on Cloudflare D1. Creates the codegraph-telemetry database binding and the initial migration; no worker code paths change yet (the ingest write path and the nightly rollup cron land next). Schema is raw events plus daily rollups: `events` holds one row per sanitized event with the envelope broken out into columns and event-specific props as JSON; `daily_machines`, `daily_event_counts` and `daily_dim_counts` are the nightly rollups the dashboard reads; `machine_first_seen` and `machine_days` carry the retention cohorts and are never purged. One generic dimension table covers every bar and pie, so a new breakdown is a cron change rather than a migration. The migration is commented as an audit surface, like the rest of this worker — every column, and which dashboard chart each rollup table serves. Three judgment calls worth flagging, all documented in the file: - `events` gets `(day, event)` instead of the separate `(day)` and `(event, day)` indexes. D1 bills a row write per index touched, so a third index on the hot table costs ~97k writes/day, and `(day, event)` is a covering index for plain day-range scans anyway (verified with EXPLAIN QUERY PLAN). - `daily_event_counts` and `daily_dim_counts` carry a `machines` column, and `machine_days` a `prod` flag. The "users by ..." panels and the production-user count are distinct-machine numbers, not event counts, and they are unrecoverable once raw events are purged. - No CHECK constraint on `event`: the worker's allowlist is the source of truth and the write path is fail-silent, so a rejected INSERT would lose data quietly instead of erroring loudly. Volume note in the migration footer: ~30M row writes/month against the 50M included on Workers Paid. Storage is the tighter constraint — raw events grow ~74 MB/day, so retention should start at 90 days (~6.7 GB) rather than 180, which would exceed D1's 10 GB per-database cap. * feat(telemetry): admin dashboard worker — scaffold + shared-password auth New Cloudflare Worker at telemetry-dashboard/, sibling of telemetry-worker/ and bound read-only to the same D1 database. Serves a static frontend plus a JSON API behind a shared password, on stats.getcodegraph.com. Auth is the simplest thing that is actually safe for exactly two users: one password in a secret, compared in constant time over SHA-256 digests, and an HMAC-signed cookie (HttpOnly; Secure; SameSite=Lax; Path=/) with a one-year expiry so you sign in once per browser. The cookie is a signed assertion, not a lookup key — no session store. Its payload carries a fingerprint of the password it was minted against, so rotating ADMIN_PASSWORD signs everyone out. Login attempts are capped at 5/min per IP via a ratelimit binding. Everything is deny-by-default: assets.run_worker_first routes every request through the worker before the static-asset server sees it, so the dashboard HTML, its JS, its CSS and the chart library are all behind the session check. The login page is rendered inline by the worker rather than served from public/, which leaves no "is this file public?" judgement calls in the asset directory. Unauthenticated pages 302 to /login, unauthenticated /api/* gets 401. A missing secret fails closed rather than opening the dashboard. scripts/smoke-auth.sh is the regression net — 54 assertions against a throwaway `wrangler dev` covering the gate, cookie flags and persistence, forged/flipped/ truncated cookies, open-redirect refusal, brute-force capping, and password rotation invalidating live sessions. Refs CG-11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(telemetry-dashboard): simplify the chart-library probe in the shell Refs CG-11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(telemetry): nightly rollup cron + raw-event retention purge (CG-10) Adds a scheduled() handler to the ingest worker that recomputes daily_event_counts / daily_dim_counts / daily_machines for the just-completed UTC day plus a 2-day overlap (late-arriving offline buffers), then purges raw events past the retention window. Rollup writes are idempotent upserts, so a re-run never double-counts. Also adds an ADMIN_TOKEN-guarded POST /admin/rollup?day=YYYY-MM-DD for backfill/repair, and drops the PostHog forwarding path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(telemetry): dashboard charts — SQL API over D1 + the Chart.js views (CG-12, CG-13) Replaces the scaffold page with the dashboard proper: 19 panels covering every view of the PostHog dashboard this retires, driven by one filter row. src/api.ts is the read API CG-12 specified: /api/{meta,summary,timeseries, breakdown,activation,retention}, all range-scoped, all parameterized against a closed set of dims and metrics, all shaped labels[] + datasets[] so the frontend does no arithmetic. Rollups answer everything except the activation funnel, which needs raw events and says where they start. The frontend splits into a DOM-free panel registry (public/panels.js) and the page that mounts it (public/app.js), so the render check can drive the same registry the browser rendered from. Panels fail alone, refetch dims rather than flashing, and every chart carries a table twin. Two numbers are labelled rather than rounded off: range-wide "users" per dimension is machine-days (the rollups cannot give distinct machines, and per-day counts are taken as the largest single-event count so one machine's install + index + usage is not counted three times), and recent activation and retention cohorts are marked as still-converting instead of drawn as a cliff. Both colour scales were run through the data-viz validator against the panel surface, not picked by eye; the results are recorded in public/theme.js. Verification, all against the committed fixture (12 machines over 10 days, every expected number worked out by hand from the events, not recorded from a run): scripts/smoke-api.sh 98 assertions scripts/render-check.mjs 79 assertions — real Chromium over CDP, no new deps scripts/smoke-auth.sh 54 assertions (unchanged, still green) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(telemetry): cutover runbook + the end-to-end gate that de-risks it (CG-14) The account-level steps of the PostHog cutover are the maintainer's to run, so this lands the runbook they follow and the check that has to pass first. The runbook (telemetry-worker/README.md) walks the six steps in the order that keeps them reversible: Workers Paid → migrate → deploy → watch 24h → verify the first rollup and the dashboard → only then delete POSTHOG_KEY and cancel the subscription. Step 3 records the outgoing version id because `wrangler rollback` is the escape hatch for the whole verification window, and that window is precisely why the PostHog key is deleted last rather than first. The new gate (scripts/smoke-cutover.sh, `npm run smoke:cutover`) covers the one seam nothing else did. Both workers declare the same D1 database_id, so pointing them at a single --persist-to directory runs the real chain: a client batch → the ingest worker → D1 → the nightly rollup → the dashboard API reading the numbers back. Every other suite stops at one link — smoke-ingest at the events table, smoke-rollup at hand-checked SQL, smoke-api at a hand-written fixture that the cron never touched. That left the dimension names the rollup WRITES versus the ones the dashboard READS agreeing by convention across two branches, where a mismatch is silent: no error, no failed request, just a panel reading zero forever. 61 assertions, all 13 dimensions, and three deliberate traps — a ci machine that is active but not a production user, usage_rollup counts that must be summed rather than tallied, and an uninstall's `targets` that must not leak into the install-scoped breakdown. Writing it caught that the activation funnel's denominator is first-seen machines, not install events (deliberate — a reinstall must not re-enter the funnel), so the suite now pins that distinction rather than assuming it. Also rewords the last PostHog reference in dashboard code: a comment justifying the 14-day retention curve by pointing at a dashboard step 6 deletes. The reasoning now stands on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(telemetry): tell the truth about where events are stored (CG-15) The telemetry docs are a privacy contract, and they still described a managed analytics store that no longer receives anything. Replace that with what actually happens now — events land in our own D1 database on Cloudflare, the endpoint makes no outbound requests, raw events are purged after 90 days and only anonymous daily rollups outlive them. This strengthens the guarantee rather than restating it: there is no second party to share with. - TELEMETRY.md: new "Where it is stored" section; the never-collected IP bullet no longer leans on a vendor-side setting to hold. - docs/design/telemetry.md: ingest section rewritten around D1 + the nightly rollup/retention cron; volume math redone on Workers Paid and the D1 quota (storage, not writes, is what sets the 90-day window); new section documenting the dashboard worker and cross-linking it. - Fixed three drifts from the worker allowlist the sweep surfaced: schema_version was still 1, client_name/client_version was still marked "plumbing to add" though session.ts passes it today, and the legacy sqlite_backend field the worker still accepts was undocumented. - telemetry-worker/README.md: step 6 claimed a repo-wide grep came back clean, which this runbook itself falsifies. Added step 7 — deleting the runbook is what makes that grep true, and is the completion check. - smoke-cutover.sh: the vendor guarantee is now asserted by class (no analytics-ingest endpoint referenced) rather than by one vendor's name, so it keeps working once the name is gone. Verified it still catches a planted forwarding URL. 61/61 pass. Retention is documented as 90 days, not the 180 in the task notes: 180 days of raw events exceeds D1's 10 GB per-database cap, and the code purges at 90. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: untrack local Kommandr issue DB and ignore its sqlite artifacts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f6ac7b36e6
commit
49c11fc2e0
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/** Every fetch goes through here so an expired session lands on /login instead
|
||||
* of failing silently mid-render. */
|
||||
export async function api(path) {
|
||||
const response = await fetch(path, { headers: { accept: 'application/json' } });
|
||||
if (response.status === 401) {
|
||||
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`;
|
||||
throw new Error('session expired');
|
||||
}
|
||||
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 = {
|
||||
/** Latest day the nightly rollup has written; every preset ends here. */
|
||||
anchor: utcDay(Date.now()),
|
||||
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() {
|
||||
try {
|
||||
const meta = await api('/api/meta');
|
||||
if (meta.latest_day) state.anchor = meta.latest_day;
|
||||
state.earliest = meta.earliest_day ?? null;
|
||||
syncFilters();
|
||||
} catch {
|
||||
// A meta failure is not fatal: the picker falls back to today's date and
|
||||
// every panel still answers. The banner is what says so.
|
||||
document.getElementById('data-through').textContent = 'Could not read the data range.';
|
||||
}
|
||||
}
|
||||
|
||||
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)}`);
|
||||
document.getElementById('data-through').textContent = `Data through ${shortDay(state.anchor)}`;
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>codegraph telemetry</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="masthead">
|
||||
<div>
|
||||
<h1>codegraph telemetry</h1>
|
||||
<p class="subtitle">Anonymous usage from the public engine, straight out of D1.</p>
|
||||
</div>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="secondary">Sign out</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<!-- One filter row for the whole page: every panel below is drawn against the
|
||||
range chosen here, and no panel carries a time control of its own. The
|
||||
panels themselves are built from the registry in public/panels.js. -->
|
||||
<section class="filters" id="filters" aria-label="Time range">
|
||||
<div class="filter-group" data-role="presets"></div>
|
||||
|
||||
<div class="filter-group custom-range">
|
||||
<label for="custom-from">From</label>
|
||||
<input type="date" id="custom-from" data-role="custom-from" />
|
||||
<label for="custom-to">To</label>
|
||||
<input type="date" id="custom-to" data-role="custom-to" />
|
||||
<button type="button" class="secondary" data-role="custom-apply">Apply</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-group filter-end">
|
||||
<button type="button" class="secondary" data-role="refresh">Refresh</button>
|
||||
</div>
|
||||
|
||||
<p class="filter-status">
|
||||
<span id="range-summary">Loading…</span>
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<span id="data-through"></span>
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<span id="refreshed-at"></span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<main class="grid" id="grid"></main>
|
||||
|
||||
<script src="/vendor/chart.umd.js"></script>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* The panel registry — what the dashboard shows, in the order it shows it.
|
||||
*
|
||||
* Every panel is data in, chart config out, with no DOM anywhere in this file:
|
||||
* app.js owns the page, this owns the mapping from an API response to a chart.
|
||||
* Keeping them apart is what lets scripts/render-check.mjs drive the real panel
|
||||
* definitions in a real browser and compare what each one plotted against what
|
||||
* the API returned.
|
||||
*
|
||||
* A panel is:
|
||||
* id stable key, also the DOM id and the anchor in a bug report
|
||||
* title sentence case, at a readable size — never a tracked-out caps label
|
||||
* note the honest footnote: what the number actually counts
|
||||
* span grid columns out of 12
|
||||
* source (query) => API path; panels sharing a path share one fetch
|
||||
* kind 'stat' | 'funnel' | 'chart'
|
||||
* figure optional headline shown under the title (pie totals)
|
||||
* empty (data) => is there nothing to draw
|
||||
* table (data) => the WCAG-clean twin every chart owes the reader
|
||||
*/
|
||||
|
||||
import {
|
||||
CATEGORICAL,
|
||||
INDEX_HOVER,
|
||||
NEUTRAL,
|
||||
SURFACE,
|
||||
categoryScale,
|
||||
compact,
|
||||
number,
|
||||
paletteFor,
|
||||
percent,
|
||||
shortDay,
|
||||
valueScale,
|
||||
} from './theme.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const summary = (q) => `/api/summary?${q}`;
|
||||
const activation = (q) => `/api/activation?${q}`;
|
||||
const retention = (q) => `/api/retention?${q}`;
|
||||
const series = (metric) => (q) => `/api/timeseries?metric=${metric}&${q}`;
|
||||
const breakdown =
|
||||
(dim, extra = '') =>
|
||||
(q) =>
|
||||
`/api/breakdown?dim=${dim}${extra}&${q}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const allZero = (data) => data.datasets.every((ds) => ds.data.every((v) => !v));
|
||||
const noRows = (data) => data.labels.length === 0 || data.datasets[0].data.every((v) => !v);
|
||||
|
||||
/** Alpha-suffixed hex for the ~10% area wash under a single-series line. */
|
||||
const wash = (hex) => `${hex}1a`;
|
||||
|
||||
/**
|
||||
* A line per series over days. One axis, always — two measures of different
|
||||
* scale get two panels rather than a second y-axis, which would invent a
|
||||
* correlation the data does not have.
|
||||
*/
|
||||
function lineChart(data, { unit = 'count' } = {}) {
|
||||
const dense = data.labels.length > 21;
|
||||
const isPercent = unit === 'percent';
|
||||
// A wash under a single line reads well — but not across gaps, where the fill
|
||||
// would colour in days the series has no value for. Days with no cohort at
|
||||
// all are exactly that case, so a gapped series goes unfilled.
|
||||
const gapped = data.datasets.some((ds) => ds.data.some((v) => v === null));
|
||||
const single = data.datasets.length === 1 && !gapped;
|
||||
|
||||
return {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels.map(shortDay),
|
||||
datasets: data.datasets.map((ds, i) => {
|
||||
const colour = CATEGORICAL[i] ?? NEUTRAL;
|
||||
return {
|
||||
label: ds.label,
|
||||
data: ds.data,
|
||||
borderColor: colour,
|
||||
backgroundColor: single ? wash(colour) : colour,
|
||||
fill: single,
|
||||
// Dots on a 90-day line are noise; the index-mode tooltip is how you
|
||||
// read a value, and the table view is how you read all of them.
|
||||
pointRadius: dense ? 0 : 3,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: colour,
|
||||
// 2px surface ring, so a marker stays legible where lines cross.
|
||||
pointBorderColor: SURFACE,
|
||||
pointBorderWidth: 2,
|
||||
spanGaps: false,
|
||||
};
|
||||
}),
|
||||
},
|
||||
options: {
|
||||
interaction: INDEX_HOVER,
|
||||
plugins: {
|
||||
// A single series needs no legend box — the panel title names it.
|
||||
legend: { display: data.datasets.length > 1 },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) =>
|
||||
`${ctx.dataset.label}: ${
|
||||
ctx.parsed.y === null ? 'no data' : isPercent ? `${ctx.parsed.y}%` : number(ctx.parsed.y)
|
||||
}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: categoryScale(),
|
||||
y: valueScale(
|
||||
isPercent
|
||||
? { max: 100, ticks: { color: undefined, padding: 8, callback: (v) => `${v}%` } }
|
||||
: {},
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bands stacked to the day's total, for an ordered split of one measure.
|
||||
*
|
||||
* Four separate lines is the wrong form here: same-hue ordinal steps crossing
|
||||
* each other read as scribble, and the question ("how is run length shifting?")
|
||||
* is part-to-whole, not four independent trends. Stacked, the band heights are
|
||||
* the mix and the outline is the total. The 2px surface-coloured border is the
|
||||
* gap between touching fills — white doing the separating, not a stroke.
|
||||
*/
|
||||
function stackedAreaChart(data) {
|
||||
const colours = paletteFor(
|
||||
data.datasets.map((ds) => ds.label),
|
||||
'ordinal',
|
||||
);
|
||||
const config = lineChart(data);
|
||||
config.data.datasets.forEach((ds, i) => {
|
||||
ds.backgroundColor = colours[i];
|
||||
ds.borderColor = SURFACE;
|
||||
ds.borderWidth = 2;
|
||||
ds.pointRadius = 0;
|
||||
ds.pointHoverRadius = 4;
|
||||
ds.pointBackgroundColor = colours[i];
|
||||
ds.pointBorderColor = SURFACE;
|
||||
ds.fill = true;
|
||||
});
|
||||
config.options.scales.y.stacked = true;
|
||||
// The swatch has to be the band's colour; the line is surface-coloured here.
|
||||
config.options.plugins.legend = {
|
||||
display: true,
|
||||
labels: { generateLabels: () => data.datasets.map((ds, i) => ({
|
||||
text: ds.label,
|
||||
fillStyle: colours[i],
|
||||
strokeStyle: colours[i],
|
||||
pointStyle: 'circle',
|
||||
datasetIndex: i,
|
||||
})) },
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal bars. `scale: 'ordinal'` is for categories whose order is their
|
||||
* meaning (run length, codebase size) and takes the one-hue ramp; nominal
|
||||
* categories all take slot 1, because colouring them by value would spend the
|
||||
* identity channel re-encoding what bar length already says.
|
||||
*/
|
||||
function barChart(data, { scale = 'nominal' } = {}) {
|
||||
const colours =
|
||||
scale === 'ordinal'
|
||||
? paletteFor(data.labels, 'ordinal')
|
||||
: data.labels.map((label) => (label === 'Other' ? NEUTRAL : CATEGORICAL[0]));
|
||||
|
||||
return {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: data.datasets[0].label,
|
||||
data: data.datasets[0].data,
|
||||
backgroundColor: colours,
|
||||
maxBarThickness: 24,
|
||||
// Rounded at the data end, square at the baseline (Chart.js skips the
|
||||
// 'start' edge by default, which is the baseline on a horizontal bar).
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: valueScale(),
|
||||
y: categoryScale({ ticks: { color: undefined, padding: 6, autoSkip: false } }),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Part-to-whole at a glance. Capped at a handful of slices by the API's `limit`. */
|
||||
function pieChart(data, { scale = 'categorical' } = {}) {
|
||||
const total = data.datasets[0].data.reduce((n, v) => n + v, 0);
|
||||
return {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: data.datasets[0].label,
|
||||
data: data.datasets[0].data,
|
||||
backgroundColor: paletteFor(data.labels, scale === 'ordinal' ? 'ordinal' : 'categorical'),
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) =>
|
||||
`${ctx.label}: ${number(ctx.parsed)} (${total > 0 ? percent(ctx.parsed / total, 1) : '—'})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table twins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Days down the side, one column per series. */
|
||||
const seriesTable = (data) => ({
|
||||
columns: ['Day', ...data.datasets.map((ds) => ds.label)],
|
||||
rows: data.labels.map((day, i) => [
|
||||
day,
|
||||
...data.datasets.map((ds) => (ds.data[i] === null ? '—' : number(ds.data[i]))),
|
||||
]),
|
||||
});
|
||||
|
||||
/** Both numbers, always — the panel plots one of them, the table shows both. */
|
||||
const breakdownTable = (data) => ({
|
||||
columns: [data.title, 'Events', 'Machine-days'],
|
||||
rows: data.rows.map((r) => [r.value, number(r.count), number(r.machines)]),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The panels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PANELS = [
|
||||
{
|
||||
id: 'production-users',
|
||||
title: 'Production users',
|
||||
note: 'Distinct machines active in the range, excluding CI runners.',
|
||||
span: 3,
|
||||
kind: 'stat',
|
||||
source: summary,
|
||||
stat: (d) => ({ value: compact(d.production_users), caption: `${number(d.active_machines)} including CI` }),
|
||||
table: (d) => ({
|
||||
columns: ['Measure', 'Machines'],
|
||||
rows: [
|
||||
['Production users', number(d.production_users)],
|
||||
['All active machines', number(d.active_machines)],
|
||||
['First seen in range', number(d.new_machines)],
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'installs',
|
||||
title: 'Installs',
|
||||
note: 'Install events, including upgrades and reinstalls.',
|
||||
span: 3,
|
||||
kind: 'stat',
|
||||
source: summary,
|
||||
stat: (d) => ({ value: compact(d.installs), caption: `${number(d.new_machines)} from machines never seen before` }),
|
||||
table: (d) => ({
|
||||
columns: ['Measure', 'Events'],
|
||||
rows: [
|
||||
['Installs', number(d.installs)],
|
||||
['New machines', number(d.new_machines)],
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'uninstalls',
|
||||
title: 'Uninstalls',
|
||||
note: 'Uninstall events in the range.',
|
||||
span: 3,
|
||||
kind: 'stat',
|
||||
source: summary,
|
||||
stat: (d) => ({
|
||||
value: compact(d.uninstalls),
|
||||
caption: d.installs > 0 ? `${percent(d.uninstalls / d.installs)} of installs` : 'No installs in range',
|
||||
}),
|
||||
table: (d) => ({
|
||||
columns: ['Measure', 'Events'],
|
||||
rows: [
|
||||
['Uninstalls', number(d.uninstalls)],
|
||||
['Installs', number(d.installs)],
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'indexing-runs',
|
||||
title: 'Indexing runs',
|
||||
note: 'Index events in the range, across every machine.',
|
||||
span: 3,
|
||||
kind: 'stat',
|
||||
source: summary,
|
||||
stat: (d) => ({ value: compact(d.index_runs), caption: `${compact(d.tool_calls)} tool and command calls` }),
|
||||
table: (d) => ({
|
||||
columns: ['Measure', 'Events'],
|
||||
rows: [
|
||||
['Indexing runs', number(d.index_runs)],
|
||||
['Tool and command calls', number(d.tool_calls)],
|
||||
],
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
id: 'activation-funnel',
|
||||
title: 'Install to first use',
|
||||
note: 'Machines first seen in the range that ran an index within 7 days.',
|
||||
span: 4,
|
||||
kind: 'funnel',
|
||||
source: activation,
|
||||
empty: (d) => d.installs === 0,
|
||||
funnel: (d) => ({
|
||||
stages: [
|
||||
{ label: 'Installed', value: d.installs, share: 1 },
|
||||
{
|
||||
label: `Indexed within ${d.window_days} days`,
|
||||
value: d.activated,
|
||||
share: d.installs > 0 ? d.activated / d.installs : 0,
|
||||
},
|
||||
],
|
||||
rate: d.rate,
|
||||
dropped: d.dropped,
|
||||
}),
|
||||
table: (d) => ({
|
||||
columns: ['Stage', 'Machines', 'Share'],
|
||||
rows: [
|
||||
['Installed', number(d.installs), '100%'],
|
||||
[`Indexed within ${d.window_days} days`, number(d.activated), percent(d.rate)],
|
||||
['Dropped off', number(d.dropped), percent(d.installs > 0 ? d.dropped / d.installs : null)],
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'activation-rate',
|
||||
title: 'Conversion rate over time',
|
||||
note: 'By the day a machine was first seen. Recent days are still converting, so their rate only rises.',
|
||||
span: 8,
|
||||
kind: 'chart',
|
||||
source: activation,
|
||||
empty: (d) => d.installs === 0,
|
||||
chart: (d) => lineChart(d, { unit: 'percent' }),
|
||||
table: (d) => ({
|
||||
columns: ['Day', 'Installs', 'Indexed', 'Rate', 'Window elapsed'],
|
||||
rows: d.rows.map((r) => [
|
||||
r.day,
|
||||
number(r.installs),
|
||||
number(r.activated),
|
||||
percent(r.rate),
|
||||
r.complete ? 'Yes' : 'Not yet',
|
||||
]),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
id: 'os',
|
||||
title: 'Users by operating system',
|
||||
note: 'Share of machine-days: a machine active on several days counts once per day.',
|
||||
span: 4,
|
||||
kind: 'chart',
|
||||
// Three hues plus a neutral "Other" — the point past which categorical
|
||||
// colours stop being reliably distinguishable under colour-vision deficiency.
|
||||
source: breakdown('os', '&limit=3'),
|
||||
empty: noRows,
|
||||
figure: (d) => `${compact(d.total)} machine-days`,
|
||||
chart: (d) => pieChart(d),
|
||||
table: breakdownTable,
|
||||
},
|
||||
{
|
||||
id: 'run-length',
|
||||
title: 'Session run length',
|
||||
note: 'Indexing runs by how long they took.',
|
||||
span: 4,
|
||||
kind: 'chart',
|
||||
source: breakdown('duration_bucket'),
|
||||
empty: noRows,
|
||||
figure: (d) => `${compact(d.total)} runs`,
|
||||
chart: (d) => pieChart(d, { scale: 'ordinal' }),
|
||||
table: breakdownTable,
|
||||
},
|
||||
{
|
||||
id: 'codebase-size',
|
||||
title: 'Codebase size',
|
||||
note: 'Files per indexed project.',
|
||||
span: 4,
|
||||
kind: 'chart',
|
||||
source: breakdown('file_count_bucket'),
|
||||
empty: noRows,
|
||||
chart: (d) => barChart(d, { scale: 'ordinal' }),
|
||||
table: breakdownTable,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'installs-uninstalls',
|
||||
title: 'Installs and uninstalls over time',
|
||||
note: 'Install and uninstall events per day.',
|
||||
span: 6,
|
||||
kind: 'chart',
|
||||
source: series('installs_uninstalls'),
|
||||
empty: allZero,
|
||||
chart: (d) => lineChart(d),
|
||||
table: seriesTable,
|
||||
},
|
||||
{
|
||||
id: 'new-installs',
|
||||
title: 'New installs over time',
|
||||
note: 'Machines seen for the first time, by day.',
|
||||
span: 6,
|
||||
kind: 'chart',
|
||||
source: series('new_installs'),
|
||||
empty: allZero,
|
||||
chart: (d) => lineChart(d),
|
||||
table: seriesTable,
|
||||
},
|
||||
{
|
||||
id: 'indexing-activity',
|
||||
title: 'Daily indexing activity',
|
||||
note: 'Indexing runs and the machines that ran them.',
|
||||
span: 6,
|
||||
kind: 'chart',
|
||||
source: series('indexing_activity'),
|
||||
empty: allZero,
|
||||
chart: (d) => lineChart(d),
|
||||
table: seriesTable,
|
||||
},
|
||||
{
|
||||
id: 'daily-production-users',
|
||||
title: 'Daily production users',
|
||||
note: 'Distinct machines active each day, excluding CI runners.',
|
||||
span: 6,
|
||||
kind: 'chart',
|
||||
source: series('production_users'),
|
||||
empty: allZero,
|
||||
chart: (d) => lineChart(d),
|
||||
table: seriesTable,
|
||||
},
|
||||
{
|
||||
id: 'run-length-over-time',
|
||||
title: 'Run length over time',
|
||||
note: 'Indexing runs per day, split by how long they took.',
|
||||
span: 6,
|
||||
kind: 'chart',
|
||||
source: series('duration_buckets'),
|
||||
empty: allZero,
|
||||
// Ordered buckets, so the bands take the one-hue ramp rather than four
|
||||
// unrelated hues: the reader sees "longer" in the colour.
|
||||
chart: stackedAreaChart,
|
||||
table: seriesTable,
|
||||
},
|
||||
{
|
||||
id: 'retention',
|
||||
title: 'Daily retention cohorts',
|
||||
note: 'Machines first seen in the range, and the share still active k days later.',
|
||||
span: 6,
|
||||
kind: 'chart',
|
||||
source: retention,
|
||||
empty: (d) => d.cohort === 0,
|
||||
figure: (d) => `${compact(d.cohort)} machines in cohort`,
|
||||
chart: (d) => lineChart(d, { unit: 'percent' }),
|
||||
table: (d) => ({
|
||||
columns: ['Day', 'Machines old enough', 'Still active', 'Rate'],
|
||||
rows: d.rows.map((r) => [
|
||||
`Day ${r.day}`,
|
||||
number(r.eligible),
|
||||
number(r.retained),
|
||||
percent(r.rate),
|
||||
]),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
id: 'languages',
|
||||
title: 'Most-indexed programming languages',
|
||||
note: 'One count per indexing run that found the language; a mixed repo counts under each.',
|
||||
span: 6,
|
||||
kind: 'chart',
|
||||
source: breakdown('language'),
|
||||
empty: noRows,
|
||||
chart: (d) => barChart(d),
|
||||
table: breakdownTable,
|
||||
},
|
||||
{
|
||||
id: 'indexing-speed',
|
||||
title: 'Indexing speed',
|
||||
note: 'Indexing runs by duration bucket.',
|
||||
span: 6,
|
||||
kind: 'chart',
|
||||
source: breakdown('duration_bucket'),
|
||||
empty: noRows,
|
||||
chart: (d) => barChart(d, { scale: 'ordinal' }),
|
||||
table: breakdownTable,
|
||||
},
|
||||
{
|
||||
id: 'versions',
|
||||
title: 'Users by app version',
|
||||
note: 'Machine-days per version, newest first.',
|
||||
span: 6,
|
||||
kind: 'chart',
|
||||
source: breakdown('codegraph_version'),
|
||||
empty: noRows,
|
||||
chart: (d) => barChart(d),
|
||||
table: breakdownTable,
|
||||
},
|
||||
{
|
||||
id: 'targets',
|
||||
title: 'AI agent targets',
|
||||
note: 'Agents wired up at install time. One install can configure several.',
|
||||
span: 6,
|
||||
kind: 'chart',
|
||||
source: breakdown('target'),
|
||||
empty: noRows,
|
||||
chart: (d) => barChart(d),
|
||||
table: breakdownTable,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,345 @@
|
||||
/* Flat and editorial: square corners, hairline rules, sentence-case headings,
|
||||
one oxblood accent. Matches getcodegraph.com.
|
||||
|
||||
No tiny all-caps tracked-out labels anywhere — panel titles are real headings
|
||||
at a readable size, and the fine print under them is sentence case. */
|
||||
|
||||
:root {
|
||||
--paper: #f7f6f2;
|
||||
--surface: #ffffff;
|
||||
--ink: #16150f;
|
||||
--secondary: #56534a;
|
||||
--muted: #807d74;
|
||||
--oxblood: #7a201a;
|
||||
--rule: #d8d5cb;
|
||||
--hairline: #e7e5de;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.masthead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
/* --- filter row --------------------------------------------------------- */
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px 20px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-end {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.custom-range label {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.custom-range input {
|
||||
padding: 7px 10px;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.custom-range input:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--oxblood);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.filter-status {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filter-status .dot {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* --- buttons ------------------------------------------------------------ */
|
||||
|
||||
button {
|
||||
padding: 8px 14px;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
color: var(--paper);
|
||||
background: var(--oxblood);
|
||||
border: 1px solid var(--oxblood);
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.secondary,
|
||||
button.range {
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border-color: var(--rule);
|
||||
}
|
||||
|
||||
button.secondary:hover,
|
||||
button.range:hover {
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
button.range.is-selected {
|
||||
color: var(--paper);
|
||||
background: var(--oxblood);
|
||||
border-color: var(--oxblood);
|
||||
}
|
||||
|
||||
button.link {
|
||||
align-self: flex-start;
|
||||
margin-top: 12px;
|
||||
padding: 0;
|
||||
color: var(--oxblood);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --- grid --------------------------------------------------------------- */
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.span-3 { grid-column: span 3; }
|
||||
.span-4 { grid-column: span 4; }
|
||||
.span-6 { grid-column: span 6; }
|
||||
.span-8 { grid-column: span 8; }
|
||||
.span-12 { grid-column: span 12; }
|
||||
|
||||
/* A laptop is the target; below that the columns just widen rather than
|
||||
pretending to be a phone layout. */
|
||||
@media (max-width: 1180px) {
|
||||
.span-3 { grid-column: span 6; }
|
||||
.span-4,
|
||||
.span-8 { grid-column: span 6; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.span-3,
|
||||
.span-4,
|
||||
.span-6,
|
||||
.span-8 { grid-column: span 12; }
|
||||
}
|
||||
|
||||
/* --- panels ------------------------------------------------------------- */
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-figure {
|
||||
margin: 0;
|
||||
color: var(--secondary);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-note {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
margin-top: 12px;
|
||||
/* Refetch dims the previous render instead of tearing it down — no skeleton
|
||||
flash, no layout jump. */
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.panel[data-stale='true'] .panel-body {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.panel-state {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.panel[data-state='ready'] .panel-state {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel[data-state='error'] .panel-state {
|
||||
color: var(--oxblood);
|
||||
}
|
||||
|
||||
/* Until a panel has data there is nothing to show but its state line. */
|
||||
.panel:not([data-state='ready']) .chart-wrap,
|
||||
.panel:not([data-state='ready']) .stat,
|
||||
.panel:not([data-state='ready']) .funnel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Height covers the plot AND the axis band, so a panel never grows its own
|
||||
little scrollbar. */
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
height: 232px;
|
||||
}
|
||||
|
||||
/* --- stat tiles --------------------------------------------------------- */
|
||||
|
||||
.stat-value {
|
||||
margin: 4px 0 0;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
/* Proportional figures on purpose: tabular-nums makes a number like 121 look
|
||||
loose at display sizes. Tabular is for the table below. */
|
||||
}
|
||||
|
||||
.stat-caption {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* --- funnel ------------------------------------------------------------- */
|
||||
|
||||
.funnel-stage + .funnel-stage {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.funnel-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.funnel-value {
|
||||
color: var(--ink);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.funnel-track {
|
||||
height: 10px;
|
||||
margin-top: 6px;
|
||||
background: var(--hairline);
|
||||
}
|
||||
|
||||
.funnel-fill {
|
||||
height: 100%;
|
||||
background: var(--oxblood);
|
||||
}
|
||||
|
||||
.funnel-summary {
|
||||
margin: 16px 0 0;
|
||||
color: var(--secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* --- table twins -------------------------------------------------------- */
|
||||
|
||||
.table-wrap {
|
||||
margin-top: 12px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table-wrap table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
/* Columns of numbers that align vertically — the one place tabular figures
|
||||
are the right call. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.table-wrap th,
|
||||
.table-wrap td {
|
||||
padding: 5px 8px 5px 0;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.table-wrap thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface);
|
||||
color: var(--secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-wrap tbody th {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.table-wrap td {
|
||||
color: var(--secondary);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Chart theme — the colours and the Chart.js defaults every panel inherits.
|
||||
*
|
||||
* The palette is not eyeballed. Both scales below were run through the data-viz
|
||||
* validator against this dashboard's actual chart surface (#ffffff, the panel
|
||||
* fill — not the page's paper), and both clear every hard gate:
|
||||
*
|
||||
* categorical #a8342a,#2a6f9e,#17916a,#c98500 (light, surface #ffffff, --pairs all)
|
||||
* lightness band PASS · chroma floor PASS · CVD separation PASS (worst pair
|
||||
* ΔE 8.7 protan, all 6 pairs) · normal-vision floor PASS (worst 15.1) ·
|
||||
* contrast PASS (all ≥ 3:1, so no panel depends on the relief rule)
|
||||
*
|
||||
* ordinal #d99a90,#c26a5c,#a3423a,#7a201a (light, surface #ffffff, --ordinal)
|
||||
* monotone lightness PASS · adjacent ΔL PASS · light-end contrast 2.34:1
|
||||
* PASS · single hue PASS (spread 3°)
|
||||
*
|
||||
* If you change a hex, re-run the validator rather than trusting your eye —
|
||||
* the red/green pair that "looks fine" is the one that collapses under
|
||||
* deuteranopia. Slot order is the CVD-safety mechanism: assign in sequence,
|
||||
* never cycle, and fold a ninth series into "Other".
|
||||
*/
|
||||
|
||||
/** Panel fill — the surface every contrast number above was measured against. */
|
||||
export const SURFACE = '#ffffff';
|
||||
export const INK = '#16150f';
|
||||
export const SECONDARY = '#56534a';
|
||||
export const MUTED = '#807d74';
|
||||
export const GRID = '#e7e5de';
|
||||
export const AXIS = '#c9c6bc';
|
||||
|
||||
/**
|
||||
* Categorical — identity. Slot 1 is the brand oxblood stepped up into the
|
||||
* lightness band (#7a201a itself is too dark to sit in a categorical scale).
|
||||
*/
|
||||
export const CATEGORICAL = ['#a8342a', '#2a6f9e', '#17916a', '#c98500'];
|
||||
|
||||
/**
|
||||
* Neutral, deliberately outside the categorical scale: "Other" is a leftover,
|
||||
* not a series, and should not read as one.
|
||||
*/
|
||||
export const NEUTRAL = '#8d8a80';
|
||||
|
||||
/**
|
||||
* Ordinal — order IS the meaning (run length, codebase size). One hue, light to
|
||||
* dark, so the reader sees the ordering in the colour instead of decoding a legend.
|
||||
*/
|
||||
export const ORDINAL = ['#d99a90', '#c26a5c', '#a3423a', '#7a201a'];
|
||||
|
||||
/** Identity by position, never by rank — a filter must not repaint the survivors. */
|
||||
export function categorical(index) {
|
||||
return CATEGORICAL[index] ?? NEUTRAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Colours for an ordered set of n marks. Four buckets map onto the ramp exactly;
|
||||
* a shorter set is spread across it so the light→dark reading survives. Anything
|
||||
* past the ramp (an unexpected bucket from an old client) goes neutral rather
|
||||
* than inventing a step that would misstate the order.
|
||||
*/
|
||||
export function ordinal(n) {
|
||||
if (n <= 0) return [];
|
||||
if (n === 1) return [ORDINAL[2]];
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
out.push(i < ORDINAL.length ? ORDINAL[Math.round((i * (ORDINAL.length - 1)) / (n - 1))] : NEUTRAL);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "Other" keeps the neutral wherever the API folded a tail into it. */
|
||||
export function paletteFor(labels, scale) {
|
||||
const hues = scale === 'ordinal' ? ordinal(labels.length) : labels.map((_, i) => categorical(i));
|
||||
return labels.map((label, i) => (label === 'Other' ? NEUTRAL : hues[i]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
|
||||
const PLAIN = new Intl.NumberFormat('en-US');
|
||||
|
||||
/** Stat-tile values: 1,284 stays exact; 12,900 becomes 12.9K. */
|
||||
export function compact(n) {
|
||||
if (n === null || n === undefined || Number.isNaN(n)) return '—';
|
||||
return Math.abs(n) >= 10_000 ? COMPACT.format(n) : PLAIN.format(n);
|
||||
}
|
||||
|
||||
export function number(n) {
|
||||
if (n === null || n === undefined || Number.isNaN(n)) return '—';
|
||||
return PLAIN.format(n);
|
||||
}
|
||||
|
||||
export function percent(fraction, digits = 1) {
|
||||
if (fraction === null || fraction === undefined || Number.isNaN(fraction)) return '—';
|
||||
return `${(fraction * 100).toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
/** "2026-07-04" → "Jul 4". Axis ticks only; tables keep the full date. */
|
||||
export function shortDay(day) {
|
||||
const parsed = Date.parse(`${day}T00:00:00Z`);
|
||||
if (!Number.isFinite(parsed)) return day;
|
||||
return new Date(parsed).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart.js defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Applied once, before any chart is built. Everything here is the recessive
|
||||
* half of the design: hairline grid, muted axis text, no animation loud enough
|
||||
* to notice. Text never wears a series colour — identity comes from the mark
|
||||
* beside it, which is why the legend uses point-style swatches.
|
||||
*/
|
||||
export function applyChartDefaults(Chart) {
|
||||
const { defaults } = Chart;
|
||||
defaults.font.family =
|
||||
"'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
|
||||
defaults.font.size = 12;
|
||||
defaults.color = MUTED;
|
||||
defaults.borderColor = GRID;
|
||||
defaults.maintainAspectRatio = false;
|
||||
defaults.animation.duration = 180;
|
||||
|
||||
defaults.plugins.legend.position = 'bottom';
|
||||
defaults.plugins.legend.align = 'start';
|
||||
defaults.plugins.legend.labels.usePointStyle = true;
|
||||
defaults.plugins.legend.labels.pointStyle = 'circle';
|
||||
defaults.plugins.legend.labels.boxWidth = 8;
|
||||
defaults.plugins.legend.labels.boxHeight = 8;
|
||||
defaults.plugins.legend.labels.padding = 14;
|
||||
defaults.plugins.legend.labels.color = SECONDARY;
|
||||
|
||||
defaults.plugins.tooltip.backgroundColor = INK;
|
||||
defaults.plugins.tooltip.padding = 10;
|
||||
defaults.plugins.tooltip.cornerRadius = 0;
|
||||
defaults.plugins.tooltip.displayColors = true;
|
||||
defaults.plugins.tooltip.usePointStyle = true;
|
||||
defaults.plugins.tooltip.boxWidth = 8;
|
||||
defaults.plugins.tooltip.boxHeight = 8;
|
||||
|
||||
defaults.elements.line.borderWidth = 2;
|
||||
defaults.elements.line.borderJoinStyle = 'round';
|
||||
defaults.elements.line.borderCapStyle = 'round';
|
||||
defaults.elements.line.tension = 0;
|
||||
defaults.elements.point.hoverBorderWidth = 2;
|
||||
defaults.elements.bar.borderRadius = 4;
|
||||
defaults.elements.arc.borderColor = SURFACE;
|
||||
// The 2px surface gap between touching fills — white doing the separating,
|
||||
// rather than a stroke drawn around each mark.
|
||||
defaults.elements.arc.borderWidth = 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ticks` is merged rather than replaced: spreading an override on top would
|
||||
* silently drop the tick limit and hand back a y-axis labelled every 10%.
|
||||
*/
|
||||
const scale = (base, extra) => ({ ...base, ...extra, ticks: { ...base.ticks, ...extra.ticks } });
|
||||
|
||||
/** A value axis: hairline grid, clean ticks, always anchored at zero. */
|
||||
export function valueScale(extra = {}) {
|
||||
return scale(
|
||||
{
|
||||
beginAtZero: true,
|
||||
border: { color: AXIS },
|
||||
grid: { color: GRID, drawTicks: false },
|
||||
ticks: { color: MUTED, padding: 8, maxTicksLimit: 6, precision: 0 },
|
||||
},
|
||||
extra,
|
||||
);
|
||||
}
|
||||
|
||||
/** A category or time axis: no grid at all, so the marks carry the chart. */
|
||||
export function categoryScale(extra = {}) {
|
||||
return scale(
|
||||
{
|
||||
border: { color: AXIS },
|
||||
grid: { display: false },
|
||||
ticks: { color: MUTED, padding: 6, autoSkipPadding: 12, maxRotation: 0 },
|
||||
},
|
||||
extra,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crosshair-style reading on anything plotted against days: hovering anywhere in
|
||||
* a column reports every series at that day, so a 2px line never has to be hit
|
||||
* dead-centre.
|
||||
*/
|
||||
export const INDEX_HOVER = { mode: 'index', intersect: false, axis: 'x' };
|
||||
Reference in New Issue
Block a user