diff --git a/.gitignore b/.gitignore index 8a3fe1d..a133fef 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,9 @@ dist/ *.tsbuildinfo app/m0-app.png app/m1-live.png -app/m2-drill.png app/m3-cost.png -app/m3-flame.png app/m3-flame-real.png +# (m2-drill.png + m3-flame.png are tracked — they illustrate the README's drill-down/flame sections) # capture / render working dirs + large media (regenerable) .rec/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 04d7b90..e7eff29 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -232,9 +232,18 @@ only `?source=live` needs the Node OTLP+SSE server (deferred). base has no routing downside. - **Demo clip.** `app/record.mjs` records the running `app/dist` on the real GPU (serve dist → `channel:'chrome'` + `--force_high_performance_gpu` → wait `[fathom]` ready → `?source=real` → `recordVideo`), then ffmpeg emits - `.mp4` + a two-pass-palette `.gif`. The GIF (`app/fathom-demo.gif`, ~30fps for size) is the README hero and is - git-tracked; `webm`/`mp4` are gitignored (regenerable; the mp4 is uploaded directly at launch). Honesty: the + `.mp4` + a two-pass-palette `.gif` (16fps · 760px · 128 colors · 8s ≈ 6 MB — dense particle motion is + worst-case for gif; the mp4/webm carry full framerate). The GIF (`app/fathom-demo.gif`) is the README hero and + is git-tracked; `webm`/`mp4` are gitignored (regenerable; the mp4 is uploaded directly at launch). Honesty: the clip is a real capture, replayed — the HUD's "requests replayed" / "replayed as a live stream" says so. +- **Launch surface (M5 Fable pass).** `index.html` carries the unfurl cards (`og:`/`twitter:` + + `public/og.png` 1200×630 + `public/favicon.svg`). First-visit failure modes are surfaced, not silent: + `createRiver`/`createFlame` accept `onError` (adapter-null → the shared `NO_ADAPTER_MSG` from + `gpu/capabilities.ts`; init throws → message) rendered in App's full-screen error state; live mode with no + gateway answering shows `ui/LiveNotice.tsx` after 4s (honest "no gateway connected" + how to run one + + one-click switch to replay — it self-dismisses if a local server connects). The river legend hints the M2 + pick (`click a comet → its span`). A minimal mobile pass lives at the end of `styles.css` (≤720px compact + panels; in flame view the flame panel owns the top via `.flame ~ #hud`; ≤480px hides the legend). - **Deferred (live mode).** Hosting the Node server for public `?source=live` needs a live process (Fly.io/Render; Cloudflare Pages is static-only, a Worker+Durable Object is the advanced alt). Not required for the launch demo. diff --git a/CLAUDE.md b/CLAUDE.md index 6dc2226..68c2e6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ v1 build (M0–M4 done; see `SPEC.md`): ## Layout | Path | Role | |---|---| -| `app/` | **cinema** — Vite + React + TS; raw-WebGPU core `app/src/gpu/` (incl. `motion.ts` = pick math mirroring `shaders/river-sim.wgsl` M2/M4 · `bloom.ts` = HDR bloom chain M4), SSE client `app/src/lib/stream.ts`, shell `app/src/ui/` (incl. `SpanDetail.tsx`) | +| `app/` | **cinema** — Vite + React + TS; raw-WebGPU core `app/src/gpu/` (incl. `motion.ts` = pick math mirroring `shaders/river-sim.wgsl` M2/M4 · `bloom.ts` = HDR bloom chain M4), SSE client `app/src/lib/stream.ts`, shell `app/src/ui/` (incl. `SpanDetail.tsx` · `LiveNotice.tsx` M5); `app/public/` = replay data + `og.png`/`favicon.svg` unfurl assets (M5) | | `app/public/traces.json` · `.sample.json` | trace data the app fetches in replay mode (copy of the root files) | | `server/` | **live server** (M1/M2) — OTLP receiver + SSE + `/traces/:id` (`src/otlp.ts`, `src/hub.ts`, `src/index.ts`); `tools/` = emit/live-demo/sentinel-otlp/pick-e2e checks; `e2e.ts` | | `app/tools/pick-check.ts` | M2 pick-math unit harness (mirrors the shader; no GPU) | @@ -133,15 +133,24 @@ GPU/frame with bloom on** — 157× under the 16.7 ms budget on the 4070 (`PROOF `app/m4-richness.png`, also the README hero). Curl samples global time → replay loops drift subtly within ±0.02 NDC (documented caveat); the curl amplitude budget (jitter 0.03 + turb 0.035 + curl 0.015 = 0.080 < 0.09 lane gap) is load-bearing — don't raise it without redoing the arithmetic (pick-check enforces). -**M5 in progress (hosted demo — Opus half done)**: the static replay demo is deployed to **GitHub Pages** -via CI — `.github/workflows/deploy.yml` builds `app/dist` and publishes on every push to `main` -(`ci.yml` gates PRs). The demo runs `?source=real` — the **labeled client-side replay**, no server (live -SSE mode is deferred). Vite `base` is now **`'./'`** (relative) so the bundle works under the Pages subpath -*and* at local root (the recorder serves `dist` at root). The **README hero is now an animated GIF** -(`app/fathom-demo.gif`, tracked) captured by `app/record.mjs` on the 4070 (real capture, replayed — the HUD -says so); `webm`/`mp4` are gitignored (regenerable; mp4 uploaded at launch). GIF is ~30fps for size; the -demo *runs* at 60fps. One-time manual step: repo **Settings → Pages → Source = GitHub Actions**. URL: -`https://manvendra-github.github.io/Fathom/`. Still missing (M5, Fable half): README hero copy + public-demo -visual polish + launch (X/HN). Deferred: hosting the Node OTLP+SSE server for public `?source=live`. -**The v1 build plan is in [`SPEC.md`](./SPEC.md)** (milestones M0–M5). **Next: finish M5 (Fable polish + launch).** +**M5 nearly done (hosted demo — both halves shipped; launch pending)**: the static replay demo is deployed +to **GitHub Pages** via CI — `.github/workflows/deploy.yml` builds `app/dist` and publishes on every push to +`main` (`ci.yml` gates PRs). The demo runs `?source=real` — the **labeled client-side replay**, no server +(live SSE mode is deferred). Vite `base` is **`'./'`** (relative) so the bundle works under the Pages subpath +*and* at local root (the recorder serves `dist` at root). URL: `https://manvendra-github.github.io/Fathom/`. +**Opus half**: deploy/CI + the recorder (`app/record.mjs` → `app/fathom-demo.gif`, tracked README hero; +`webm`/`mp4` gitignored, mp4 uploaded at launch; gif recipe = 16fps·760px·128 colors·8s, shipped in the +recorder — the gif is ~16fps for size, the demo *runs* at 60fps). **Fable half (launch surface)**: README +rebuilt to SPEC §7's shape (hook → hero gif → demo link + the 0.106 ms/157× number → "why this exists" → +substance; `m2-drill.png` + `m3-flame.png` now tracked and embedded); unfurl cards in `app/index.html` +(`og:`/`twitter:` + `app/public/og.png` 1200×630 + `favicon.svg`); **init errors surface on-screen** — +`onError` threaded through `createRiver`/`createFlame` (adapter-null was a silent blank canvas; shared copy +in `gpu/capabilities.ts` `NO_ADAPTER_MSG`); **live mode with no gateway** shows an honest "no gateway +connected" notice after 4s (`ui/LiveNotice.tsx`; self-dismisses on connect, one-click switch to replay); +river legend hints `click a comet → its span`; humane replay-fetch errors; **minimal mobile pass** +(≤720px compact panels + flame-owns-the-top via `.flame ~ #hud`, ≤480px hides the legend). Verified: +pick-check 21/21 · cost-check 50/50 · pick-e2e 8/8 · 4-state screenshot iteration on the 4070 +(`PROOF.md` §8c). Still missing (M5): **launch** (X first, then HN 12–17 UTC — plan in `SPEC.md` §7). +Deferred: hosting the Node OTLP+SSE server for public `?source=live`. +**The v1 build plan is in [`SPEC.md`](./SPEC.md)** (milestones M0–M5). **Next: launch.** Decision context: vault note `next-flagship-project-research.md`. diff --git a/PROOF.md b/PROOF.md index 163e7ee..b47bb4d 100644 --- a/PROOF.md +++ b/PROOF.md @@ -284,7 +284,7 @@ curl flow, model sub-streams, `est. $ saved (cache) —` honest unpriced state). --- -## 8. M5 — hosted demo build + capture (Opus half) +## 8. M5 — hosted demo: build + capture (Opus half) · launch surface (Fable half) The public demo is the static `app/dist` on **GitHub Pages** (CI-built), running `?source=real` — the labeled client-side replay, no server. Deploy config: `.github/workflows/deploy.yml` (build → Pages on `main`) + `ci.yml` (PR build gate). URL: `https://manvendra-github.github.io/Fathom/`. @@ -302,22 +302,34 @@ npm run build **(b) Demo clip captured on the real GPU** (`app/record.mjs` → serve dist → Chrome `--force_high_performance_gpu` → `[fathom]` ready → `?source=real` → `recordVideo` → ffmpeg): ```bash -npm run app:record +npm run app:record # re-run after the M5 Fable polish (hint line, notice, meta) — 2026-07-04 # [record] serving app/dist at http://localhost:8976 source=real -# [page] [fathom] replay mode · 24,982 particles · nvidia · lovelace +# [page] [fathom] replay mode · 25,146 particles · nvidia · lovelace # [record] hero still -> fathom-demo.png # [record] video -> fathom-demo.webm # [record] ffmpeg -> fathom-demo.mp4, fathom-demo.gif -# artifacts: fathom-demo.gif 6.69 MB (760×428, 128 frames, ~30fps) · fathom-demo.mp4 5.32 MB · -# fathom-demo.webm 3.52 MB · fathom-demo.png 0.42 MB +# outputs: fathom-demo.webm 3.43 MB · fathom-demo.mp4 5.15 MB · fathom-demo.gif 6.17 MB +# (gif: 760×428, 16fps × 8s = 128 frames, 128 colors — the recipe now shipped in app/record.mjs) ``` `fathom-demo.gif` is the README hero (git-tracked); `webm`/`mp4` are gitignored (regenerable; the mp4 is uploaded directly at launch). The clip is a **real capture, replayed** — the HUD reads "requests replayed" and the panel says "replayed as a live stream" (honest, no manipulation beyond replay-order interleaving). **M5 Opus exit criterion met:** the static replay demo builds and deploys via CI to a public URL, and the -README autoplay GIF is captured from the shipping bundle on the 4070. GIF is ~30fps for size; the demo -*runs* at 60fps (§7). Remaining (Fable half): README hero copy + public-demo polish + launch. +README autoplay GIF is captured from the shipping bundle on the 4070. GIF is ~16fps for size; the demo +*runs* at 60fps (§7). + +**(c) Fable half — launch surface + first-visit polish** (same-day pass, screenshot-iterated on the 4070): +README rebuilt to the SPEC §7 launch shape (hook → hero gif → demo link + the 0.106 ms/157× number → +"why this exists" → substance); unfurl cards (`og:`/`twitter:` + `og.png` 1200×630 + SVG favicon); +adapter-null and init failures now surface on-screen (`onError` threaded through `createRiver`/`createFlame` +— previously a silent blank canvas); live mode with no gateway shows an honest **"no gateway connected"** +notice after 4s (self-dismisses on connect; one-click switch to the real capture); river legend gains +`click a comet → its span`; replay-fetch errors are worded for humans; minimal mobile pass (≤720px compact +panels, ≤480px canvas-first; flame panel owns the top on phones). Verified: build green · pick-check 21/21 · +cost-check 50/50 · pick-e2e 8/8 (`m2-drill.png` regenerated with the polished UI) · 4-state screenshot +iteration (desktop river / live notice / mobile river / mobile flame — 2 defects caught + fixed: mid-word +code wrapping, flame-over-HUD overlap). Remaining: launch (X/HN). --- diff --git a/README.md b/README.md index 2127884..27d9ce1 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,44 @@ # Fathom -**A WebGPU "observability cinema" for LLM-ops telemetry.** +**I made LLM-gateway telemetry run at 60fps on the GPU.** -Every particle is one real LLM-gateway span. Requests flow as glowing comets into lanes by their -real outcome: **cache hits** stream off as a cyan tributary, **429s** retry and fail over in amber, -**PII** is caught and blocked in-path as red flares, and plain **spans/misses** run as the blue river. +[![Fathom — real LLM-gateway spans as a WebGPU particle river (real capture, replayed; gif is ~16fps for size — the demo runs at 60fps)](./app/fathom-demo.gif)](https://manvendra-github.github.io/Fathom/) -[![Fathom cinema — real sentinel spans: curl-noise flow + bloom, replayed at 60fps](./app/fathom-demo.gif)](https://manvendra-github.github.io/Fathom/) +> **▶ Live demo — https://manvendra-github.github.io/Fathom/** +> 460 real gateway spans, replayed client-side on *your* GPU (needs a [WebGPU](https://caniuse.com/webgpu) +> browser). The clip above is a real capture, replayed — the HUD says so. The gif is ~16fps for file +> size; the demo itself runs at 60fps. +> +> **The number:** the full pipeline — curl-noise compute, model-shaded sub-streams, HDR bloom — costs +> **0.106 ms of GPU time per frame** on an RTX 4070, measured per-pass with `timestamp-query`. That's +> **157× under the 60fps budget** ([PROOF.md §7](./PROOF.md)). Even *integrated* Intel UHD renders a +> million particles in 3.47 ms. -> **▶ Live demo — https://manvendra-github.github.io/Fathom/** · real sentinel spans, replayed -> client-side at 60fps on the GPU (needs a [WebGPU](https://caniuse.com/webgpu) browser). The clip -> above is a real capture, replayed — the HUD says so. +## Why this exists -> Perf and the real-data look are proven (see [`PROOF.md`](./PROOF.md)); the v1 build is underway per -> [`SPEC.md`](./SPEC.md). **Done: M0** (the app) **+ M1** (a generic OTLP live server — real sentinel spans -> stream in as comets) **+ M2** (drill-down — click a comet → its real span attributes via `GET /traces/:id`) -> **+ M3** (a `river`↔`$ flame` toggle → a rotating **3D cost flame graph**: provider monoliths + model bars -> extruded from the live aggregation, rising embers, orbit camera, hover pick — numbers reconcile with the HUD) -> **+ M4** (richness pass — curl-noise flow in a stateless compute pass, a real HDR **bloom** chain with a -> toggle, model-shaded **sub-streams** within each lane, an honest `est. $ saved (cache)` HUD counter — the -> whole pipeline measured at **0.106 ms GPU/frame** on the 4070, 157× under the 60fps budget). -> **M5 (underway):** the static replay demo is deployed to **GitHub Pages** via CI (link above); -> README hero GIF + launch polish next. +LLM-ops dashboards are flat. Langfuse, Phoenix, Helicone, LiteLLM — tables and 2D charts of the most +consequential telemetry a team has: money leaving per request, PII trying to leave, 429s quietly +eating the latency budget. **Money and PII deserve to be seen.** + +Fathom plugs onto **any** OpenTelemetry-emitting gateway and renders every span as a glowing comet, +laned by its real outcome: **cache hits** stream off as a cyan tributary, **429s** retry and fail +over in amber, **PII** is caught and blocked in-path as red flares, and plain **misses** run as the +blue river. Shade within a lane is the model that served it. + +And it's a tool, not a screensaver: **click any comet** and it opens as a real span — route, outcome, +usage, raw `gen_ai.*` attributes — reconciled against the server's `GET /traces/:id`. + +![Drill-down — click a comet, get its real span attributes in an outcome-lit callout](./app/m2-drill.png) + +Toggle to the **`$ flame` view** and cost stacks up as a rotating 3D flame graph — provider monoliths, +model bars, rising embers — aggregated from the same spans and reconciled with the HUD *by +construction* (one reduction feeds both). + +![$ flame — cost by provider → model as a rotating 3D flame graph with orbit camera and hover pick](./app/m3-flame.png) + +> **Status:** v1 milestones M0–M4 shipped, M5 (launch) underway — the plan is +> [`SPEC.md`](./SPEC.md), every measured claim is [`PROOF.md`](./PROOF.md), the design is +> [`ARCHITECTURE.md`](./ARCHITECTURE.md). --- @@ -113,6 +130,8 @@ develop the renderer. mixed stream — the load harness emits them in scenario phases, which isn't real arrival timing. Spans and proportions are unchanged; only replay position is shuffled. The panel says "replayed as a live stream." - Model names show as `std`/`pii` — the literal routed-model ids in sentinel's load config, not prettied. +- The hero gif is **~16fps** (gif palettes and file size, not the renderer); measured frame times live + in [`PROOF.md`](./PROOF.md) — 0.106 ms GPU/frame with everything on. - ffmpeg isn't required; without it you get `.webm` (plays everywhere). With it, `record.mjs` also emits `.mp4` + `.gif`. ## Requirements diff --git a/SPEC.md b/SPEC.md index 2beb2cf..383eddb 100644 --- a/SPEC.md +++ b/SPEC.md @@ -124,7 +124,7 @@ Restructuring is **M0 work**, not done in this spec turn. | **M2 ✅ done** | Drill-down | ✅ clicking a comet opens the correct span's real attributes, reconciled against `GET /traces/:id` on the real GPU (see `PROOF.md` §5); pick is CPU math mirroring the shader (`app/src/gpu/motion.ts`) | **Opus** (pick math, `/traces/:id`, wiring) ✅; **Fable** (attribute-panel UI — outcome-lit callout card: reticle + leader line, card parked opposite the comet) ✅ | | **M3 ✅ done** | 3D cost flame graph | ✅ toggle + cost-by-model/provider from the live buffer, reconciles with the HUD on screen; rendered as a **3D WGSL scene** (orbiting monoliths + embers, 4.5 ms/frame — `PROOF.md` §6) | **Opus** (cost aggregation + toggle + reconciliation) ✅; **Fable** (3D WGSL flame: monoliths, embers, orbit camera, ray pick, tracked DOM labels) ✅ | | **M4 ✅ done** | Richness pass | ✅ bloom (HDR post chain + toggle) + curl-noise (stateless compute pass, pick mirror intact) + model sub-streams + `est. $ saved` counter — measured at **0.106 ms GPU/frame** with bloom on (157× headroom, `timestamp-query`; see `PROOF.md` §7) | **Fable** (curl field, bloom look, sub-streams) ✅; **Opus** (perf measurement, $-saved honesty, pick-mirror integrity) ✅ | -| **M5 🚧 in progress** | Hosted demo + launch | **Opus half ✅:** static replay demo deployed to **GitHub Pages** via CI (`deploy.yml` builds `app/dist` on `main`, `ci.yml` gates PRs; `?source=real`, `base:'./'`), README autoplay GIF captured on the 4070 (`app/fathom-demo.gif`, real-capture-replayed, `PROOF.md` §8). **Fable half:** README hero copy + public-demo polish; **launch** (X/HN) pending | **Opus** (deploy/CI config + GIF capture) ✅; **Fable** (README hero + public-demo polish) — pending | +| **M5 🚧 in progress** | Hosted demo + launch | **Opus half ✅:** static replay demo deployed to **GitHub Pages** via CI (`deploy.yml` builds `app/dist` on `main`, `ci.yml` gates PRs; `?source=real`, `base:'./'`), README autoplay GIF captured on the 4070 (`app/fathom-demo.gif`, real-capture-replayed, `PROOF.md` §8). **Fable half ✅:** README rebuilt to the §7 launch shape (hook → hero → number → why); unfurl cards + favicon; init errors surfaced; live-mode "no gateway" notice; river pick hint; mobile pass (`PROOF.md` §8c). **Launch (X/HN) pending** | **Opus** (deploy/CI config + GIF capture) ✅; **Fable** (README hero + public-demo polish) ✅ | **Model policy** (Opus + Fable only): **Fable** for anything people *look at* (shaders, viz, UI polish, launch surface); **Opus** for anything that must be *provably correct* (pick math, cost reconciliation, OTLP mapping) and all plumbing/tests/doc-sync. Highest-value Fable milestone: **M4**. Don't drop below Opus on **M3's aggregation** — a wrong cost number undercuts the "money deserves to be seen" pitch. diff --git a/app/fathom-demo.gif b/app/fathom-demo.gif index 9ee86ac..0a3ff78 100644 Binary files a/app/fathom-demo.gif and b/app/fathom-demo.gif differ diff --git a/app/fathom-demo.png b/app/fathom-demo.png index 7cd5386..35dd25d 100644 Binary files a/app/fathom-demo.png and b/app/fathom-demo.png differ diff --git a/app/index.html b/app/index.html index 107380c..6738499 100644 --- a/app/index.html +++ b/app/index.html @@ -4,6 +4,23 @@ Fathom — LLM-ops observability cinema + + + + + + + + + + + + + + + + +
diff --git a/app/m2-drill.png b/app/m2-drill.png new file mode 100644 index 0000000..cd7128f Binary files /dev/null and b/app/m2-drill.png differ diff --git a/app/m3-flame.png b/app/m3-flame.png new file mode 100644 index 0000000..887f407 Binary files /dev/null and b/app/m3-flame.png differ diff --git a/app/public/favicon.svg b/app/public/favicon.svg new file mode 100644 index 0000000..f481ad8 --- /dev/null +++ b/app/public/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/public/og.png b/app/public/og.png new file mode 100644 index 0000000..9cc5fb5 Binary files /dev/null and b/app/public/og.png differ diff --git a/app/record.mjs b/app/record.mjs index 19e5826..f746ec5 100644 --- a/app/record.mjs +++ b/app/record.mjs @@ -8,7 +8,7 @@ * ready signal, and drives ?source= — the labeled client-side replay (HUD reads * "requests replayed"), i.e. real captured sentinel spans, replayed. No manipulation beyond that. * - * The gif is ~30fps for size; the demo itself runs at 60fps (PROOF.md §7) and the .mp4/.webm carry + * The gif is ~16fps for size; the demo itself runs at 60fps (PROOF.md §7) and the .mp4/.webm carry * the full framerate. Windows: powerPreference is ignored (crbug/369219127) — * --force_high_performance_gpu selects the discrete GPU (the RTX 4070 on the dev box). */ @@ -69,11 +69,14 @@ try { if (hasFfmpeg) { const mp4 = join(DIR, `${PREFIX}.mp4`); spawnSync(FFMPEG, ['-y', '-i', webm, '-movflags', '+faststart', '-pix_fmt', 'yuv420p', '-vf', 'scale=1280:-2', mp4], { stdio: 'ignore' }); + // README-hero recipe: dense full-screen particle motion is worst-case for gif, so trade + // framerate/size for weight — 16fps · 760px · 128 colors · 8s keeps it ~6-7 MB (GitHub-friendly). + // The .mp4/.webm carry the full framerate; the README states the gif is ~16fps. const pal = join(DIR, `${PREFIX}.pal.png`); - const gifVf = 'fps=30,scale=1000:-1:flags=lanczos'; - spawnSync(FFMPEG, ['-y', '-i', webm, '-vf', `${gifVf},palettegen=stats_mode=diff`, pal], { stdio: 'ignore' }); + const gifVf = 'fps=16,scale=760:-1:flags=lanczos'; + spawnSync(FFMPEG, ['-y', '-t', '8', '-i', webm, '-vf', `${gifVf},palettegen=stats_mode=diff:max_colors=128`, pal], { stdio: 'ignore' }); const gif = join(DIR, `${PREFIX}.gif`); - spawnSync(FFMPEG, ['-y', '-i', webm, '-i', pal, '-lavfi', `${gifVf}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=3`, gif], { stdio: 'ignore' }); + spawnSync(FFMPEG, ['-y', '-t', '8', '-i', webm, '-i', pal, '-lavfi', `${gifVf}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5`, gif], { stdio: 'ignore' }); await rm(pal, { force: true }); const sizes = await Promise.all([webm, mp4, gif].map(async (f) => { try { return `${f.split(/[\\/]/).pop()} ${(((await stat(f)).size) / 1e6).toFixed(2)} MB`; } catch { return `${f} (missing)`; } diff --git a/app/src/App.tsx b/app/src/App.tsx index 82dbd14..cf1a7ee 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -8,6 +8,7 @@ import { fetchSpanDetail } from './lib/detail'; import { aggregateCost, summarize, type CostMetric } from './data/cost'; import { Hud } from './ui/Hud'; import { Legend } from './ui/Legend'; +import { LiveNotice } from './ui/LiveNotice'; import { SpanDetail } from './ui/SpanDetail'; import { FlameView, fmt } from './ui/FlameView'; import { Controls, type SourceKey, type ViewKey, REPLAY_FILES } from './ui/Controls'; @@ -23,6 +24,10 @@ function initialSource(): SourceKey { function initialView(): ViewKey { return new URLSearchParams(location.search).get('view') === 'flame' ? 'flame' : 'river'; } +function replayLoadError(source: 'real' | 'sample', e: unknown): string { + const msg = e instanceof Error ? e.message : String(e); + return `couldn't load the replay trace (${REPLAY_FILES[source]}) — ${msg}. Reload to retry; if you're running locally, npm run build first.`; +} export default function App() { const canvasRef = useRef(null); @@ -38,6 +43,7 @@ export default function App() { const [stats, setStats] = useState(null); const [meta, setMeta] = useState(null); const [connected, setConnected] = useState(false); + const [liveStalled, setLiveStalled] = useState(false); // live picked but no gateway answering const [error, setError] = useState(null); const [selected, setSelected] = useState(null); const [detail, setDetail] = useState(null); @@ -64,6 +70,7 @@ export default function App() { setHoverKey(key); if (key) document.querySelector(`.ftrow[data-key="${CSS.escape(key)}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }, + onError: (msg) => { if (!cancelled) setError(msg); }, }); if (source === 'live') { // Aggregate the server's live ring buffer (poll so the flame tracks the stream). @@ -86,11 +93,11 @@ export default function App() { const trace = (await res.json()) as NormalizedTrace; if (cancelled) return; setMeta(trace.meta); setEvents(trace.events); setFlameLoading(false); - } catch (e) { if (!cancelled) setError(String(e)); } + } catch (e) { if (!cancelled) setError(replayLoadError(source as 'real' | 'sample', e)); } })(); } } else if (source === 'live') { - const handle = createRiver(canvas, { mode: 'live', perf: DEBUG }, setStats); + const handle = createRiver(canvas, { mode: 'live', perf: DEBUG, onError: (msg) => { if (!cancelled) setError(msg); } }, setStats); handle.setPaused(paused); handle.setBloom(bloom); handleRef.current = handle; @@ -108,12 +115,12 @@ export default function App() { const trace = (await res.json()) as NormalizedTrace; if (cancelled) return; setMeta(trace.meta); - const handle = createRiver(canvas, { mode: 'replay', trace, perf: DEBUG }, setStats); + const handle = createRiver(canvas, { mode: 'replay', trace, perf: DEBUG, onError: (msg) => { if (!cancelled) setError(msg); } }, setStats); handle.setPaused(paused); handle.setBloom(bloom); handleRef.current = handle; } catch (e) { - if (!cancelled) setError(String(e)); + if (!cancelled) setError(replayLoadError(source as 'real' | 'sample', e)); } })(); } @@ -131,6 +138,16 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [source, view]); + // Live mode with nothing answering is a silent dead-end (EventSource retries forever, the badge + // sits at "connecting…"). After 4s un-connected, surface an honest notice instead. No hard block: + // http://localhost is a potentially-trustworthy origin, so a locally-run server CAN feed the + // hosted demo — the notice self-dismisses the moment a connection lands. + useEffect(() => { + if (source !== 'live' || connected) { setLiveStalled(false); return; } + const t = setTimeout(() => setLiveStalled(true), 4000); + return () => clearTimeout(t); + }, [source, connected, view]); + // Flame aggregation + a HUD summary over the same events (so the HUD reconciles with the flame). const costModel = useMemo(() => aggregateCost(events ?? [], metric), [events, metric]); const flameStats = useMemo(() => { @@ -215,7 +232,10 @@ export default function App() { : 'every particle is one real span · replayed as a live stream'} - + {source === 'live' && !connected && liveStalled && ( + setSource('real')} /> + )} + {!flameView && source !== 'live' && (
diff --git a/app/src/gpu/capabilities.ts b/app/src/gpu/capabilities.ts index 950bae5..0c0dce1 100644 --- a/app/src/gpu/capabilities.ts +++ b/app/src/gpu/capabilities.ts @@ -2,3 +2,8 @@ export function hasWebGPU(): boolean { return typeof navigator !== 'undefined' && 'gpu' in navigator; } + +/** Shared user-facing copy for the adapter-null case (browser has `navigator.gpu` but grants no GPU). */ +export const NO_ADAPTER_MSG = + 'Your browser reports WebGPU, but no GPU adapter was granted — this happens over remote desktop, ' + + 'in VMs, or on blocklisted drivers. Try Chrome/Edge 113+ on hardware with a GPU.'; diff --git a/app/src/gpu/flame.ts b/app/src/gpu/flame.ts index 4f758fa..bacf6aa 100644 --- a/app/src/gpu/flame.ts +++ b/app/src/gpu/flame.ts @@ -1,6 +1,7 @@ import type { CostModel } from '../data/cost'; import { perspective, lookAt, multiply, transformPoint, normalize, cross, type Vec3 } from './mat4'; import { ndcToPixel } from './motion'; +import { NO_ADAPTER_MSG } from './capabilities'; import flameWGSL from './shaders/flame.wgsl?raw'; /** @@ -57,6 +58,7 @@ export function createFlame( onPick?: (key: string | null) => void; onHover?: (key: string | null) => void; // fired only when the hovered bar changes onReady?: (gpuLabel: string) => void; + onError?: (message: string) => void; // adapter denied / init threw — surface, don't blank }, ): FlameHandle { let destroyed = false; @@ -264,7 +266,8 @@ export function createFlame( // ---- GPU init ------------------------------------------------------------------- (async () => { const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' }); - if (!adapter || destroyed) return; + if (destroyed) return; + if (!adapter) { callbacks.onError?.(NO_ADAPTER_MSG); return; } // else: silent blank canvas device = await adapter.requestDevice(); if (destroyed) { device.destroy(); return; } @@ -420,7 +423,10 @@ export function createFlame( raf = requestAnimationFrame(frame); // eslint-disable-next-line no-console console.log('[fathom] flame mode ·', gpuLabel); - })().catch((e) => console.error('[fathom] flame init failed:', e)); + })().catch((e) => { + console.error('[fathom] flame init failed:', e); + if (!destroyed) callbacks.onError?.(`WebGPU init failed — ${e instanceof Error ? e.message : String(e)}`); + }); return { destroy() { diff --git a/app/src/gpu/river.ts b/app/src/gpu/river.ts index 98d0158..213dfbb 100644 --- a/app/src/gpu/river.ts +++ b/app/src/gpu/river.ts @@ -4,6 +4,7 @@ import { makeComet, outcomeOf, COMET_MAX } from '../data/comet'; import { accSaved, newSavedAcc, savedOf } from '../data/cost'; import type { Outcome } from '../data/classify'; import { pickHead, headPos, ndcToPixel, type HeadLike } from './motion'; +import { NO_ADAPTER_MSG } from './capabilities'; import riverWGSL from './shaders/river.wgsl?raw'; import riverSimWGSL from './shaders/river-sim.wgsl?raw'; import { createBloom } from './bloom'; @@ -21,8 +22,8 @@ export interface RiverStats { } export type RiverOpts = - | { mode: 'replay'; trace: NormalizedTrace; perf?: boolean } - | { mode: 'live'; capacity?: number; perf?: boolean }; + | { mode: 'replay'; trace: NormalizedTrace; perf?: boolean; onError?: (message: string) => void } + | { mode: 'live'; capacity?: number; perf?: boolean; onError?: (message: string) => void }; /** M4 perf snapshot — GPU pass times via timestamp-query when available, else frame pacing only. */ export interface RiverPerf { @@ -178,7 +179,8 @@ export function createRiver( (async () => { const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' }); - if (!adapter || destroyed) return; + if (destroyed) return; + if (!adapter) { opts.onError?.(NO_ADAPTER_MSG); return; } // else: silent blank canvas tsq = wantPerf && adapter.features.has('timestamp-query'); device = await adapter.requestDevice(tsq ? { requiredFeatures: ['timestamp-query' as GPUFeatureName] } : undefined); if (destroyed) { device.destroy(); return; } @@ -394,7 +396,10 @@ export function createRiver( raf = requestAnimationFrame(frame); // eslint-disable-next-line no-console console.log('[fathom]', opts.mode, 'mode ·', opts.mode === 'replay' ? `${drawCount.toLocaleString()} particles` : `pool ${capacity.toLocaleString()} (max ${COMET_MAX}/comet)`, '·', gpuLabel); - })().catch((e) => console.error('[fathom] init failed:', e)); + })().catch((e) => { + console.error('[fathom] init failed:', e); + if (!destroyed) opts.onError?.(`WebGPU init failed — ${e instanceof Error ? e.message : String(e)}`); + }); return { destroy() { diff --git a/app/src/styles.css b/app/src/styles.css index d2eba0f..7c1ef2f 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -182,3 +182,54 @@ body { background: linear-gradient(rgba(205, 214, 244, 0.5), transparent); } @media (prefers-reduced-motion: reduce) { .flame-label { transition: none; } } + +/* --- M5 launch polish --------------------------------------------------------- + Small guided moments for a first-time visitor: the river's pick hint, and an honest + notice when `live` is selected with no gateway answering (the hosted-demo case). */ +.legend .row.hint { margin-top: 4px; font-size: 10px; color: #565b74; letter-spacing: 0.04em; } + +.live-notice { + top: 22px; left: 50%; transform: translateX(-50%); + width: 384px; max-width: calc(100vw - 24px); padding: 14px 16px; z-index: 12; + border-top: 1px solid rgba(255, 176, 32, 0.55); /* the fallback lane's amber — "waiting" */ + animation: detail-in 180ms ease-out backwards; +} +.live-notice .ln-title { + font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; color: #f9e2af; margin-bottom: 6px; +} +.live-notice p { margin: 0 0 8px; font-size: 11px; color: #bac2de; } +.live-notice code { + color: #f9e2af; background: rgba(200, 160, 40, 0.1); border-radius: 4px; padding: 0 4px; + overflow-wrap: break-word; /* long URLs/env vars wrap only when they must, not mid-word */ +} +.live-notice .ln-aside { display: block; margin-top: 2px; color: #565b74; font-size: 10px; } +.live-notice .ln-replay { + font: inherit; width: 100%; margin-top: 2px; padding: 6px 10px; cursor: pointer; + color: #10131f; background: #f9e2af; border: 1px solid #f9e2af; border-radius: 6px; font-weight: 600; +} +.live-notice .ln-replay:hover { background: #ffedc2; } +@media (prefers-reduced-motion: reduce) { .live-notice { animation: none; } } + +/* --- M5 minimal mobile pass ---------------------------------------------------- + No layout rearchitecture: tighter insets, the HUD keeps the honesty badge, the GPU + title panel yields, panels clamp to the viewport. Canvas stays the star. */ +@media (max-width: 720px) { + #hud { top: 12px; left: 12px; width: min(272px, calc(100vw - 24px)); padding: 10px 12px; } + #hud .metric .v { font-size: 15px; } + .title-r { display: none; } + .legend { bottom: 12px; left: 12px; padding: 8px 10px; font-size: 10px; } + .controls { bottom: 12px; right: 12px; padding: 8px; flex-wrap: wrap; justify-content: flex-end; max-width: calc(100vw - 24px); } + .detail, .detail.at-left, .detail.at-right { + top: 12px; left: 50%; transform: translateX(-50%); + width: calc(100vw - 24px); max-height: 62vh; + } + .live-notice { top: 12px; } + .flame { top: 12px; right: 12px; width: min(320px, calc(100vw - 24px)); max-height: 46vh; } + /* flame view on a phone: the flame panel IS the data panel — it owns the top, the HUD yields + (same numbers by construction; .flame precedes #hud as a sibling, so ~ selects it) */ + .flame ~ #hud { display: none; } + #timeline { left: 12px; right: 12px; } +} +@media (max-width: 480px) { + .legend { display: none; } /* phones: canvas first — the HUD still labels the data honestly */ +} diff --git a/app/src/ui/Legend.tsx b/app/src/ui/Legend.tsx index 04e34ee..5af04cb 100644 --- a/app/src/ui/Legend.tsx +++ b/app/src/ui/Legend.tsx @@ -5,7 +5,7 @@ const ROWS: Array<{ color: string; label: string }> = [ { color: '#ff3040', label: 'PII caught — blocked in-path (422)' }, ]; -export function Legend() { +export function Legend({ view }: { view: 'river' | 'flame' }) { return (
{ROWS.map((r) => ( @@ -17,6 +17,11 @@ export function Legend() {
shade within a lane = model
+ {view === 'river' && ( +
+ click a comet → its span +
+ )}
); } diff --git a/app/src/ui/LiveNotice.tsx b/app/src/ui/LiveNotice.tsx new file mode 100644 index 0000000..f2ee4c9 --- /dev/null +++ b/app/src/ui/LiveNotice.tsx @@ -0,0 +1,23 @@ +/** + * Shown when `live` is selected but no gateway has answered (M5 polish). On the hosted demo + * this is the common case — there is no public server; live mode listens for one on the + * visitor's own machine. Honest: explains the stuck "connecting…" badge instead of hiding it. + */ +export function LiveNotice({ server, onReplay }: { server: string; onReplay: () => void }) { + const hostedToLocal = location.protocol === 'https:' && server.startsWith('http://localhost'); + return ( +
+
no gateway connected
+

+ Fathom is listening for a live OTLP stream from {server} and hasn't heard + back{hostedToLocal ? ' — this hosted demo can only reach a gateway running on your machine' : ''}. +

+

+ To feed it real spans: npm run server, then point any OTel gateway at it —{' '} + OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4319 + (own deploy? set VITE_FATHOM_SERVER) +

+ +
+ ); +}