From 49c11fc2e0c02170742be8411e66a31af611f4b7 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 1 Aug 2026 16:17:10 -0500 Subject: [PATCH] Self-hosted telemetry on Cloudflare D1 + password-gated admin dashboard (CG-7) (#1497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * chore(telemetry-dashboard): simplify the chart-library probe in the shell Refs CG-11. Co-Authored-By: Claude Opus 5 * 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 * 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 * 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 * 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 * chore: untrack local Kommandr issue DB and ignore its sqlite artifacts Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 5 --- .gitignore | 3 + .kommandr/kommandr.db | Bin 4096 -> 0 bytes CHANGELOG.md | 4 + TELEMETRY.md | 33 +- docs/design/telemetry.md | 108 +- telemetry-dashboard/.dev.vars.example | 7 + telemetry-dashboard/.gitignore | 7 + telemetry-dashboard/README.md | 193 ++ telemetry-dashboard/package-lock.json | 1577 +++++++++++++++++ telemetry-dashboard/package.json | 22 + telemetry-dashboard/public/app.js | 395 +++++ telemetry-dashboard/public/index.html | 52 + telemetry-dashboard/public/panels.js | 534 ++++++ telemetry-dashboard/public/styles.css | 345 ++++ telemetry-dashboard/public/theme.js | 195 ++ telemetry-dashboard/scripts/fixture.sql | 208 +++ telemetry-dashboard/scripts/render-check.mjs | 465 +++++ telemetry-dashboard/scripts/seed-fixture.sh | 31 + telemetry-dashboard/scripts/smoke-api.sh | 264 +++ telemetry-dashboard/scripts/smoke-auth.sh | 211 +++ telemetry-dashboard/scripts/vendor-assets.mjs | 37 + telemetry-dashboard/src/api.ts | 827 +++++++++ telemetry-dashboard/src/auth.ts | Bin 0 -> 6473 bytes telemetry-dashboard/src/index.ts | 275 +++ telemetry-dashboard/src/login-page.ts | 120 ++ telemetry-dashboard/tsconfig.json | 17 + telemetry-dashboard/wrangler.jsonc | 51 + telemetry-worker/.dev.vars.example | 9 +- telemetry-worker/.gitignore | 3 +- telemetry-worker/README.md | 214 ++- telemetry-worker/migrations/0001_init.sql | 205 +++ telemetry-worker/package.json | 9 +- telemetry-worker/scripts/smoke-cutover.sh | 270 +++ telemetry-worker/scripts/smoke-ingest.sh | 187 ++ telemetry-worker/scripts/smoke-rollup.sh | 276 +++ telemetry-worker/src/env.d.ts | 10 + telemetry-worker/src/index.ts | 173 +- telemetry-worker/src/rollup.ts | 397 +++++ telemetry-worker/wrangler.jsonc | 37 +- 39 files changed, 7683 insertions(+), 88 deletions(-) delete mode 100644 .kommandr/kommandr.db create mode 100644 telemetry-dashboard/.dev.vars.example create mode 100644 telemetry-dashboard/.gitignore create mode 100644 telemetry-dashboard/README.md create mode 100644 telemetry-dashboard/package-lock.json create mode 100644 telemetry-dashboard/package.json create mode 100644 telemetry-dashboard/public/app.js create mode 100644 telemetry-dashboard/public/index.html create mode 100644 telemetry-dashboard/public/panels.js create mode 100644 telemetry-dashboard/public/styles.css create mode 100644 telemetry-dashboard/public/theme.js create mode 100644 telemetry-dashboard/scripts/fixture.sql create mode 100644 telemetry-dashboard/scripts/render-check.mjs create mode 100755 telemetry-dashboard/scripts/seed-fixture.sh create mode 100755 telemetry-dashboard/scripts/smoke-api.sh create mode 100755 telemetry-dashboard/scripts/smoke-auth.sh create mode 100644 telemetry-dashboard/scripts/vendor-assets.mjs create mode 100644 telemetry-dashboard/src/api.ts create mode 100644 telemetry-dashboard/src/auth.ts create mode 100644 telemetry-dashboard/src/index.ts create mode 100644 telemetry-dashboard/src/login-page.ts create mode 100644 telemetry-dashboard/tsconfig.json create mode 100644 telemetry-dashboard/wrangler.jsonc create mode 100644 telemetry-worker/migrations/0001_init.sql create mode 100755 telemetry-worker/scripts/smoke-cutover.sh create mode 100755 telemetry-worker/scripts/smoke-ingest.sh create mode 100755 telemetry-worker/scripts/smoke-rollup.sh create mode 100644 telemetry-worker/src/env.d.ts create mode 100644 telemetry-worker/src/rollup.ts diff --git a/.gitignore b/.gitignore index 9bb9779..47d16c0 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ __tests__/zz-scratch* # linux-arm64 kernel cross-build cache (rust:1-bookworm builder) target-linux/ +.kommandr/kommandr.db +.kommandr/kommandr.db-wal +.kommandr/kommandr.db-shm diff --git a/.kommandr/kommandr.db b/.kommandr/kommandr.db deleted file mode 100644 index db7a7459cf0d014b0dc2333abb5541c58c2d6ed1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYBBV;st3JAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo*g{}s{ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dcd9b0..7b57b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- Anonymous usage telemetry is now stored entirely on CodeGraph's own first-party infrastructure — no third-party analytics vendor receives any of it, and the endpoint that receives it makes no outbound requests at all. Individual events are deleted after 90 days, leaving only anonymous daily totals. Nothing about what is collected changed, your IP address is still never read or stored, and every off-switch works exactly as before (`codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, `DO_NOT_TRACK=1`). `TELEMETRY.md` remains the complete field-by-field list. + ### Fixes - A CodeGraph process that gets force-killed — by the stuck-process watchdog, a crash, or the OS — no longer leaves the database's write-ahead log behind to grow without bound. Previously each killed session stacked more data onto the same log file and nothing ever shrank it, which on machines where sessions were killed regularly could quietly eat tens of gigabytes of disk. The log is now capped, and any oversized leftover is reclaimed automatically the next time the project is opened. Thanks @tiendungdev for the exceptional Windows report that pinned this down. (#1431) diff --git a/TELEMETRY.md b/TELEMETRY.md index f9301da..c24ebef 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -70,8 +70,8 @@ per-call event stream, and nothing is sent in real time. - **No source code.** No file paths, file names, directory names, repository names or URLs, symbol names, search queries, or anything else derived from the contents of an indexed project. -- **No IP addresses.** The ingest endpoint never reads, logs, or forwards the client IP, - and IP discarding is enabled at the analytics backend on top of that. No geolocation. +- **No IP addresses.** The ingest endpoint never reads, logs, or stores the client IP — + and there is no analytics vendor downstream that could. No geolocation. - **No fingerprinting.** The machine ID is a random UUID stored in `~/.codegraph/telemetry.json` — delete that file (or run `codegraph telemetry off`, then `on`) and the old ID is gone forever, with no way to reconnect it. @@ -81,12 +81,29 @@ per-call event stream, and nothing is sent in real time. Events POST to `telemetry.getcodegraph.com` — a first-party endpoint whose complete source lives in [`telemetry-worker/`](telemetry-worker/) in this repository. It validates -every event and property against the allowlist above (anything else is dropped), strips -IPs, rate-limits, and forwards to a managed analytics store (PostHog, US region) as -anonymous events. Sends are fire-and-forget with a short timeout: offline or air-gapped -machines buffer a bounded local file (256 KB cap) and never retry-loop, log errors, or -slow a command down. Telemetry never adds latency to MCP tool calls — recording is an -in-memory counter. +every event and property against the allowlist above (anything else is dropped), never +reads the client IP, and rate-limits per machine ID. Sends are fire-and-forget with a +short timeout: offline or air-gapped machines buffer a bounded local file (256 KB cap) +and never retry-loop, log errors, or slow a command down. Telemetry never adds latency to +MCP tool calls — recording is an in-memory counter. + +## Where it is stored + +Accepted events are written to **our own database on Cloudflare** (D1) and go nowhere +else. **No third-party analytics vendor receives any of this data**, because the ingest +endpoint makes no outbound requests at all — its source is the entire path your events +take, and there is nothing after it. This is a stronger guarantee than a promise not to +share: there is no second party to share with. + +What is kept is checkable rather than asserted. The storage schema — +[`telemetry-worker/migrations/0001_init.sql`](telemetry-worker/migrations/0001_init.sql), +checked in beside the endpoint that writes it — is the complete list of what a row can +hold, with a comment on every column. + +Individual events are **deleted after 90 days**. What outlives them is anonymous daily +totals: counts per day of things like operating system, version, and language, plus which +days each machine ID was active so returning-user numbers survive. No event details, and +still nothing that identifies a person or a codebase. The engineering contract behind all of this — including the rule that schema changes must update this page, the client, and the public endpoint in one PR — is in diff --git a/docs/design/telemetry.md b/docs/design/telemetry.md index c0263e7..4af2b58 100644 --- a/docs/design/telemetry.md +++ b/docs/design/telemetry.md @@ -1,8 +1,9 @@ # Anonymous usage telemetry -Status: implemented — ingest Worker (`telemetry-worker/`), client (`src/telemetry/`), -`codegraph telemetry` CLI, MCP + installer wiring, `TELEMETRY.md`. Pending: Worker deploy -+ DNS, release. +Status: implemented — client (`src/telemetry/`), `codegraph telemetry` CLI, MCP + installer +wiring, `TELEMETRY.md`, ingest Worker (`telemetry-worker/`) storing to its own Cloudflare D1 +database, nightly rollup + retention cron, and the admin dashboard Worker +(`telemetry-dashboard/`). Scope: public `codegraph` engine (CLI + MCP server + installer) CodeGraph is a local-first tool whose whole pitch is "your code never leaves your machine." @@ -26,7 +27,10 @@ Answer, in aggregate and anonymously: - **No source code, ever.** No file paths, file names, repo names, symbol names, query strings, search terms, or anything derived from the contents of an indexed project. -- No IP addresses (stripped at the edge; storage disabled at the backend too). +- No IP addresses — never read at the edge, and there is no downstream backend that could + see one. +- No third-party analytics vendor. Events are stored only in our own database; the ingest + Worker makes no outbound requests at all. - No hardware fingerprinting — the machine ID is a random UUID, not derived from anything. - No per-keystroke / per-call event stream — usage is aggregated locally into daily rollups before anything is sent. @@ -58,7 +62,7 @@ Common envelope on every batch (computed once per process): | `os` / `arch` | `darwin` / `arm64` | `process.platform` / `process.arch` | | `node_major` | `22` | major only | | `ci` | `false` | `CI` env var present | -| `schema_version` | `1` | bump when the schema changes | +| `schema_version` | `2` | bump when the schema changes (v2 dropped `index.sqlite_backend`) | Event types: @@ -70,8 +74,8 @@ Event types: - **`usage_rollup`** — the workhorse. One event per `(day, kind, name)` per machine, aggregated locally. Props: `kind` (`mcp_tool`/`cli_command`), `name` (e.g. `codegraph_explore`, `affected`), `count`, `error_count`, and for MCP: - `client_name`/`client_version` from the `initialize` handshake (`src/mcp/session.ts` - `case 'initialize'` — plumbing to add; currently unread). + `client_name`/`client_version` captured from the `initialize` handshake + (`src/mcp/session.ts`) and passed through on every `recordUsage` call. The prompt hook additionally rolls up its gate DECISION as `cli_command` counters named `prompt-hook-gate-`, outcome ∈ `high-keyword` / `high-token` / `medium-segment` / `nudge-projects` / `noop-shape` / @@ -87,13 +91,23 @@ Event types: rather than polluting `noop-unverified` (#1142). - **`uninstall`** — one per `uninstall`/`uninit` run (churn signal). Props: `targets`. -Volume math: rollups mean monthly events ≈ active machines × active days × distinct -tools used (single digits) — the PostHog free tier (1M events/mo) covers tens of -thousands of MAU. There is no per-call event by design. +One legacy field is still *accepted* and belongs in the mirror even though nothing sends +it: `sqlite_backend` (`native`/`wasm`) on `install` and `index`. Pre-schema-v2 clients +(≤ June 2026) sent it; `node:sqlite` is the only backend now, so current clients omit it. +It is never `required`, and it is safe to drop from the Worker once those clients' +share is negligible. -Events are sent as PostHog **anonymous events** (`$process_person_profile: false`): -cheaper, no person profiles, unique-machine counts still work on `distinct_id` = -`machine_id`. Revisit only if retention tooling demands profiles. +Volume math: rollups mean monthly events ≈ active machines × active days × distinct tools +used (single digits) — there is no per-call event by design. At ~97k accepted POSTs/day +that is ≈30M D1 row writes/month against the 50M included on **Workers Paid**, roughly +doubling to ≈48M once the retention purge reaches steady state (a delete bills like an +insert). Storage is the binding constraint, not writes: raw events grow ≈74 MB/day, so the +90-day window lands at ≈6.7 GB against D1's 10 GB per-database cap — which is what sets the +window. Full arithmetic and the remaining levers are in the migration's footer comment. + +There are no person profiles to opt out of: `machine_id` is the only identifier that exists +anywhere in the system, it is a client-minted random UUID, and unique-machine counts are +computed from it directly in SQL. ## Consent & controls @@ -166,15 +180,59 @@ public on purpose, so anyone can audit exactly what the endpoint stores. It ship with the npm package (excluded by the `files` allowlist): - `POST /v1/events`: validate against the event/property allowlist (drop unknown events, - strip unknown props), enforce sane sizes, **never forward or log the client IP** - (drop `CF-Connecting-IP`), light per-`machine_id` rate limit so abuse can't burn the - ingest cap, forward to `https://us.i.posthog.com/batch/` with the project key from a - Worker secret. Responds `204` on accept (including events dropped by the allowlist) - and honest `4xx` for malformed/oversized/rate-limited requests — the client treats - every response as final and never retries. -- Backend today: PostHog Cloud US, free plan, "discard client IP" enabled, GeoIP disabled, - autocapture/replay/heatmaps/web-vitals all off. The Worker is the seam: swapping the - backend later is a Worker change, not a client release. + strip unknown props), enforce sane sizes, **never read or log the client IP**, light + per-`machine_id` rate limit so abuse can't burn the ingest cap, then write the survivors + to D1. Responds `204` on accept (including events dropped by the allowlist) and honest + `4xx` for malformed/oversized/rate-limited requests — the client treats every response + as final and never retries. +- **Storage: our own Cloudflare D1 database** (`codegraph-telemetry`, bound as `env.DB`). + The Worker makes **no outbound requests** — nothing is forwarded to a third-party + analytics vendor, so there is no vendor-side privacy setting to get wrong and no second + copy of the data anywhere. The complete stored schema is + [`telemetry-worker/migrations/0001_init.sql`](../../telemetry-worker/migrations/0001_init.sql), + checked in for the same reason the Worker's source is public. +- The write is off the response path (`ctx.waitUntil`, one `batch()` = one transaction) and + deliberately **fail-silent**: a D1 error is logged as counts only, never the payload, and + the client still gets its `204`. Clients never retry, so losing a datapoint beats losing + availability. +- **Nightly cron (00:30 UTC, `src/rollup.ts`)** rolls each finished day into anonymous daily + counts (`daily_machines`, `daily_event_counts`, `daily_dim_counts`) and re-runs the two + days before it, since offline clients ship completed-day rollups late. Aggregation is + `INSERT … SELECT … ON CONFLICT DO UPDATE` inside D1 — no event row crosses the wire, and + re-running a day is a no-op rather than a double count. The same job **purges raw + `events` older than `RETENTION_DAYS`** (90; a var in `wrangler.jsonc`). Rollups and + `machine_days`/`machine_first_seen` are kept forever, so shortening the window costs + ad-hoc drill-back, never a chart. +- The Worker remains the seam: changing storage later is a Worker change, not a client + release. The client only ever knows the domain. + +Operational detail — deploy, migrations, the cron, the `POST /admin/rollup` backfill hatch, +and the D1 quota arithmetic — lives in +[`telemetry-worker/README.md`](../../telemetry-worker/README.md). + +## Admin dashboard (Cloudflare Worker) + +`stats.getcodegraph.com` → a second Worker at +[`telemetry-dashboard/`](../../telemetry-dashboard/) — the read side, and the reason +self-hosting the data costs us no analysis capability. Also public source, for the same +reason: the code that touches telemetry should be readable by the people it collects from. +Full documentation is [`telemetry-dashboard/README.md`](../../telemetry-dashboard/README.md). + +- **Same D1 database, read-only.** It never migrates and never writes; schema changes belong + to the ingest Worker. The two Workers are separate deployments that agree on a list of + dimension names by convention alone, which is exactly the seam + `telemetry-worker/scripts/smoke-cutover.sh` exists to cover — a mismatch there is silent, + showing up as a panel that reads zero forever rather than as an error. +- **Reads rollups, not raw events**, so a chart stays correct for days whose raw rows have + been purged. `/api/activation` is the one exception — "did this machine ever run an index" + is not a daily aggregate — so it reads raw `events` and is bounded by the retention window, + which it reports as `raw_events_from`. +- **Auth is a shared password and a signed cookie**, sized for exactly two people: + `ADMIN_PASSWORD` + `SESSION_SECRET` as Worker secrets, constant-time compare, HMAC-signed + cookie with no session store, everything except `/login` and `robots.txt` gated. Rotating + the password signs everyone out; that is the revocation story. +- This Worker *does* read the client IP, solely as a login rate-limit key, never stored or + logged — the one deliberate difference from the ingest Worker, which never reads it at all. ## codegraph-pro rule (do not lose this in upstream merges) @@ -187,9 +245,9 @@ CLAUDE.md and must survive every upstream merge. ## Rollout 1. This doc + repo-root `TELEMETRY.md` (user-facing field-by-field list) + README section. -2. Worker + DNS live first (so the first shipping client never 404s), PostHog dashboards: - weekly active machines, installs by target, usage by tool × client, version adoption, - languages indexed. +2. Worker + DNS live first (so the first shipping client never 404s), then the dashboard + Worker over the same D1: weekly active machines, installs by target, usage by + tool × client, version adoption, languages indexed. 3. Client module + config + `codegraph telemetry` subcommand + MCP `clientInfo` plumbing. 4. Installer toggle + first-run notice. CHANGELOG entry under `[Unreleased]` announcing telemetry, the default, and every off-switch. Release. diff --git a/telemetry-dashboard/.dev.vars.example b/telemetry-dashboard/.dev.vars.example new file mode 100644 index 0000000..1bb2f4b --- /dev/null +++ b/telemetry-dashboard/.dev.vars.example @@ -0,0 +1,7 @@ +# Copy to .dev.vars for local development (`npm run dev`) and so that +# `wrangler types` includes both secrets in the generated Env. +# The real values live only in the deployed secrets: +# wrangler secret put ADMIN_PASSWORD +# wrangler secret put SESSION_SECRET +ADMIN_PASSWORD="dev-password" +SESSION_SECRET="dev-session-secret-not-the-real-one" diff --git a/telemetry-dashboard/.gitignore b/telemetry-dashboard/.gitignore new file mode 100644 index 0000000..8dd024e --- /dev/null +++ b/telemetry-dashboard/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.wrangler/ +.dev.vars +# generated by `wrangler types` (npm run types) — includes .dev.vars keys +worker-configuration.d.ts +# copied out of node_modules by `npm run vendor` +public/vendor/ diff --git a/telemetry-dashboard/README.md b/telemetry-dashboard/README.md new file mode 100644 index 0000000..83d673e --- /dev/null +++ b/telemetry-dashboard/README.md @@ -0,0 +1,193 @@ +# codegraph telemetry dashboard + +The private admin view behind `stats.getcodegraph.com`. Its sibling +[`telemetry-worker/`](../telemetry-worker/) writes anonymous usage events into a D1 database; +this worker reads them back and draws the charts. Two people use it, so the auth is +deliberately the simplest thing that is actually safe: one shared password in a secret, and +a long-lived signed cookie. + +This directory is in the public repo for the same reason the ingest worker is — the code +that touches telemetry should be readable by the people it collects from. Nothing secret +lives here: the password and the cookie-signing key are deployment secrets, and the D1 +database ID is an identifier, not a credential. + +## What is gated + +Everything except the login page and `robots.txt`. `assets.run_worker_first` is `true` in +`wrangler.jsonc`, so Cloudflare hands *every* request to `src/index.ts` before the static +asset server sees it — the dashboard HTML, its JS, its CSS and the chart library are all +behind the session check, and a request without a valid cookie gets a redirect (pages) or a +`401` (`/api/*`). The login page is rendered inline by the worker rather than served from +`public/`, so the asset directory needs no "is this file public?" judgement calls. + +| Route | Auth | Notes | +|---|---|---| +| `GET /login` | public | Password form. Redirects to `/` if already signed in. | +| `POST /login` | public | Rate-limited per IP; sets the session cookie on success. | +| `POST /logout` | public | Clears the cookie. | +| `GET /robots.txt` | public | `Disallow: /`. | +| `GET /api/*` | required | JSON. `401` without a session. See the API below. | +| everything else | required | Static assets from `public/`. `302 /login` without a session. | + +## The API + +Every endpoint is `GET`, session-gated, and scoped by `?from=YYYY-MM-DD&to=YYYY-MM-DD` +(inclusive, UTC days). Ranges wider than 366 days are clamped and say so in +`range.clamped`. Responses come back Chart.js-shaped — `labels[] + datasets[]` — plus a +`rows[]` in the data's natural shape, which is what each panel's "Show numbers" table +renders. Bad input is a `400` with a message, never a guess. Chart data carries +`Cache-Control: private, max-age=300`. + +| Endpoint | Answers | +|---|---| +| `/api/meta` | The days data actually exists for. The picker anchors its presets on `latest_day` so no chart ends on a day the nightly rollup has not written yet. | +| `/api/summary` | Big numbers: production users, active machines, new machines, installs, uninstalls, indexing runs, tool calls. | +| `/api/timeseries?metric=` | `installs_uninstalls`, `new_installs`, `production_users`, `indexing_activity`, `tool_calls`, `duration_buckets`. One dense point per day — a day with nothing is a zero, not a gap. | +| `/api/breakdown?dim=` | `os`, `arch`, `codegraph_version`, `node_major`, `language`, `file_count_bucket`, `duration_bucket`, `target`, `scope`, `kind`, `name`, `client_name`, `name_error`. Optional `&event=`, `&metric=count\|machines`, `&limit=`. | +| `/api/activation?window=7` | Install → first index funnel, plus the daily rate. | +| `/api/retention` | Day 0–14 cohort curve for machines first seen in the range. | +| `/api/health` | Liveness plus the latest event/rollup day. Uncached. | + +Everything reads the `daily_*` rollups and `machine_days`, which are kept forever, so a +chart stays correct for days whose raw events have been purged. `/api/activation` is the +one exception — "did this machine ever run an index" is not a daily aggregate — so it +reads raw `events` and is bounded by the ingest worker's retention window. It reports +`raw_events_from` for that reason. + +### Two numbers that are easy to misread + +Both are labelled honestly in the UI rather than rounded off into something friendlier: + +- **Machine-days, not users.** `daily_dim_counts.machines` is per day, so summing it over + a range counts a machine once per day it was active. A range-wide distinct count per + dimension value is not recoverable from the rollups at all, so the panels that use it + say "machine-days" and are share-of-total panels where the distinction does not move the + shape. Where a dimension rides several event types, the per-day figure is the largest + single-event count rather than their sum, so one machine's install + index + usage on + one day is not counted three times. +- **Recent cohorts have not finished converting.** A machine that installed yesterday has + not had seven days to run an index, so the tail of the activation curve is a floor, not + a result. The API marks those days (`complete: false`, `incomplete_from`) and the panel + says so instead of drawing a cliff and calling it a drop in conversion. Retention does + the same thing with a per-day denominator: day *k* is measured only over the machines + that have actually had *k* days to come back. + +## How the session works + +- The password is compared in constant time, over SHA-256 digests so the operands are always + the same length and nothing about the secret leaks through timing. +- The cookie is a signed assertion — `base64url(payload).base64url(HMAC-SHA256)` — not a + lookup key. There is no session store; a tampered payload fails the signature check. +- `HttpOnly; Secure; SameSite=Lax; Path=/`, `Max-Age` one year. You sign in once per browser + and it survives restarts. +- The payload carries a fingerprint of the password it was minted against, so + **rotating `ADMIN_PASSWORD` signs everyone out** — that is the revocation story. +- Login attempts are capped at 5/min per IP. Unlike the ingest worker, which never reads the + client IP at all, this one does — solely as a rate-limit key, never stored or logged. + +## Deploy + +Prereqs: the `getcodegraph.com` zone on the deploying Cloudflare account (the custom domain +auto-provisions DNS + cert), and the D1 database from `telemetry-worker/` already created. + +```bash +cd telemetry-dashboard +npm install +npx wrangler login # once + +npx wrangler secret put ADMIN_PASSWORD # the shared password +npx wrangler secret put SESSION_SECRET # cookie-signing key, e.g. `openssl rand -base64 48` + +npm run deploy +``` + +Both secrets are required — the worker refuses every request if either is missing, so a +half-configured deployment fails closed rather than becoming an open dashboard. + +Rotating either one is a `wrangler secret put` away. Rotating `SESSION_SECRET` invalidates +outstanding cookies too, and is the right move if you think one leaked. + +Migrations belong to the writer, not to this worker: apply schema changes from +`telemetry-worker/` (`npm run db:migrate`). D1 is read-only here. + +## Local dev & checks + +```bash +cp .dev.vars.example .dev.vars # placeholder secrets; also feeds `wrangler types` +npm run check # vendor + wrangler types + tsc --noEmit + deploy --dry-run +npm run seed # load scripts/fixture.sql into the LOCAL D1 +npm run dev # http://localhost:8787 + +npm run smoke:auth # the auth gate (54 assertions) +npm run smoke:api # the SQL and its numbers (98 assertions) +npm run smoke:render # the panels, in a browser (79 assertions) +``` + +Each suite starts its own throwaway `wrangler dev` on its own port and cleans up after +itself, so they can be run in any order (`DASH_PORT` overrides the port). + +**`smoke-auth.sh`** is the regression net for the gate: unauthenticated requests reach +nothing (pages, API *and* static assets), the cookie is persistent and correctly flagged, +flipped/truncated/forged cookies are all rejected, brute force is capped, and rotating the +password invalidates existing sessions. Run it after touching `src/auth.ts` or the route +table in `src/index.ts`. + +**`smoke-api.sh`** checks every endpoint against `scripts/fixture.sql` — twelve machines +over ten days, listed machine by machine in that file's header, small enough that every +expected number was worked out by hand rather than recorded from a passing run. It also +covers the boring half: bad dims, malformed dates, backwards ranges and over-wide ranges. + +**`render-check.mjs`** loads the real page in whatever Chromium is already on the machine +(over the DevTools protocol — no new dependency; it *skips* if there is no browser) and +reads the live Chart.js instance behind each canvas, comparing what every panel plotted +against the same endpoint fetched from Node. That is what catches a panel wired to the +wrong dimension, which neither of the other two suites can see. It also drives the range +picker and asserts a clean console, so a CSP regression fails the build. +`RENDER_SHOT=/tmp/dash.png npm run smoke:render` writes a full-page screenshot — the only +way to check the things assertions cannot, like label collisions. + +## Frontend + +Plain static files in `public/` — one HTML page, ES modules, no framework, no build step. + +| File | Holds | +|---|---| +| `index.html` | The shell: masthead, the one filter row, an empty grid. | +| `panels.js` | The panel registry — data in, chart config out, no DOM. Adding a panel is one entry. | +| `theme.js` | Palette, formatters, and the Chart.js defaults every panel inherits. | +| `app.js` | The page: range picker, one fetch per panel, loading/empty/error states. | + +The split is what lets `render-check.mjs` import the *same* registry the browser just +rendered from, so its expectations cannot drift from the panels under test. + +Panels fail alone: each fetches, draws and reports independently, so a failed query leaves +the other eighteen on screen. There is no client-side cache — the only reuse is +deduplicating identical URLs within a single render (four stat tiles share one +`/api/summary`), and that map is discarded afterwards, so refresh really does re-ask. +A refetch dims the previous render rather than tearing it down, so nothing jumps. Every +chart has a "Show numbers" table twin, which is what keeps a value from being reachable +only by hovering. + +### Colours + +Two scales, both run through the data-viz validator against this dashboard's actual chart +surface (`#ffffff`, the panel fill) rather than picked by eye — the exact results are +recorded at the top of `theme.js`: + +- **Categorical** `#a8342a #2a6f9e #17916a #c98500` — identity (which series). Slot 1 is + the brand oxblood stepped up into the legible lightness band. Clears every gate + including all-pairs colour-vision separation, with no contrast relief needed. +- **Ordinal** `#d99a90 #c26a5c #a3423a #7a201a` — one hue, light to dark, for scales whose + order *is* their meaning (run length, codebase size), so the ordering is visible in the + colour instead of needing the legend. + +Nominal bars all take slot 1: colouring them by value would spend the identity channel +re-encoding what bar length already shows. If you change a hex, re-run the validator — the +red/green pair that "looks fine" is the one that collapses under deuteranopia. +Workers Static Assets serves them verbatim, so third-party libraries are copied out of +`node_modules` into `public/vendor/` by `npm run vendor` (wired into `dev` and `deploy`). +That keeps the version pinned by the lockfile, avoids a third-party origin at runtime, and +lets the CSP stay `script-src 'self'`. `public/vendor/` is gitignored — it is build output. + +Visual conventions follow the rest of codegraph: flat and editorial, square corners, hairline +rules, sentence-case headings, one oxblood accent, no tiny all-caps tracked labels. diff --git a/telemetry-dashboard/package-lock.json b/telemetry-dashboard/package-lock.json new file mode 100644 index 0000000..25d5ce5 --- /dev/null +++ b/telemetry-dashboard/package-lock.json @@ -0,0 +1,1577 @@ +{ + "name": "codegraph-telemetry-dashboard", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codegraph-telemetry-dashboard", + "devDependencies": { + "chart.js": "^4.4.0", + "typescript": "^5.0.0", + "wrangler": "^4.36.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260722.1.tgz", + "integrity": "sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260722.1.tgz", + "integrity": "sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260722.1.tgz", + "integrity": "sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260722.1.tgz", + "integrity": "sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260722.1.tgz", + "integrity": "sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260722.1", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260722.1.tgz", + "integrity": "sha512-FJIg4omaCb2wwSyOeRosEdmVRi3JzGAOOH3pa3twmmtvWECl6BVZMIDwJbjByLKRyU+mrfk0M/n3oSiojFZvSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.28.0", + "workerd": "1.20260722.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260722.1.tgz", + "integrity": "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260722.1", + "@cloudflare/workerd-darwin-arm64": "1.20260722.1", + "@cloudflare/workerd-linux-64": "1.20260722.1", + "@cloudflare/workerd-linux-arm64": "1.20260722.1", + "@cloudflare/workerd-windows-64": "1.20260722.1" + } + }, + "node_modules/wrangler": { + "version": "4.115.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.115.0.tgz", + "integrity": "sha512-+upG2VW66M1sjb43yzgUZ6Ss8iYpJ6+7F3U4GF8TY5EYd+08sYdS54d24AEwCnhuef3J9KlSPusqiqR9WYy1UA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "4.20260722.1", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260722.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260722.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/telemetry-dashboard/package.json b/telemetry-dashboard/package.json new file mode 100644 index 0000000..de34e8f --- /dev/null +++ b/telemetry-dashboard/package.json @@ -0,0 +1,22 @@ +{ + "name": "codegraph-telemetry-dashboard", + "private": true, + "type": "module", + "description": "Password-gated admin dashboard over the codegraph telemetry D1 database (stats.getcodegraph.com)", + "scripts": { + "vendor": "node scripts/vendor-assets.mjs", + "dev": "npm run vendor && wrangler dev", + "deploy": "npm run vendor && wrangler deploy", + "types": "wrangler types", + "check": "npm run vendor && wrangler types && tsc --noEmit && wrangler deploy --dry-run", + "seed": "./scripts/seed-fixture.sh", + "smoke:auth": "./scripts/smoke-auth.sh", + "smoke:api": "./scripts/smoke-api.sh", + "smoke:render": "npm run vendor && node scripts/render-check.mjs" + }, + "devDependencies": { + "chart.js": "^4.4.0", + "typescript": "^5.0.0", + "wrangler": "^4.36.0" + } +} diff --git a/telemetry-dashboard/public/app.js b/telemetry-dashboard/public/app.js new file mode 100644 index 0000000..2c7f3a5 --- /dev/null +++ b/telemetry-dashboard/public/app.js @@ -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(); +} diff --git a/telemetry-dashboard/public/index.html b/telemetry-dashboard/public/index.html new file mode 100644 index 0000000..c573420 --- /dev/null +++ b/telemetry-dashboard/public/index.html @@ -0,0 +1,52 @@ + + + + + + codegraph telemetry + + + +
+
+

codegraph telemetry

+

Anonymous usage from the public engine, straight out of D1.

+
+
+ +
+
+ + +
+
+ +
+ + + + + +
+ +
+ +
+ +

+ Loading… + + + + +

+
+ +
+ + + + + diff --git a/telemetry-dashboard/public/panels.js b/telemetry-dashboard/public/panels.js new file mode 100644 index 0000000..120d8b4 --- /dev/null +++ b/telemetry-dashboard/public/panels.js @@ -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, + }, +]; diff --git a/telemetry-dashboard/public/styles.css b/telemetry-dashboard/public/styles.css new file mode 100644 index 0000000..73ec975 --- /dev/null +++ b/telemetry-dashboard/public/styles.css @@ -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); +} diff --git a/telemetry-dashboard/public/theme.js b/telemetry-dashboard/public/theme.js new file mode 100644 index 0000000..14c5836 --- /dev/null +++ b/telemetry-dashboard/public/theme.js @@ -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' }; diff --git a/telemetry-dashboard/scripts/fixture.sql b/telemetry-dashboard/scripts/fixture.sql new file mode 100644 index 0000000..108df44 --- /dev/null +++ b/telemetry-dashboard/scripts/fixture.sql @@ -0,0 +1,208 @@ +-- Seed data for the dashboard's local checks: 12 machines over 10 days +-- (2026-07-01 … 2026-07-10), small enough that every number on every panel can +-- be worked out by hand from the events below and checked against the API. +-- +-- npm run seed (writes the LOCAL .wrangler D1 — never the remote one) +-- +-- Only the raw `events` rows are hand-authored. `machine_days`, +-- `machine_first_seen` and the three `daily_*` rollups are DERIVED from them at +-- the bottom of this file by the same aggregations the writers use in +-- telemetry-worker/ (the ingest path and the nightly cron respectively), so the +-- fixture can never drift into a state production could not produce. +-- +-- The machines, and what each one does: +-- +-- id first os arch ver ci installs indexes on uninstalls +-- m01 07-01 darwin arm64 1.4.0 0 local 07-01, 07-02, 07-04 +-- m02 07-01 darwin arm64 1.4.0 0 global 07-01 +-- m03 07-01 linux x64 1.4.0 0 local 07-03 +-- m04 07-01 win32 x64 1.4.0 0 local never 07-06 +-- m05 07-02 darwin arm64 1.4.0 0 local 07-02 +-- m06 07-02 linux x64 1.4.1 0 local never 07-07 +-- m07 07-03 darwin x64 1.4.1 0 local 07-03 +-- m08 07-05 linux arm64 1.5.0 0 global 07-06 +-- m09 07-05 win32 x64 1.5.0 0 local 07-05, 07-07 +-- m10 07-08 darwin arm64 1.5.0 0 local 07-08 +-- m11 07-09 linux x64 1.5.0 0 local 07-10 +-- m12 07-09 linux x64 1.5.0 1 global 07-09 (CI runner) +-- +-- m04 and m06 never index: they are the two machines the activation funnel is +-- supposed to lose (12 installs → 10 activated → 83.3%). m12 is the one CI +-- machine, so "production users" is 11 where "active machines" is 12. + +DELETE FROM daily_dim_counts; +DELETE FROM daily_event_counts; +DELETE FROM daily_machines; +DELETE FROM machine_days; +DELETE FROM machine_first_seen; +DELETE FROM events; + +-- --------------------------------------------------------------------------- +-- install — 12, one per machine on its first day +-- --------------------------------------------------------------------------- +INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props) +VALUES + ('2026-07-01T09:00:00Z','2026-07-01T09:00:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude","cursor"]}'), + ('2026-07-01T09:05:00Z','2026-07-01T09:05:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000002','1.4.0','darwin','arm64',22,0,2,'{"scope":"global","kind":"fresh","targets":["claude"]}'), + ('2026-07-01T10:00:00Z','2026-07-01T10:00:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000003','1.4.0','linux','x64',20,0,2,'{"scope":"local","kind":"fresh","targets":["codex"]}'), + ('2026-07-01T11:00:00Z','2026-07-01T11:00:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000004','1.4.0','win32','x64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude","opencode"]}'), + ('2026-07-02T09:00:00Z','2026-07-02T09:00:00Z','2026-07-02','install','00000000-0000-4000-8000-000000000005','1.4.0','darwin','arm64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude"]}'), + ('2026-07-02T14:00:00Z','2026-07-02T14:00:00Z','2026-07-02','install','00000000-0000-4000-8000-000000000006','1.4.1','linux','x64',20,0,2,'{"scope":"local","kind":"fresh","targets":["cursor"]}'), + ('2026-07-03T08:00:00Z','2026-07-03T08:00:00Z','2026-07-03','install','00000000-0000-4000-8000-000000000007','1.4.1','darwin','x64',22,0,2,'{"scope":"local","kind":"upgrade","targets":["claude"]}'), + ('2026-07-05T08:00:00Z','2026-07-05T08:00:00Z','2026-07-05','install','00000000-0000-4000-8000-000000000008','1.5.0','linux','arm64',22,0,2,'{"scope":"global","kind":"fresh","targets":["claude","codex"]}'), + ('2026-07-05T09:00:00Z','2026-07-05T09:00:00Z','2026-07-05','install','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude"]}'), + ('2026-07-08T08:00:00Z','2026-07-08T08:00:00Z','2026-07-08','install','00000000-0000-4000-8000-000000000010','1.5.0','darwin','arm64',22,0,2,'{"scope":"local","kind":"fresh","targets":["cursor"]}'), + ('2026-07-09T08:00:00Z','2026-07-09T08:00:00Z','2026-07-09','install','00000000-0000-4000-8000-000000000011','1.5.0','linux','x64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude"]}'), + ('2026-07-09T08:30:00Z','2026-07-09T08:30:00Z','2026-07-09','install','00000000-0000-4000-8000-000000000012','1.5.0','linux','x64',22,1,2,'{"scope":"global","kind":"fresh","targets":["claude"]}'); + +-- --------------------------------------------------------------------------- +-- index — 13 runs +-- languages typescript 7 · javascript 2 · python 2 · go 2 · rust 2 · csharp 2 · java 1 (18 rows) +-- file_count_bucket <100 2 · 100-1k 5 · 1k-10k 4 · 10k+ 2 +-- duration_bucket <10s 5 · 10-60s 4 · 1-5m 2 · 5m+ 2 +-- --------------------------------------------------------------------------- +INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props) +VALUES + ('2026-07-01T09:10:00Z','2026-07-01T09:10:00Z','2026-07-01','index','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript","javascript"],"file_count_bucket":"100-1k","duration_bucket":"<10s"}'), + ('2026-07-01T09:20:00Z','2026-07-01T09:20:00Z','2026-07-01','index','00000000-0000-4000-8000-000000000002','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript"],"file_count_bucket":"<100","duration_bucket":"<10s"}'), + ('2026-07-02T10:00:00Z','2026-07-02T10:00:00Z','2026-07-02','index','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript","javascript"],"file_count_bucket":"100-1k","duration_bucket":"10-60s"}'), + ('2026-07-02T11:00:00Z','2026-07-02T11:00:00Z','2026-07-02','index','00000000-0000-4000-8000-000000000005','1.4.0','darwin','arm64',22,0,2,'{"languages":["python"],"file_count_bucket":"1k-10k","duration_bucket":"10-60s"}'), + ('2026-07-03T09:00:00Z','2026-07-03T09:00:00Z','2026-07-03','index','00000000-0000-4000-8000-000000000003','1.4.0','linux','x64',20,0,2,'{"languages":["go"],"file_count_bucket":"100-1k","duration_bucket":"<10s"}'), + ('2026-07-03T10:00:00Z','2026-07-03T10:00:00Z','2026-07-03','index','00000000-0000-4000-8000-000000000007','1.4.1','darwin','x64',22,0,2,'{"languages":["typescript","rust"],"file_count_bucket":"10k+","duration_bucket":"5m+"}'), + ('2026-07-04T10:00:00Z','2026-07-04T10:00:00Z','2026-07-04','index','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript"],"file_count_bucket":"100-1k","duration_bucket":"<10s"}'), + ('2026-07-05T09:30:00Z','2026-07-05T09:30:00Z','2026-07-05','index','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"languages":["csharp"],"file_count_bucket":"1k-10k","duration_bucket":"1-5m"}'), + ('2026-07-06T09:00:00Z','2026-07-06T09:00:00Z','2026-07-06','index','00000000-0000-4000-8000-000000000008','1.5.0','linux','arm64',22,0,2,'{"languages":["rust","go"],"file_count_bucket":"1k-10k","duration_bucket":"1-5m"}'), + ('2026-07-07T09:00:00Z','2026-07-07T09:00:00Z','2026-07-07','index','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"languages":["csharp"],"file_count_bucket":"1k-10k","duration_bucket":"10-60s"}'), + ('2026-07-08T08:10:00Z','2026-07-08T08:10:00Z','2026-07-08','index','00000000-0000-4000-8000-000000000010','1.5.0','darwin','arm64',22,0,2,'{"languages":["typescript"],"file_count_bucket":"<100","duration_bucket":"<10s"}'), + ('2026-07-09T09:00:00Z','2026-07-09T09:00:00Z','2026-07-09','index','00000000-0000-4000-8000-000000000012','1.5.0','linux','x64',22,1,2,'{"languages":["java"],"file_count_bucket":"10k+","duration_bucket":"5m+"}'), + ('2026-07-10T09:00:00Z','2026-07-10T09:00:00Z','2026-07-10','index','00000000-0000-4000-8000-000000000011','1.5.0','linux','x64',22,0,2,'{"languages":["python","typescript"],"file_count_bucket":"100-1k","duration_bucket":"10-60s"}'); + +-- --------------------------------------------------------------------------- +-- uninstall — 2 +-- --------------------------------------------------------------------------- +INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props) +VALUES + ('2026-07-06T12:00:00Z','2026-07-06T12:00:00Z','2026-07-06','uninstall','00000000-0000-4000-8000-000000000004','1.4.0','win32','x64',22,0,2,'{"targets":["claude","opencode"]}'), + ('2026-07-07T12:00:00Z','2026-07-07T12:00:00Z','2026-07-07','uninstall','00000000-0000-4000-8000-000000000006','1.4.1','linux','x64',20,0,2,'{"targets":["cursor"]}'); + +-- --------------------------------------------------------------------------- +-- usage_rollup — 5 rows, 85 calls (the `count` prop is summed, never the rows) +-- codegraph_explore 82 · index 3 | Claude Code 70 · Cursor 12 +-- --------------------------------------------------------------------------- +INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props) +VALUES + ('2026-07-03T02:00:00Z','2026-07-02T12:00:00Z','2026-07-02','usage_rollup','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":40,"error_count":1,"client_name":"Claude Code"}'), + ('2026-07-04T02:00:00Z','2026-07-03T12:00:00Z','2026-07-03','usage_rollup','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":25,"client_name":"Claude Code"}'), + ('2026-07-04T02:00:00Z','2026-07-03T12:00:00Z','2026-07-03','usage_rollup','00000000-0000-4000-8000-000000000005','1.4.0','darwin','arm64',22,0,2,'{"kind":"cli_command","name":"index","count":3}'), + ('2026-07-07T02:00:00Z','2026-07-06T12:00:00Z','2026-07-06','usage_rollup','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":12,"client_name":"Cursor"}'), + ('2026-07-11T02:00:00Z','2026-07-10T12:00:00Z','2026-07-10','usage_rollup','00000000-0000-4000-8000-000000000011','1.5.0','linux','x64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":5,"client_name":"Claude Code"}'); + +-- --------------------------------------------------------------------------- +-- Derived: what the ingest worker writes on every batch +-- --------------------------------------------------------------------------- +-- prod is 0 only when EVERY event a machine sent that day carried ci = 1, which +-- is what makes m12 the only non-production machine-day. +INSERT INTO machine_days (machine_id, day, prod) +SELECT machine_id, day, max(CASE WHEN ci = 1 THEN 0 ELSE 1 END) FROM events GROUP BY machine_id, day; + +INSERT INTO machine_first_seen (machine_id, first_day) +SELECT machine_id, min(day) FROM events GROUP BY machine_id; + +-- --------------------------------------------------------------------------- +-- Derived: what the nightly cron writes +-- --------------------------------------------------------------------------- +-- These mirror ROLLUP_STATEMENTS in telemetry-worker/src/rollup.ts, with the +-- single-day filter dropped so one pass seeds the whole fixture range. + +INSERT INTO daily_machines (day, machines, prod_machines) +SELECT day, count(*), coalesce(sum(prod), 0) FROM machine_days GROUP BY day; + +INSERT INTO daily_event_counts (day, event, count, machines) +SELECT day, event, + CASE WHEN event = 'usage_rollup' + THEN sum(coalesce(json_extract(props, '$.count'), 0)) + ELSE count(*) END, + count(DISTINCT machine_id) + FROM events GROUP BY day, event; + +-- Envelope dimensions — carried by every event. +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'os', CAST(os AS TEXT), + CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END, + count(DISTINCT machine_id) + FROM events WHERE os IS NOT NULL AND os <> '' GROUP BY day, event, os; + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'arch', CAST(arch AS TEXT), + CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END, + count(DISTINCT machine_id) + FROM events WHERE arch IS NOT NULL AND arch <> '' GROUP BY day, event, arch; + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'codegraph_version', CAST(codegraph_version AS TEXT), + CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END, + count(DISTINCT machine_id) + FROM events WHERE codegraph_version IS NOT NULL AND codegraph_version <> '' GROUP BY day, event, codegraph_version; + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'node_major', CAST(node_major AS TEXT), + CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END, + count(DISTINCT machine_id) + FROM events WHERE node_major IS NOT NULL GROUP BY day, event, node_major; + +-- Event-specific scalar props. +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'file_count_bucket', CAST(json_extract(props, '$.file_count_bucket') AS TEXT), count(*), count(DISTINCT machine_id) + FROM events WHERE event = 'index' AND json_extract(props, '$.file_count_bucket') IS NOT NULL + GROUP BY day, event, json_extract(props, '$.file_count_bucket'); + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'duration_bucket', CAST(json_extract(props, '$.duration_bucket') AS TEXT), count(*), count(DISTINCT machine_id) + FROM events WHERE event = 'index' AND json_extract(props, '$.duration_bucket') IS NOT NULL + GROUP BY day, event, json_extract(props, '$.duration_bucket'); + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'scope', CAST(json_extract(props, '$.scope') AS TEXT), count(*), count(DISTINCT machine_id) + FROM events WHERE event = 'install' AND json_extract(props, '$.scope') IS NOT NULL + GROUP BY day, event, json_extract(props, '$.scope'); + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'kind', CAST(json_extract(props, '$.kind') AS TEXT), + CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END, + count(DISTINCT machine_id) + FROM events WHERE event IN ('install', 'usage_rollup') AND json_extract(props, '$.kind') IS NOT NULL + GROUP BY day, event, json_extract(props, '$.kind'); + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'name', CAST(json_extract(props, '$.name') AS TEXT), + sum(coalesce(json_extract(props, '$.count'), 0)), count(DISTINCT machine_id) + FROM events WHERE event = 'usage_rollup' AND json_extract(props, '$.name') IS NOT NULL + GROUP BY day, event, json_extract(props, '$.name'); + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'client_name', CAST(json_extract(props, '$.client_name') AS TEXT), + sum(coalesce(json_extract(props, '$.count'), 0)), count(DISTINCT machine_id) + FROM events WHERE event = 'usage_rollup' AND json_extract(props, '$.client_name') IS NOT NULL + GROUP BY day, event, json_extract(props, '$.client_name'); + +-- Array props — one row per element, so a TypeScript+Go repo counts under both. +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT e.day, e.event, 'language', CAST(j.value AS TEXT), count(*), count(DISTINCT e.machine_id) + FROM events e, json_each(e.props, '$.languages') j + WHERE e.event = 'index' AND j.value <> '' + GROUP BY e.day, e.event, j.value; + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT e.day, e.event, 'target', CAST(j.value AS TEXT), count(*), count(DISTINCT e.machine_id) + FROM events e, json_each(e.props, '$.targets') j + WHERE e.event IN ('install', 'uninstall') AND j.value <> '' + GROUP BY e.day, e.event, j.value; + +-- Errors per tool: count is errors, machines is the machines that saw one. +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'name_error', CAST(json_extract(props, '$.name') AS TEXT), + sum(json_extract(props, '$.error_count')), count(DISTINCT machine_id) + FROM events + WHERE event = 'usage_rollup' AND json_extract(props, '$.name') IS NOT NULL + AND coalesce(json_extract(props, '$.error_count'), 0) > 0 + GROUP BY day, event, json_extract(props, '$.name'); diff --git a/telemetry-dashboard/scripts/render-check.mjs b/telemetry-dashboard/scripts/render-check.mjs new file mode 100644 index 0000000..9411bec --- /dev/null +++ b/telemetry-dashboard/scripts/render-check.mjs @@ -0,0 +1,465 @@ +#!/usr/bin/env node +/** + * Renders the dashboard in a real browser against the fixture and checks that + * every panel drew, and drew the numbers the API returned. + * + * smoke-api.sh proves the SQL; this proves the other half — that each panel is + * wired to the right endpoint and plots it without mangling it. It reads the + * Chart.js instance off each canvas and compares its dataset arrays against the + * same endpoint fetched straight from Node, so a panel pointed at the wrong dim + * fails here even though both halves are individually fine. + * + * node scripts/render-check.mjs (or: npm run smoke:render) + * + * Zero new dependencies: it drives whatever Chromium is already on the machine + * over the DevTools protocol (Node 22 has WebSocket built in). With no browser + * installed it SKIPS rather than fails — the shell smoke suites stay the + * portable floor, and this is the deeper check where a browser exists. + */ + +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const PORT = Number(process.env.DASH_PORT ?? 8790); +const BASE = `http://127.0.0.1:${PORT}`; + +/** The fixture's own window — see scripts/fixture.sql. */ +const FROM = '2026-07-01'; +const TO = '2026-07-10'; + +let pass = 0; +let fail = 0; + +const ok = (what) => { + console.log(` ok ${what}`); + pass++; +}; +const bad = (what, detail) => { + console.log(` FAIL ${what}${detail ? ` (${detail})` : ''}`); + fail++; +}; +const check = (what, condition, detail) => (condition ? ok(what) : bad(what, detail)); +const same = (what, expected, actual) => + check( + what, + JSON.stringify(expected) === JSON.stringify(actual), + `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// --------------------------------------------------------------------------- +// Finding a browser +// --------------------------------------------------------------------------- + +/** Expands one `*` in a path segment, newest match first. */ +function glob(pattern) { + const [head, ...rest] = pattern.split('*'); + const base = dirname(head); + const prefix = head.slice(base.length + 1); + if (!existsSync(base)) return []; + return readdirSync(base) + .filter((name) => name.startsWith(prefix)) + .sort() + .reverse() + .map((name) => join(base, name) + rest.join('*')); +} + +function findBrowser() { + const home = process.env.HOME ?? ''; + const candidates = [ + process.env.CHROME_BIN, + ...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-arm64/chrome-headless-shell`), + ...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-x64/chrome-headless-shell`), + ...glob(`${home}/.cache/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-linux/chrome-headless-shell`), + ...glob(`${home}/.cache/ms-playwright/chromium-*/chrome-linux/chrome`), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/google-chrome', + ]; + return candidates.find((path) => path && existsSync(path)) ?? null; +} + +// --------------------------------------------------------------------------- +// A minimal DevTools-protocol client +// --------------------------------------------------------------------------- + +class CDP { + constructor(socket) { + this.socket = socket; + this.nextId = 1; + this.pending = new Map(); + this.events = []; + socket.addEventListener('message', (event) => { + const message = JSON.parse(event.data); + if (message.id !== undefined) { + const waiter = this.pending.get(message.id); + if (!waiter) return; + this.pending.delete(message.id); + if (message.error) waiter.reject(new Error(message.error.message)); + else waiter.resolve(message.result); + } else { + this.events.push(message); + } + }); + } + + static async connect(url) { + const socket = new WebSocket(url); + await new Promise((resolve, reject) => { + socket.addEventListener('open', resolve, { once: true }); + socket.addEventListener('error', () => reject(new Error(`cannot reach ${url}`)), { once: true }); + }); + return new CDP(socket); + } + + send(method, params = {}, sessionId) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params })); + }); + } + + /** Runs an expression in the page and returns its value, awaiting promises. */ + async evaluate(sessionId, expression) { + const result = await this.send( + 'Runtime.evaluate', + { expression, returnByValue: true, awaitPromise: true }, + sessionId, + ); + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.exception?.description ?? 'page threw'); + } + return result.result.value; + } +} + +// --------------------------------------------------------------------------- +// The page probe +// --------------------------------------------------------------------------- + +/** + * Runs inside the page. Reads what each panel actually rendered — including the + * live Chart.js instance behind each canvas — rather than trusting that a + * fetch resolved. + */ +const PROBE = `(() => { + const panels = [...document.querySelectorAll('[data-panel]')].map((section) => { + const canvas = section.querySelector('canvas'); + const chart = canvas && window.Chart ? window.Chart.getChart(canvas) : null; + return { + id: section.dataset.panel, + state: section.dataset.state, + stale: section.dataset.stale, + title: section.querySelector('h2').textContent, + figure: section.querySelector('[data-role="figure"]').textContent, + note: section.querySelector('.panel-note')?.textContent ?? '', + message: section.querySelector('[data-role="state"]').textContent, + stat: section.querySelector('.stat-value')?.textContent ?? null, + funnelValues: [...section.querySelectorAll('.funnel-value')].map((n) => n.textContent), + funnelWidths: [...section.querySelectorAll('.funnel-fill')].map((n) => n.style.width), + chart: chart && { + type: chart.config.type, + labels: chart.data.labels, + datasets: chart.data.datasets.map((d) => ({ label: d.label, data: d.data })), + legend: chart.options.plugins?.legend?.display !== false, + }, + tableRows: section.querySelectorAll('[data-role="table"] tbody tr').length, + tableCols: section.querySelectorAll('[data-role="table"] thead th').length, + tableHidden: section.querySelector('[data-role="table"]').hidden, + }; + }); + return { + ready: document.body.dataset.ready === 'true', + range: document.getElementById('range-summary').textContent, + dataThrough: document.getElementById('data-through').textContent, + refreshed: document.getElementById('refreshed-at').textContent, + selectedPreset: document.querySelector('button.range.is-selected')?.textContent ?? null, + panels, + }; +})()`; + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- + +const children = []; +let profileDir = null; + +function cleanup() { + for (const child of children) { + try { + child.kill('SIGTERM'); + } catch { + /* already gone */ + } + } + if (profileDir) rmSync(profileDir, { recursive: true, force: true }); +} +process.on('exit', cleanup); +process.on('SIGINT', () => process.exit(130)); + +function run(command, args, options = {}) { + const child = spawn(command, args, { cwd: root, stdio: 'ignore', ...options }); + children.push(child); + return child; +} + +async function waitFor(what, probe, attempts = 90) { + for (let i = 0; i < attempts; i++) { + try { + if (await probe()) return true; + } catch { + /* not up yet */ + } + await sleep(1000); + } + throw new Error(`timed out waiting for ${what}`); +} + +async function main() { + const browserPath = findBrowser(); + if (!browserPath) { + console.log('render-check: no Chromium found — skipping.'); + console.log(' Set CHROME_BIN, or install Chrome; the shell smoke suites cover the rest.'); + return 0; + } + console.log(`Browser: ${browserPath}`); + + console.log('Seeding the local D1 fixture…'); + const seed = run('./scripts/seed-fixture.sh', [], { stdio: 'inherit' }); + const seeded = await new Promise((resolve) => seed.on('exit', resolve)); + if (seeded !== 0) throw new Error('seeding failed'); + + console.log(`Starting wrangler dev on :${PORT}…`); + run('npx', ['wrangler', 'dev', '--port', String(PORT), '--ip', '127.0.0.1']); + await waitFor('wrangler dev', async () => (await fetch(`${BASE}/robots.txt`)).ok); + + const password = readFileSync(join(root, '.dev.vars'), 'utf8').match(/^ADMIN_PASSWORD="(.*)"$/m)?.[1]; + if (!password) throw new Error('no ADMIN_PASSWORD in .dev.vars'); + const login = await fetch(`${BASE}/login`, { + method: 'POST', + body: new URLSearchParams({ password }), + redirect: 'manual', + }); + const cookie = login.headers.getSetCookie().find((c) => c.startsWith('cg_admin_session=')); + if (!cookie) throw new Error('login did not set a session cookie'); + const [name, value] = cookie.split(';')[0].split('='); + + profileDir = mkdtempSync(join(tmpdir(), 'cg-dash-profile-')); + // chrome-headless-shell is headless by construction and rejects the flag; + // a full Chrome needs it. + const headlessFlag = browserPath.includes('headless') ? [] : ['--headless=new']; + run(browserPath, [ + ...headlessFlag, + '--disable-gpu', + '--no-first-run', + '--no-default-browser-check', + '--remote-debugging-port=0', + `--user-data-dir=${profileDir}`, + 'about:blank', + ]); + + let devtoolsPort = null; + await waitFor('the browser', () => { + const portFile = join(profileDir, 'DevToolsActivePort'); + if (!existsSync(portFile)) return false; + devtoolsPort = Number(readFileSync(portFile, 'utf8').split('\n')[0]); + return Number.isFinite(devtoolsPort) && devtoolsPort > 0; + }, 30); + + const version = await (await fetch(`http://127.0.0.1:${devtoolsPort}/json/version`)).json(); + const cdp = await CDP.connect(version.webSocketDebuggerUrl); + const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }); + + await cdp.send('Page.enable', {}, sessionId); + await cdp.send('Runtime.enable', {}, sessionId); + await cdp.send('Log.enable', {}, sessionId); + await cdp.send('Network.enable', {}, sessionId); + await cdp.send('Network.setCookie', { url: BASE, name, value, path: '/', httpOnly: true }, sessionId); + + await cdp.send('Page.navigate', { url: `${BASE}/` }, sessionId); + await waitFor('the dashboard to finish rendering', async () => { + const view = await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"'); + return view === true; + }, 60); + + let view = await cdp.evaluate(sessionId, PROBE); + + // -- what loaded --------------------------------------------------------- + console.log('\nThe page renders'); + // The very same registry the page just rendered from, imported here so the + // expectations cannot drift from the panels under test. + const { PANELS } = await import(pathToFileURL(join(root, 'public', 'panels.js')).href); + check(`all ${PANELS.length} panels are on the page`, view.panels.length === PANELS.length, `got ${view.panels.length}`); + const broken = view.panels.filter((p) => p.state !== 'ready'); + check( + 'every panel reached its ready state', + broken.length === 0, + broken.map((p) => `${p.id}: ${p.state} ${p.message}`).join(' | '), + ); + check('the default range is the 30-day preset', view.selectedPreset === 'Last 30 days', view.selectedPreset); + check('the range is stated in the filter row', /Jun|Jul/.test(view.range), view.range); + check('the data horizon is stated', view.dataThrough.includes('Jul 10'), view.dataThrough); + check('the refresh time is stated', view.refreshed.startsWith('Last refreshed'), view.refreshed); + + // A CSP violation surfaces here as a `security` log entry, which is the point + // of the check: the page must work under `script-src 'self'` with no inline + // styles at all. The favicon 404 is expected — there isn't one — and is the + // only network noise allowed through. + const errors = cdp.events.filter( + (e) => + (e.method === 'Log.entryAdded' && + e.params.entry.level === 'error' && + !/favicon/.test(e.params.entry.url ?? '')) || + e.method === 'Runtime.exceptionThrown', + ); + check( + 'no console errors — the strict CSP allows everything the page needs', + errors.length === 0, + errors.map((e) => e.params.entry?.text ?? e.params.exceptionDetails?.text).join(' | '), + ); + + // -- the range picker really re-queries ---------------------------------- + console.log('\nChanging the range re-queries every panel'); + await cdp.evaluate( + sessionId, + `document.body.dataset.ready = ""; + [...document.querySelectorAll('button.range')].find((b) => b.textContent === 'Last 7 days').click();`, + ); + await waitFor('the 7-day render', async () => + (await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true, + ); + view = await cdp.evaluate(sessionId, PROBE); + const weekly = view.panels.find((p) => p.id === 'daily-production-users'); + check('a daily line now holds 7 points', weekly.chart?.labels.length === 7, `${weekly.chart?.labels.length}`); + check('the 7-day preset is marked selected', view.selectedPreset === 'Last 7 days', view.selectedPreset); + check('every panel re-rendered cleanly', view.panels.every((p) => p.state === 'ready')); + + console.log('\nA custom range works the same way'); + await cdp.evaluate( + sessionId, + `document.body.dataset.ready = ""; + document.querySelector('[data-role="custom-from"]').value = "${FROM}"; + document.querySelector('[data-role="custom-to"]').value = "${TO}"; + document.querySelector('[data-role="custom-apply"]').click();`, + ); + await waitFor('the custom-range render', async () => + (await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true, + ); + view = await cdp.evaluate(sessionId, PROBE); + check('the fixture window is 10 days', view.panels.find((p) => p.id === 'daily-production-users').chart?.labels.length === 10); + check('no preset stays highlighted', view.selectedPreset === null, view.selectedPreset); + + // -- every panel plots what the API returned ------------------------------ + console.log('\nEvery panel plots the API’s own numbers'); + const query = `from=${FROM}&to=${TO}`; + const fetched = new Map(); + const apiGet = async (path) => { + if (!fetched.has(path)) { + fetched.set( + path, + fetch(`${BASE}${path}`, { headers: { cookie: `${name}=${value}` } }).then((r) => r.json()), + ); + } + return fetched.get(path); + }; + + for (const panel of PANELS) { + const rendered = view.panels.find((p) => p.id === panel.id); + const data = await apiGet(panel.source(query)); + + if (panel.kind === 'chart') { + const plotted = rendered.chart?.datasets.map((d) => d.data); + same(`${panel.id}: plots the endpoint's series`, data.datasets.map((d) => d.data), plotted); + // A legend is owed wherever colour carries identity: any multi-series + // chart, and every pie (whose slices are identities inside one dataset). + // A single line needs none — the panel title already names it. + const owed = rendered.chart.type === 'pie' || data.datasets.length > 1; + check( + `${panel.id}: a legend exactly where colour carries identity`, + rendered.chart.legend === owed, + `legend ${rendered.chart.legend}, expected ${owed}`, + ); + } else if (panel.kind === 'stat') { + same(`${panel.id}: shows the endpoint's number`, panel.stat(data).value, rendered.stat); + } else if (panel.kind === 'funnel') { + same( + `${panel.id}: shows both funnel stages`, + panel.funnel(data).stages.map((s) => s.value.toLocaleString('en-US')), + rendered.funnelValues, + ); + } + + const table = panel.table(data); + check( + `${panel.id}: the table twin carries every row`, + rendered.tableRows === table.rows.length && rendered.tableCols === table.columns.length, + `${rendered.tableRows}×${rendered.tableCols} vs ${table.rows.length}×${table.columns.length}`, + ); + } + + // -- a few numbers checked against the fixture by hand -------------------- + console.log('\nSpot checks against the fixture, worked out by hand'); + const byId = Object.fromEntries(view.panels.map((p) => [p.id, p])); + same('production users is 11 (m12 is the CI machine)', '11', byId['production-users'].stat); + same('installs is 12', '12', byId['installs'].stat); + same('uninstalls is 2', '2', byId['uninstalls'].stat); + same('indexing runs is 13', '13', byId['indexing-runs'].stat); + same('the funnel loses m04 and m06', ['12', '10'], byId['activation-funnel'].funnelValues); + const widths = byId['activation-funnel'].funnelWidths; + check( + '…and draws the drop as a shorter bar', + widths[0] === '100%' && widths[1].startsWith('83.3'), + widths.join(' / '), + ); + same('the OS pie is machine-days', ['linux', 'darwin', 'win32'], byId.os.chart.labels); + same('…and its slices are 9 / 8 / 4', [[9, 8, 4]], byId.os.chart.datasets.map((d) => d.data)); + check('…with the honest metric named under the title', byId.os.figure === '21 machine-days', byId.os.figure); + same('run length keeps its bucket order', ['<10s', '10-60s', '1-5m', '5m+'], byId['run-length'].chart.labels); + same('languages lead with typescript', 'typescript', byId.languages.chart.labels[0]); + check('retention starts at 100%', byId.retention.chart.datasets[0].data[0] === 100); + + // Colour, spacing and label collisions are not things an assertion catches. + // RENDER_SHOT=/tmp/dash.png npm run smoke:render → look at it. + if (process.env.RENDER_SHOT) { + await cdp.send( + 'Emulation.setDeviceMetricsOverride', + { width: 1440, height: 900, deviceScaleFactor: 2, mobile: false }, + sessionId, + ); + await sleep(500); + const shot = await cdp.send( + 'Page.captureScreenshot', + { format: 'png', captureBeyondViewport: true }, + sessionId, + ); + writeFileSync(process.env.RENDER_SHOT, Buffer.from(shot.data, 'base64')); + console.log(`\nScreenshot written to ${process.env.RENDER_SHOT}`); + } + + console.log('\nPanel copy follows the house rules'); + const capsy = view.panels.filter((p) => /^[A-Z0-9 ]{4,}$/.test(p.title)); + check('no shouty panel titles', capsy.length === 0, capsy.map((p) => p.title).join(', ')); + check('every panel says what it is counting', view.panels.every((p) => p.note.length > 20)); + check('tables start closed', view.panels.every((p) => p.tableHidden)); + + return fail; +} + +try { + const failures = await main(); + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(failures === 0 ? 0 : 1); +} catch (err) { + console.error(`\nrender-check: ${err.message}`); + process.exit(1); +} diff --git a/telemetry-dashboard/scripts/seed-fixture.sh b/telemetry-dashboard/scripts/seed-fixture.sh new file mode 100755 index 0000000..88c8b1e --- /dev/null +++ b/telemetry-dashboard/scripts/seed-fixture.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Loads scripts/fixture.sql into the LOCAL .wrangler D1 (never the remote one: +# --local is on every command here, and nothing in this repo writes production). +# +# The schema comes from the writer, telemetry-worker/migrations/, because that +# is where it belongs — D1 is read-only from this worker. +# +# ./scripts/seed-fixture.sh (or: npm run seed) +set -uo pipefail + +cd "$(dirname "$0")/.." + +DB=codegraph-telemetry +MIGRATION=../telemetry-worker/migrations/0001_init.sql + +if [[ ! -f "$MIGRATION" ]]; then + echo "seed: cannot find $MIGRATION — run this from a full checkout" >&2 + exit 1 +fi + +# The migration is plain CREATE TABLE, so a second run fails on "table already +# exists". That is the expected steady state here, hence the swallowed output — +# the fixture load below is the step whose failure actually matters. +npx wrangler d1 execute "$DB" --local --file="$MIGRATION" >/dev/null 2>&1 + +if ! npx wrangler d1 execute "$DB" --local --file=scripts/fixture.sql >/dev/null; then + echo "seed: loading scripts/fixture.sql failed" >&2 + exit 1 +fi + +echo "seed: fixture loaded into the local $DB (12 machines, 2026-07-01 … 2026-07-10)" diff --git a/telemetry-dashboard/scripts/smoke-api.sh b/telemetry-dashboard/scripts/smoke-api.sh new file mode 100755 index 0000000..964c1b6 --- /dev/null +++ b/telemetry-dashboard/scripts/smoke-api.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash +# End-to-end check of the chart API against the committed fixture. +# +# Every expected number below is worked out by hand from scripts/fixture.sql — +# the header comment there lists all twelve machines and what each one does — so +# a failure here means the SQL changed its mind, not that a golden file drifted. +# +# ./scripts/smoke-api.sh (or: npm run smoke:api) +set -uo pipefail + +cd "$(dirname "$0")/.." + +# Deliberately NOT $PORT — see smoke-auth.sh. +DASH_PORT="${DASH_PORT:-8789}" +BASE="http://127.0.0.1:${DASH_PORT}" +PASSWORD="$(grep '^ADMIN_PASSWORD=' .dev.vars | cut -d'"' -f2)" +JAR="$(mktemp -t cg-api-jar)" +LOG="$(mktemp -t cg-api-log)" +PASS=0 +FAIL=0 + +# The fixture's own window. Every assertion is scoped to it, so a later fixture +# row outside these days cannot silently change an expected number. +FROM=2026-07-01 +TO=2026-07-10 +RANGE="from=$FROM&to=$TO" + +cleanup() { + [[ -n "${DEV_PID:-}" ]] && kill "$DEV_PID" 2>/dev/null + rm -f "$JAR" "$LOG" +} +trap cleanup EXIT + +status() { curl -s -o /dev/null -w '%{http_code}' "$@"; } +get() { curl -s -b "$JAR" "$BASE$1"; } + +# Resolves a dotted path through the JSON. Numeric segments index arrays, so +# `datasets.0.data` works. Node rather than jq: this is a Node project, jq is not. +jget() { + node -e ' + let v = JSON.parse(process.argv[1]); + for (const key of process.argv[2].split(".")) v = v?.[key]; + console.log(v === undefined ? "" : typeof v === "object" && v !== null ? JSON.stringify(v) : String(v)); + ' "$1" "$2" +} + +check() { # check + if [[ "$2" == "$3" ]]; then + printf ' ok %s\n' "$1" + PASS=$((PASS + 1)) + else + printf ' FAIL %s (expected %s, got %s)\n' "$1" "$2" "$3" + FAIL=$((FAIL + 1)) + fi +} + +field() { # field + check "$1" "$3" "$(jget "$4" "$2")" +} + +echo "Seeding the local D1 fixture…" +./scripts/seed-fixture.sh || exit 1 + +echo "Starting wrangler dev on :${DASH_PORT}…" +npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 & +DEV_PID=$! +READY="" +for _ in $(seq 1 90); do + if [[ "$(curl -s "$BASE/robots.txt")" == "User-agent: *"* ]]; then READY=1; break; fi + sleep 1 +done +if [[ -z "$READY" ]]; then + echo "wrangler dev never came up on :${DASH_PORT} — log follows" + cat "$LOG" + exit 1 +fi + +echo +echo "The gate still holds on every new endpoint" +for path in summary meta timeseries breakdown activation retention; do + check "GET /api/$path without a cookie → 401" 401 "$(status "$BASE/api/$path")" +done + +curl -s -o /dev/null -c "$JAR" -X POST -d "password=$PASSWORD" "$BASE/login" +check "signed in" 200 "$(status -b "$JAR" "$BASE/api/session")" + +echo +echo "Caching" +check "chart data is privately cacheable" "private, max-age=300" \ + "$(curl -sD - -o /dev/null -b "$JAR" "$BASE/api/summary" | grep -i '^cache-control:' | cut -d' ' -f2- | tr -d '\r')" +check "health stays uncached" "no-store" \ + "$(curl -sD - -o /dev/null -b "$JAR" "$BASE/api/health" | grep -i '^cache-control:' | cut -d' ' -f2- | tr -d '\r')" + +echo +echo "/api/meta — what the range picker anchors on" +META="$(get "/api/meta")" +field "latest day" latest_day 2026-07-10 "$META" +field "earliest day" earliest_day 2026-07-01 "$META" +field "raw events start" earliest_raw_day 2026-07-01 "$META" +field "retention window" retention_days 14 "$META" + +echo +echo "/api/summary — the big numbers (12 machines, one of them CI)" +SUMMARY="$(get "/api/summary?$RANGE")" +field "production users (m12 is CI)" production_users 11 "$SUMMARY" +field "active machines" active_machines 12 "$SUMMARY" +field "new machines" new_machines 12 "$SUMMARY" +field "installs" installs 12 "$SUMMARY" +field "uninstalls" uninstalls 2 "$SUMMARY" +field "indexing runs" index_runs 13 "$SUMMARY" +field "tool calls (SUM of count)" tool_calls 85 "$SUMMARY" +field "range echoed back" range.days 10 "$SUMMARY" + +echo +echo "/api/timeseries — one dense point per day, zeros where nothing happened" +TS="$(get "/api/timeseries?metric=installs_uninstalls&$RANGE")" +field "10 labels" labels.0 2026-07-01 "$TS" +field "installs" datasets.0.data '[4,2,1,0,2,0,0,1,2,0]' "$TS" +field "uninstalls" datasets.1.data '[0,0,0,0,0,1,1,0,0,0]' "$TS" +field "legend labels" datasets.1.label Uninstalls "$TS" + +TS="$(get "/api/timeseries?metric=new_installs&$RANGE")" +field "new installs by first-seen day" datasets.0.data '[4,2,1,0,2,0,0,1,2,0]' "$TS" + +TS="$(get "/api/timeseries?metric=production_users&$RANGE")" +field "daily production users" datasets.0.data '[4,3,4,1,2,3,2,1,1,1]' "$TS" + +TS="$(get "/api/timeseries?metric=indexing_activity&$RANGE")" +field "indexing runs" datasets.0.data '[2,2,2,1,1,1,1,1,1,1]' "$TS" +field "machines indexing" datasets.1.data '[2,2,2,1,1,1,1,1,1,1]' "$TS" + +TS="$(get "/api/timeseries?metric=tool_calls&$RANGE")" +field "calls per day" datasets.0.data '[0,40,28,0,0,12,0,0,0,5]' "$TS" +field "machines per day" datasets.1.data '[0,1,2,0,0,1,0,0,0,1]' "$TS" + +TS="$(get "/api/timeseries?metric=duration_buckets&$RANGE")" +field "bucket order is the scale" datasets.0.label '<10s' "$TS" +field "…and ends at the longest" datasets.3.label '5m+' "$TS" +field "<10s over time" datasets.0.data '[2,0,1,1,0,0,0,1,0,0]' "$TS" +field "10-60s over time" datasets.1.data '[0,2,0,0,0,0,1,0,0,1]' "$TS" +field "1-5m over time" datasets.2.data '[0,0,0,0,1,1,0,0,0,0]' "$TS" +field "5m+ over time" datasets.3.data '[0,0,1,0,0,0,0,0,1,0]' "$TS" + +echo +echo "/api/breakdown — bars and pies" +# machine-days, taking the largest per-event count per day so one machine's +# install + index + usage_rollup on one day is not counted three times. +BD="$(get "/api/breakdown?dim=os&$RANGE")" +field "os labels" labels '["linux","darwin","win32"]' "$BD" +field "os machine-days" datasets.0.data '[9,8,4]' "$BD" +field "os metric named" datasets.0.label 'Machine-days' "$BD" +field "os total" total 21 "$BD" + +BD="$(get "/api/breakdown?dim=os&metric=count&$RANGE")" +field "os by events sums every event" total 112 "$BD" + +BD="$(get "/api/breakdown?dim=language&$RANGE")" +field "languages, most-indexed first" labels '["typescript","csharp","go","javascript","python","rust","java"]' "$BD" +field "language counts" datasets.0.data '[7,2,2,2,2,2,1]' "$BD" +field "language rows total" total 18 "$BD" + +BD="$(get "/api/breakdown?dim=file_count_bucket&$RANGE")" +field "codebase size keeps bucket order" labels '["<100","100-1k","1k-10k","10k+"]' "$BD" +field "codebase size counts" datasets.0.data '[2,5,4,2]' "$BD" + +BD="$(get "/api/breakdown?dim=duration_bucket&$RANGE")" +field "run length keeps bucket order" labels '["<10s","10-60s","1-5m","5m+"]' "$BD" +field "run length counts" datasets.0.data '[5,4,2,2]' "$BD" +field "run length total = index runs" total 13 "$BD" + +BD="$(get "/api/breakdown?dim=target&$RANGE")" +field "agent targets are the installs" event install "$BD" +field "agent target labels" labels '["claude","cursor","codex","opencode"]' "$BD" +field "agent target counts" datasets.0.data '[9,3,2,1]' "$BD" + +BD="$(get "/api/breakdown?dim=codegraph_version&$RANGE")" +field "versions sort newest first" labels '["1.5.0","1.4.1","1.4.0"]' "$BD" +field "version machine-days" datasets.0.data '[8,3,10]' "$BD" + +BD="$(get "/api/breakdown?dim=name&$RANGE")" +field "tool names by call volume" labels '["codegraph_explore","index"]' "$BD" +field "tool call counts" datasets.0.data '[82,3]' "$BD" + +BD="$(get "/api/breakdown?dim=client_name&$RANGE")" +field "agents by call volume" labels '["Claude Code","Cursor"]' "$BD" +field "agent call counts" datasets.0.data '[70,12]' "$BD" + +BD="$(get "/api/breakdown?dim=kind&$RANGE")" +field "install kinds" labels '["fresh","upgrade"]' "$BD" +field "install kind counts" datasets.0.data '[11,1]' "$BD" + +BD="$(get "/api/breakdown?dim=scope&$RANGE")" +field "install scopes" datasets.0.data '[9,3]' "$BD" + +BD="$(get "/api/breakdown?dim=name_error&$RANGE")" +field "errors by tool" datasets.0.data '[1]' "$BD" + +BD="$(get "/api/breakdown?dim=language&limit=2&$RANGE")" +field "the tail folds into Other, never truncates" labels '["typescript","csharp","Other"]' "$BD" +field "Other keeps the total honest" total 18 "$BD" +field "truncation is declared" truncated true "$BD" + +echo +echo "/api/activation — install → first index within 7 days" +ACT="$(get "/api/activation?$RANGE")" +field "cohort is every machine first seen" installs 12 "$ACT" +field "m04 and m06 never indexed" activated 10 "$ACT" +field "…so two dropped" dropped 2 "$ACT" +field "window" window_days 7 "$ACT" +field "daily rate, null where no cohort" datasets.0.data '[75,50,100,null,100,null,null,100,100,null]' "$ACT" +field "recent cohorts flagged incomplete" incomplete_from 2026-07-04 "$ACT" +field "…and the completed ones are not" rows.2.complete true "$ACT" +field "…while the last week is" rows.8.complete false "$ACT" + +# Narrowing the window drops m03 alone: it installed on 07-01 and did not index +# until 07-03. Everyone else who ever indexed did it on day 0 or day 1. +ACT="$(get "/api/activation?window=1&$RANGE")" +field "a 1-day window converts fewer" activated 9 "$ACT" + +echo +echo "/api/retention — day 0–14, denominator per day" +RET="$(get "/api/retention?$RANGE")" +field "cohort size" cohort 12 "$RET" +field "15 points" labels.14 'Day 14' "$RET" +# Day 2 divides by 10, not 12: m11/m12 arrived on 07-09 and cannot have a day-2 +# data point yet. Day 10+ is null — nobody in the cohort is old enough at all. +field "retention curve" datasets.0.data \ + '[100,41.7,30,11.1,0,22.2,0,0,0,0,null,null,null,null,null]' "$RET" +field "day 2 eligible excludes the newest cohorts" rows.2.eligible 10 "$RET" +field "day 10 has nobody old enough" rows.10.eligible 0 "$RET" + +echo +echo "Bad input is rejected, never guessed at" +check "unknown dim → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=machine_id")" +check "missing dim → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown")" +check "unknown metric → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&metric=secrets")" +check "unknown series → 400" 400 "$(status -b "$JAR" "$BASE/api/timeseries?metric=everything")" +check "limit out of range → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&limit=0")" +check "impossible date → 400" 400 "$(status -b "$JAR" "$BASE/api/summary?from=2026-02-31&to=2026-07-10")" +check "malformed date → 400" 400 "$(status -b "$JAR" "$BASE/api/summary?from=yesterday")" +check "backwards range → 400" 400 "$(status -b "$JAR" "$BASE/api/summary?from=2026-07-10&to=2026-07-01")" +check "window out of range → 400" 400 "$(status -b "$JAR" "$BASE/api/activation?window=99")" +check "unknown endpoint → 404" 404 "$(status -b "$JAR" "$BASE/api/everything")" +check "event name is a closed shape → 400" 400 \ + "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&event=install%27%20OR%201=1")" + +CLAMPED="$(get "/api/breakdown?dim=os&from=2019-01-01&to=$TO")" +field "a decade-wide range clamps to a year" range.days 366 "$CLAMPED" +field "…and says so" range.clamped true "$CLAMPED" +field "…kept against the recent end" range.from 2025-07-10 "$CLAMPED" + +echo +echo "An empty range renders as empty, not as an error" +EMPTY="$(get "/api/summary?from=2025-01-01&to=2025-01-07")" +field "no machines" production_users 0 "$EMPTY" +field "no installs" installs 0 "$EMPTY" +EMPTY="$(get "/api/breakdown?dim=os&from=2025-01-01&to=2025-01-07")" +field "no bars" labels '[]' "$EMPTY" +EMPTY="$(get "/api/timeseries?metric=production_users&from=2025-01-01&to=2025-01-03")" +field "still a dense axis" datasets.0.data '[0,0,0]' "$EMPTY" + +echo +printf '%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/telemetry-dashboard/scripts/smoke-auth.sh b/telemetry-dashboard/scripts/smoke-auth.sh new file mode 100755 index 0000000..e8f0a8c --- /dev/null +++ b/telemetry-dashboard/scripts/smoke-auth.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# End-to-end check of the auth gate against a local `wrangler dev`. +# +# Verifies the acceptance criteria for the gate: unauthenticated requests reach +# nothing (pages, API, or static assets), a valid cookie reaches everything, and +# a tampered cookie is rejected. Run it after touching src/auth.ts or the route +# table in src/index.ts. +# +# ./scripts/smoke-auth.sh +set -uo pipefail + +cd "$(dirname "$0")/.." + +# Deliberately NOT $PORT: that is commonly already set to some other local dev +# server, and the whole suite would then silently test the wrong app. +DASH_PORT="${DASH_PORT:-8788}" +BASE="http://127.0.0.1:${DASH_PORT}" +PASSWORD="$(grep '^ADMIN_PASSWORD=' .dev.vars | cut -d'"' -f2)" +JAR="$(mktemp -t cg-dash-jar)" +LOG="$(mktemp -t cg-dash-log)" +DEV_VARS_BACKUP="$(mktemp -t cg-dash-vars)" +PASS=0 +FAIL=0 + +cleanup() { + [[ -n "${DEV_PID:-}" ]] && kill "$DEV_PID" 2>/dev/null + # The rotation phase rewrites .dev.vars; always put the original back. + [[ -s "$DEV_VARS_BACKUP" ]] && cp "$DEV_VARS_BACKUP" .dev.vars + rm -f "$JAR" "$LOG" "$DEV_VARS_BACKUP" +} +trap cleanup EXIT + +# `curl -o /dev/null -w '%{http_code}'` plus the headers we care about. +status() { curl -s -o /dev/null -w '%{http_code}' "$@"; } +body() { curl -s "$@"; } + +check() { # check + if [[ "$2" == "$3" ]]; then + printf ' ok %s\n' "$1" + PASS=$((PASS + 1)) + else + printf ' FAIL %s (expected %s, got %s)\n' "$1" "$2" "$3" + FAIL=$((FAIL + 1)) + fi +} + +contains() { # contains + if [[ "$3" == *"$2"* ]]; then + printf ' ok %s\n' "$1" + PASS=$((PASS + 1)) + else + printf ' FAIL %s (missing %q in %.200q…)\n' "$1" "$2" "$3" + FAIL=$((FAIL + 1)) + fi +} + +lacks() { # lacks + if [[ "$3" != *"$2"* ]]; then + printf ' ok %s\n' "$1" + PASS=$((PASS + 1)) + else + printf ' FAIL %s (found %q)\n' "$1" "$2" + FAIL=$((FAIL + 1)) + fi +} + +echo "Seeding local D1 from the ingest worker's migration…" +npx wrangler d1 execute codegraph-telemetry --local \ + --file=../telemetry-worker/migrations/0001_init.sql >/dev/null 2>&1 + +echo "Starting wrangler dev on :${DASH_PORT}…" +npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 & +DEV_PID=$! +READY="" +for _ in $(seq 1 90); do + if [[ "$(body "$BASE/robots.txt")" == "User-agent: *"* ]]; then READY=1; break; fi + sleep 1 +done +if [[ -z "$READY" ]]; then + echo "wrangler dev never came up on :${DASH_PORT} — log follows" + cat "$LOG" + exit 1 +fi + +echo +echo "Unauthenticated — nothing but the login page and robots.txt" +check "GET / → 302 to login" 302 "$(status "$BASE/")" +check "GET /index.html → 302 to login" 302 "$(status "$BASE/index.html")" +check "GET /styles.css → 302 to login" 302 "$(status "$BASE/styles.css")" +check "GET /app.js → 302 to login" 302 "$(status "$BASE/app.js")" +check "GET /vendor/chart → 302 to login" 302 "$(status "$BASE/vendor/chart.umd.js")" +check "GET /api/health → 401" 401 "$(status "$BASE/api/health")" +check "GET /api/session → 401" 401 "$(status "$BASE/api/session")" +check "GET /api/anything → 401" 401 "$(status "$BASE/api/whatever")" +check "GET /login → 200" 200 "$(status "$BASE/login")" +check "GET /robots.txt → 200" 200 "$(status "$BASE/robots.txt")" +contains "no data leaks in the 401 body" '"unauthorized"' "$(body "$BASE/api/health")" + +echo +echo "Login page" +LOGIN_HTML="$(body "$BASE/login")" +contains "sentence-case heading" "codegraph telemetry" "$LOGIN_HTML" +contains "sentence-case label" ">Password<" "$LOGIN_HTML" +contains "sentence-case button" ">Sign in<" "$LOGIN_HTML" +lacks "no uppercased labels" "uppercase" "$LOGIN_HTML" +lacks "no tracked-out labels" "letter-spacing" "$LOGIN_HTML" +contains "label is normal size" "font-size: 16px" "$LOGIN_HTML" +check "open redirect refused" "/" \ + "$(body "$BASE/login?next=%2F%2Fevil.example" | sed -n 's/.*name="next" value="\([^"]*\)".*/\1/p')" +check "same-origin next kept" "/api/health" \ + "$(body "$BASE/login?next=%2Fapi%2Fhealth" | sed -n 's/.*name="next" value="\([^"]*\)".*/\1/p')" + +echo +echo "Sign-in" +check "wrong password → 401" 401 \ + "$(status -X POST "$BASE/login" -d "password=definitely-not-it" -d "next=/")" +check "wrong password sets no cookie" "" \ + "$(curl -s -D - -o /dev/null -X POST "$BASE/login" -d "password=nope" | grep -ci 'set-cookie' | sed 's/^0$//')" +check "empty password → 400" 400 "$(status -X POST "$BASE/login" -d "password=")" +check "cross-origin post → 400" 400 \ + "$(status -X POST "$BASE/login" -H 'Origin: https://evil.example' -d "password=${PASSWORD}")" +# One sign-in, then every cookie assertion reads the captured headers. Doing a +# fresh POST per assertion would burn the login rate limit and 429 halfway down. +SIGNIN="$(curl -s -D - -o /dev/null -c "$JAR" -X POST "$BASE/login" -d "password=${PASSWORD}" -d "next=/")" +check "correct password → 302" "302" "$(printf '%s' "$SIGNIN" | head -1 | awk '{print $2}')" +contains "cookie is HttpOnly" "HttpOnly" "$SIGNIN" +contains "cookie is Secure" "Secure" "$SIGNIN" +contains "cookie is SameSite=Lax" "SameSite=Lax" "$SIGNIN" +contains "cookie is ~1 year" "Max-Age=31536000" "$SIGNIN" +contains "cookie is site-wide" "Path=/" "$SIGNIN" + +COOKIE="$(grep cg_admin_session "$JAR" | awk '{print $NF}')" +PAYLOAD="${COOKIE%%.*}" +SIG="${COOKIE#*.}" + +# A persistent cookie carries a real expiry in the jar; a session cookie (gone +# on browser restart) carries 0. This is the "survives a restart" criterion. +JAR_EXPIRY="$(grep cg_admin_session "$JAR" | awk '{print $5}')" +if [[ "$JAR_EXPIRY" -gt "$(( $(date +%s) + 300 * 86400 ))" ]]; then + check "cookie persists across browser restarts" "persistent" "persistent" +else + check "cookie persists across browser restarts" "persistent" "session-only (expiry ${JAR_EXPIRY})" +fi + +echo +echo "Authenticated — the whole app" +check "GET / → 200" 200 "$(status -b "$JAR" "$BASE/")" +check "GET /styles.css → 200" 200 "$(status -b "$JAR" "$BASE/styles.css")" +check "GET /app.js → 200" 200 "$(status -b "$JAR" "$BASE/app.js")" +check "GET /vendor/chart→ 200" 200 "$(status -b "$JAR" "$BASE/vendor/chart.umd.js")" +check "GET /api/session → 200" 200 "$(status -b "$JAR" "$BASE/api/session")" +check "GET /api/health → 200" 200 "$(status -b "$JAR" "$BASE/api/health")" +contains "health reads D1" '"ok":true' "$(body -b "$JAR" "$BASE/api/health")" +check "GET /login while signed in → 302" 302 "$(status -b "$JAR" "$BASE/login")" +check "unknown API route → 404" 404 "$(status -b "$JAR" "$BASE/api/nope")" +check "POST to an API route → 405" 405 "$(status -b "$JAR" -X POST "$BASE/api/health")" + +echo +echo "Tampering" +# Mutate the FIRST signature character, not the last: base64url's final +# character of a 32-byte tag carries only 4 significant bits, so flipping it is +# sometimes a no-op on the decoded bytes and the test would pass vacuously. +FLIPPED="${PAYLOAD}.$([[ "${SIG:0:1}" == 'A' ]] && echo B || echo A)${SIG:1}" +check "flipped signature → 401" 401 "$(status -H "Cookie: cg_admin_session=${FLIPPED}" "$BASE/api/health")" +check "truncated signature → 401" 401 "$(status -H "Cookie: cg_admin_session=${PAYLOAD}.${SIG:0:40}" "$BASE/api/health")" +check "swapped payload → 401" 401 \ + "$(status -H "Cookie: cg_admin_session=$(printf '%s' '{"v":1,"iat":0,"exp":9999999999,"pw":"x"}' | base64 | tr -d '=' | tr '+/' '-_').${SIG}" "$BASE/api/health")" +check "no signature → 401" 401 "$(status -H "Cookie: cg_admin_session=${PAYLOAD}" "$BASE/api/health")" +check "garbage cookie → 401" 401 "$(status -H 'Cookie: cg_admin_session=not-a-token' "$BASE/api/health")" +check "empty cookie → 401" 401 "$(status -H 'Cookie: cg_admin_session=' "$BASE/api/health")" +check "tampered cookie on a page → 302 to login" 302 \ + "$(status -H "Cookie: cg_admin_session=${FLIPPED}" "$BASE/")" + +echo +echo "Sign-out" +check "POST /logout → 302" 302 "$(status -X POST "$BASE/logout")" +contains "logout clears the cookie" "Max-Age=0" \ + "$(curl -s -D - -o /dev/null -X POST "$BASE/logout")" +check "GET /logout → 405" 405 "$(status "$BASE/logout")" + +echo +echo "Rate limiting (6 attempts in a minute; the 6th should be capped)" +LAST="" +for _ in 1 2 3 4 5 6 7; do + LAST="$(status -X POST "$BASE/login" -d 'password=guess')" +done +check "brute force capped → 429" 429 "$LAST" + +echo +echo "Password rotation (restarting with a different ADMIN_PASSWORD)" +cp .dev.vars "$DEV_VARS_BACKUP" +sed 's/^ADMIN_PASSWORD=.*/ADMIN_PASSWORD="rotated-password"/' "$DEV_VARS_BACKUP" >.dev.vars +kill "$DEV_PID" 2>/dev/null +wait "$DEV_PID" 2>/dev/null +npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 & +DEV_PID=$! +for _ in $(seq 1 90); do + [[ "$(body "$BASE/robots.txt")" == "User-agent: *"* ]] && break + sleep 1 +done +check "cookie from the old password → 401" 401 \ + "$(status -H "Cookie: cg_admin_session=${COOKIE}" "$BASE/api/health")" +check "old password no longer signs in → 401" 401 \ + "$(status -X POST "$BASE/login" -d "password=${PASSWORD}")" +check "new password signs in → 302" 302 \ + "$(status -X POST "$BASE/login" -d "password=rotated-password")" + +echo +printf '%s\n' "-----" +printf '%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/telemetry-dashboard/scripts/vendor-assets.mjs b/telemetry-dashboard/scripts/vendor-assets.mjs new file mode 100644 index 0000000..4d6378e --- /dev/null +++ b/telemetry-dashboard/scripts/vendor-assets.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Copies third-party browser libraries out of node_modules into public/vendor/. + * + * Workers Static Assets are served verbatim — nothing in public/ goes through a + * bundler — so a library from npm has to be physically present there. Keeping + * it a copy step (rather than a checked-in blob or a CDN