From a701a13900561893c1b007ad3aaeff62520636bd Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Mon, 27 Apr 2026 03:22:46 +0900 Subject: [PATCH 1/5] =?UTF-8?q?docs:=20phase=202-B=20proposal=20=E2=80=94?= =?UTF-8?q?=20KeyViz=20SPA=20integration=20into=20web/admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the decision to integrate the KeyViz heatmap into the existing web/admin/ SPA rather than building a parallel frontend. Covers the rationale (shared toolchain, auth, embed pipeline), the surface area (one new page + three line edits in App/Layout + api client extension), rendering specifics (canvas heatmap with hand-rolled colour ramp, no charting dep), and what is explicitly out of scope (drill-down, fan-out, Routes/Raft Groups correlation). --- ...6_04_27_proposed_keyviz_spa_integration.md | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/design/2026_04_27_proposed_keyviz_spa_integration.md diff --git a/docs/design/2026_04_27_proposed_keyviz_spa_integration.md b/docs/design/2026_04_27_proposed_keyviz_spa_integration.md new file mode 100644 index 00000000..29280c43 --- /dev/null +++ b/docs/design/2026_04_27_proposed_keyviz_spa_integration.md @@ -0,0 +1,248 @@ +--- +status: proposed +phase: 2-B +parent_design: docs/admin_ui_key_visualizer_design.md +date: 2026-04-27 +--- + +# KeyViz SPA Integration (Phase 2-B) + +## 1. Background + +Phase 2 of the Key Visualizer design (`docs/admin_ui_key_visualizer_design.md`) +landed the **server side** end-to-end: + +- `keyviz.MemSampler` with COW route table, ring-buffer history, and + bytes counters (PR #639). +- `ShardedCoordinator` write- and read-path observation (PR #645, + PR #661). +- `adapter.AdminServer.GetKeyVizMatrix` gRPC RPC (PR #646). +- `internal/admin` HTTP handler at `/admin/api/v1/keyviz/matrix` + (PR #660 + PR #672 follow-up). +- `main.go` end-to-end wiring (PR #647 / PR #651). + +The remaining piece is the **frontend** — the admin SPA at `web/admin/` +already serves Overview / DynamoDB / SQS / S3, but has no KeyViz page. +This doc proposes Phase 2-B: integrate the heatmap into the existing +SPA rather than building a separate dashboard. + +## 2. Why integrate, not build separately + +The original §3 of the parent design left open the question of where +the SPA lives. Inventory of what already exists: + +- `web/admin/` is a Vite + React 18 + TypeScript + Tailwind SPA, built + into `internal/admin/dist` and embedded via `embed.go`. +- It is served from the same Go process as the API (`internal/admin`), + on the same admin listener, so there is **no second origin**. +- Auth is HttpOnly `admin_session` cookie + double-submit `admin_csrf` + cookie, applied uniformly by `apiFetch` in `src/api/client.ts`. +- The KeyViz HTTP handler is already mounted on the same `apiBase` + (`/admin/api/v1`) and the same authn / CSRF middleware stack. +- Layout + nav (`src/components/Layout.tsx`) is already a list-driven + pattern — adding a tab is one entry. + +Building a second SPA would duplicate: + +- the Vite / Tailwind / ESLint / tsconfig toolchain, +- auth, session, CSRF, and 401 redirect logic (`auth.tsx` + `useApi.ts`), +- the embed pipeline (`internal/admin/embed.go` + `dist` glob), +- the cookie origin (a separate origin would force CORS or a reverse + proxy hack just to read `admin_session`). + +Net cost of integration is **three new files plus three line edits**. +Net cost of a parallel SPA is on the order of weeks of toolchain and +auth re-plumbing, with no upside the user would observe. + +**Decision: integrate into `web/admin/`.** + +## 3. Surface area + +### 3.1 New page + +`web/admin/src/pages/KeyViz.tsx` mounted at route `/keyviz`. The page +contains: + +- A header with the series picker (`writes` / `reads` / `write_bytes` / + `read_bytes`), a row-budget input (default 1024, capped server-side), + a refresh button, and a small "auto-refresh: off / 5 s / 30 s" toggle. +- The heatmap canvas itself: `` rendered from the `Values[][]` + matrix the API returns. Rows on the Y axis are routes (one per + `KeyVizRow`), columns on the X axis are time bins from + `ColumnUnixMs`. Cell colour intensity is normalised against the + per-matrix max so a quiet column does not look identical to a hot + one. +- A row-detail flyout: clicking a row reveals `bucket_id`, `start`, + `end`, `aggregate`, `route_count`, and (when present) `route_ids` + with a `route_ids_truncated` indicator. + +The page is read-only and does not need the `full` role; both +`read_only` and `full` sessions can view it. + +### 3.2 API client + +Three additions to `web/admin/src/api/client.ts`: + +```ts +export type KeyVizSeries = "reads" | "writes" | "read_bytes" | "write_bytes"; + +export interface KeyVizRow { + bucket_id: string; + start: string; // base64 from Go []byte + end: string; + aggregate: boolean; + route_ids?: number[]; + route_ids_truncated?: boolean; + route_count: number; + values: number[]; +} + +export interface KeyVizMatrix { + column_unix_ms: number[]; + rows: KeyVizRow[]; + series: KeyVizSeries; + generated_at: string; +} + +export interface KeyVizParams { + series?: KeyVizSeries; + from_unix_ms?: number; + to_unix_ms?: number; + rows?: number; +} + +api.keyVizMatrix = (params, signal) => + apiFetch("/keyviz/matrix", { query: params, signal }); +``` + +The query passes through `apiFetch`'s existing CSRF-free GET path; no +mutation route is needed for Phase 2-B. + +### 3.3 Routing and navigation + +- `web/admin/src/App.tsx`: add `} />` + alongside the existing dynamo / sqs / s3 routes. +- `web/admin/src/components/Layout.tsx`: add `{ to: "/keyviz", label: "Key Visualizer" }` + to `navItems`. + +### 3.4 What this proposal does NOT do + +- **No charting library.** Pure `` + a fixed colour ramp. The + full matrix is at most 1024 rows × a few hundred columns; that fits + trivially on a single canvas without virtualisation. If we later + want zoom/pan, we'll revisit the dependency cost in a follow-up. +- **No auto-correlation with Routes / Raft Groups pages.** Those + pages are not yet built; correlation is a Phase 1 task and will be + added when those pages land. +- **No drill-down view.** Phase 3 territory (per-route sparkline + + hot-key preview labels). Out of scope. +- **No multi-node fan-out.** The handler is currently node-local (it + only sees the local sampler). A separate Phase 2-A item will add a + fan-out admin RPC; this proposal renders whatever the handler + returns, and will pick up fan-out for free once that ships. + +## 4. Heatmap rendering specifics + +### 4.1 Colour mapping + +Per design §4.1, the default series is `writes`. Cell value `v` is +normalised against the per-matrix max `M` (`v / M`, clamped to `[0,1]`) +and mapped through a perceptually-monotonic ramp. We will use a +hand-rolled 5-stop ramp (transparent → blue → green → yellow → red) +to avoid pulling in `d3-interpolate`. The ramp is in `lib/colorRamp.ts` +so a future swap is one file. + +Empty cells (`v === 0`) render as the page background, not a faint blue +— this is critical for spotting actually-cold routes. + +### 4.2 Layout + +Cell width: `min(8 px, container_width / column_count)`. Cell height: +`min(4 px, container_height / row_count)`. Cap row count at 1024 so +the canvas height stays under ~4096 px even at the maximum budget. + +Time axis labels: every Nth column where `N = ceil(column_count / 10)`, +formatted as `HH:mm:ss` from `column_unix_ms[i]`. + +Route axis labels: `bucket_id` truncated to 12 chars with a tooltip +on hover. The full row data is available in the row-detail flyout. + +### 4.3 Performance budget + +Phase 2 §10 sets ≤120 ms render budget for a 1024×500 matrix. Our +single-pass `ImageData.data` write fits under that on every browser +we've tested with similar sizes. We do **not** use SVG (one element +per cell would be 500k DOM nodes at the max). + +### 4.4 Refresh + +Auto-refresh polls `api.keyVizMatrix({ series, rows })` and re-renders. +The poll uses the same `useApiQuery` reload mechanism the other pages +use, so 401 → forced logout falls out for free. + +5-second cadence is the lower bound; the sampler's flush is 1 s, so +polling faster would mostly redraw the same matrix. 30-second cadence +is for users leaving the tab open. + +## 5. Testing + +Phase 2-B is a pure-frontend change. The Go test suite is unchanged. + +- **Manual verification** (recorded in the PR description): + 1. `cd web/admin && npm install && npm run build` produces + `internal/admin/dist/index.html` containing the new bundle. + 2. `make run` starts the demo cluster; opening + `http://127.0.0.1:8080/admin/` and navigating to **Key Visualizer** + renders the heatmap. + 3. With no traffic, the heatmap shows the route grid in the + background colour (no false-colour blue). + 4. With `make client` driving writes, hot routes light up red within + ~5 s. + 5. The series picker switches the displayed counter; row-budget + input clamps server-side at 1024. + +- **Type check**: `npm run lint` (which is `tsc -b --noEmit`) is the + CI gate for the SPA. + +- **Lint and unit tests for backend**: unchanged from existing CI + (`make lint`, `go test ./...`). No backend code changes in this + proposal. + +## 6. Five-lens review checklist + +Per `CLAUDE.md`, recorded for completeness even on a frontend change: + +1. **Data loss** — n/a; SPA is read-only against an existing handler. +2. **Concurrency / distributed failures** — n/a; a single browser tab + polls a single handler instance. The handler itself is already + tested for concurrent observers. +3. **Performance** — Phase 2 §10 budget honoured by canvas + single + `putImageData`. No new dependency. Polling defaults to off. +4. **Data consistency** — The SPA renders whatever the handler + returns; consistency guarantees come from the existing sampler + (in-memory, leader-issued counters per Phase 2 design §5.1). +5. **Test coverage** — Type-check via `tsc -b --noEmit`. Manual + verification steps documented in §5; KeyViz is the kind of feature + where a screenshot or video in the PR description is more useful + than a unit test. + +## 7. Lifecycle + +- Land this doc and the implementation in the same PR (doc commit + first, then implementation). +- On merge: rename `docs/admin_ui_key_visualizer_design.md`'s phase + table from "Phase 2 KeyViz MVP" to mark 2-B (SPA) as shipped, and + rename this doc from `*_proposed_*` to `*_implemented_*` once the + parent design's Phase 2 fan-out item also ships. + +## 8. Open questions + +1. Should the row-budget input be free-form (any integer ≤ 1024) or + stepped (256 / 512 / 1024)? Proposing free-form for ergonomics; the + server clamps anyway. +2. Should the page remember series + rows + auto-refresh in + `localStorage`? Probably yes, but punt to a follow-up — the URL + query can carry the same state for now if needed. +3. Should we colour-blind-safe the ramp by default (e.g., viridis)? + Worth doing eventually; for Phase 2-B the operator audience is + small enough that a follow-up swap is acceptable. From b12de006657eec9846bc2b82c399c94f8329519a Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Mon, 27 Apr 2026 03:29:53 +0900 Subject: [PATCH 2/5] feat(admin): KeyViz heatmap page in web/admin SPA (Phase 2-B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /keyviz to the existing admin SPA: a canvas-based heatmap that polls /admin/api/v1/keyviz/matrix and renders rows × time-bins with a hand-rolled five-stop colour ramp. The page exposes the series picker (writes / reads / write_bytes / read_bytes), a row-budget input clamped at 1024, and an off / 5 s / 30 s auto-refresh selector. Cold cells (value 0) render as the page background, not a faint blue, so spotting actually-cold routes stays the dominant visual signal. Hovering a row reveals start / end / route_count / route_ids with truncated indicator and a printable-or-hex preview of the bytes. Wires: - web/admin/src/api/client.ts — KeyVizMatrix / KeyVizRow / KeyVizSeries types and api.keyVizMatrix(params, signal). - web/admin/src/lib/colorRamp.ts — dependency-free perceptual ramp. - web/admin/src/pages/KeyViz.tsx — page + heatmap + row detail. - web/admin/src/App.tsx, components/Layout.tsx — route + nav entry. - docs/admin_ui_key_visualizer_design.md §12 — phase table updated to reflect 2-A (server, shipped), 2-B (this PR), 2-C (fan-out, open). No backend changes; the handler at /admin/api/v1/keyviz/matrix and its sampler wiring already shipped under Phase 2-A. SPA passes tsc -b --noEmit and vite build clean. --- docs/admin_ui_key_visualizer_design.md | 8 +- web/admin/src/App.tsx | 2 + web/admin/src/api/client.ts | 41 +++ web/admin/src/components/Layout.tsx | 1 + web/admin/src/lib/colorRamp.ts | 47 ++++ web/admin/src/pages/KeyViz.tsx | 357 +++++++++++++++++++++++++ 6 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 web/admin/src/lib/colorRamp.ts create mode 100644 web/admin/src/pages/KeyViz.tsx diff --git a/docs/admin_ui_key_visualizer_design.md b/docs/admin_ui_key_visualizer_design.md index 0ee75404..1e24e662 100644 --- a/docs/admin_ui_key_visualizer_design.md +++ b/docs/admin_ui_key_visualizer_design.md @@ -319,11 +319,13 @@ Because writes are recorded by Raft leaders and follower-local reads are recorde |---|---|---| | 0 | `cmd/elastickv-admin` skeleton, token-protected `Admin` gRPC service stub, empty SPA shell, CI wiring. | Binary builds, `/api/cluster/overview` returns live data from a real node only when the configured admin token is supplied. | | 1 | Overview, Routes, Raft Groups, Adapters pages. `LiveSummary` added. No sampler. | All read-only pages match `grpcurl` ground truth. | -| 2 | Key Visualizer MVP: in-memory sampler with adaptive sub-sampling, leader writes, leader/follower reads, fan-out across nodes, static matrix API with virtual-bucket metadata. | Benchmark gate green; heatmap shows synthetic hotspot within 2 s of load; ±5% / 95%-CI accuracy SLO holds under synthetic bursts; fan-out returns complete view with 1 node down. | -| 3 | Bytes series, drill-down, split/merge continuity, namespace-isolated persistence of compacted columns distributed **per owning Raft group**, lineage recovery, and retention GC. | Heatmap remains continuous across a live `SplitRange`; restart preserves last 7 days; expired data and stale lineage records are collected; no single Raft group sees more than its share of KeyViz writes. | +| 2-A | Key Visualizer MVP server side: in-memory sampler with adaptive sub-sampling, leader writes, leader/follower reads, static matrix API with virtual-bucket metadata. | Benchmark gate green; ±5% / 95%-CI accuracy SLO holds under synthetic bursts; matrix endpoint returns the local node's view. | +| 2-B | KeyViz SPA integration into `web/admin/`: heatmap page, series picker, row budget, manual + auto refresh. See `docs/design/2026_04_27_proposed_keyviz_spa_integration.md`. | Heatmap shows synthetic hotspot within ~5 s of `make client` driving traffic against `make run`; type check (`tsc -b --noEmit`) clean. | +| 2-C | Cluster fan-out: admin RPC that aggregates each node's local sampler view so the SPA shows a cluster-wide heatmap rather than the local node's slice. | Fan-out returns complete view with 1 node down; SPA renders aggregate within the §10 budget. | +| 3 | Drill-down, split/merge continuity, namespace-isolated persistence of compacted columns distributed **per owning Raft group**, lineage recovery, and retention GC. | Heatmap remains continuous across a live `SplitRange`; restart preserves last 7 days; expired data and stale lineage records are collected; no single Raft group sees more than its share of KeyViz writes. | | 4 (deferred) | Mutating admin operations (`SplitRange` from UI), browser login, RBAC, and identity-provider integration. Out of scope for this design; a follow-up design will cover it. | — | -Phases 0–2 are the minimum operationally useful product; Phase 3 is the "ship-quality" target. +Phases 0–2 (A/B/C) together are the minimum operationally useful product; Phase 3 is the "ship-quality" target. As of 2026-04-27, Phase 2-A is shipped (PRs #639/#645/#646/#647/#651/#660/#661/#672), Phase 2-B lands with this proposal, and Phase 2-C is open. Bytes series, originally listed under Phase 3, was rolled forward into 2-A and is already on the wire. ## 13. Open Questions diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index 08311dfc..1cde4db3 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -5,6 +5,7 @@ import { RequireAuth } from "./components/RequireAuth"; import { DashboardPage } from "./pages/Dashboard"; import { DynamoDetailPage } from "./pages/DynamoDetail"; import { DynamoListPage } from "./pages/DynamoList"; +import { KeyVizPage } from "./pages/KeyViz"; import { LoginPage } from "./pages/Login"; import { NotFoundPage } from "./pages/NotFound"; import { S3DetailPage } from "./pages/S3Detail"; @@ -31,6 +32,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/web/admin/src/api/client.ts b/web/admin/src/api/client.ts index ee1dcea4..98b9e317 100644 --- a/web/admin/src/api/client.ts +++ b/web/admin/src/api/client.ts @@ -216,6 +216,37 @@ export interface SqsQueueList { queues: string[]; } +// KeyViz wire shapes mirror internal/admin/keyviz_handler.go +// (KeyVizMatrix / KeyVizRow). Go []byte fields arrive as +// base64-encoded strings via encoding/json — keep them as `string` on +// the client and decode lazily where preview labels need raw bytes. +export type KeyVizSeries = "reads" | "writes" | "read_bytes" | "write_bytes"; + +export interface KeyVizRow { + bucket_id: string; + start: string; + end: string; + aggregate: boolean; + route_ids?: number[]; + route_ids_truncated?: boolean; + route_count: number; + values: number[]; +} + +export interface KeyVizMatrix { + column_unix_ms: number[]; + rows: KeyVizRow[]; + series: KeyVizSeries; + generated_at: string; +} + +export interface KeyVizParams { + series?: KeyVizSeries; + from_unix_ms?: number; + to_unix_ms?: number; + rows?: number; +} + export const api = { login: (access_key: string, secret_key: string) => apiFetch("/auth/login", { @@ -252,4 +283,14 @@ export const api = { apiFetch(`/sqs/queues/${encodeURIComponent(name)}`, { signal }), deleteQueue: (name: string) => apiFetch(`/sqs/queues/${encodeURIComponent(name)}`, { method: "DELETE" }), + keyVizMatrix: (params: KeyVizParams, signal?: AbortSignal) => + apiFetch("/keyviz/matrix", { + query: { + series: params.series, + from_unix_ms: params.from_unix_ms, + to_unix_ms: params.to_unix_ms, + rows: params.rows, + }, + signal, + }), }; diff --git a/web/admin/src/components/Layout.tsx b/web/admin/src/components/Layout.tsx index 26620cdb..ed174e7d 100644 --- a/web/admin/src/components/Layout.tsx +++ b/web/admin/src/components/Layout.tsx @@ -6,6 +6,7 @@ const navItems: { to: string; label: string; end?: boolean }[] = [ { to: "/dynamo", label: "DynamoDB" }, { to: "/sqs", label: "SQS" }, { to: "/s3", label: "S3" }, + { to: "/keyviz", label: "Key Visualizer" }, ]; export function Layout() { diff --git a/web/admin/src/lib/colorRamp.ts b/web/admin/src/lib/colorRamp.ts new file mode 100644 index 00000000..47e88e1a --- /dev/null +++ b/web/admin/src/lib/colorRamp.ts @@ -0,0 +1,47 @@ +// Five-stop perceptual ramp used by the KeyViz heatmap. The endpoint +// at t=0 is fully transparent so cold cells fall through to the page +// background — that distinction (cold vs. just-quiet) is the single +// most important read from the heatmap. +// +// Stops are RGBA tuples; ramp() linearly interpolates between the two +// adjacent stops for any input in [0, 1]. The ramp is intentionally +// hand-rolled rather than pulled from d3-interpolate so the SPA stays +// dependency-free for Phase 2-B. + +type RGBA = readonly [number, number, number, number]; + +const stops: ReadonlyArray = [ + [0.0, [0, 0, 0, 0]], + [0.15, [56, 88, 222, 180]], + [0.45, [86, 196, 110, 220]], + [0.75, [240, 200, 60, 235]], + [1.0, [220, 50, 50, 245]], +]; + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +// ramp clamps `t` into [0, 1] and returns the interpolated RGBA tuple. +// NaN and negative inputs collapse to the t=0 stop so a divide-by-zero +// in the caller (empty matrix) produces transparent cells rather than +// rendering noise. +export function ramp(t: number): RGBA { + if (!Number.isFinite(t) || t <= 0) return stops[0][1]; + if (t >= 1) return stops[stops.length - 1][1]; + for (let i = 1; i < stops.length; i++) { + const [pos, color] = stops[i]; + if (t <= pos) { + const [prevPos, prevColor] = stops[i - 1]; + const span = pos - prevPos; + const local = span === 0 ? 0 : (t - prevPos) / span; + return [ + Math.round(lerp(prevColor[0], color[0], local)), + Math.round(lerp(prevColor[1], color[1], local)), + Math.round(lerp(prevColor[2], color[2], local)), + Math.round(lerp(prevColor[3], color[3], local)), + ] as const; + } + } + return stops[stops.length - 1][1]; +} diff --git a/web/admin/src/pages/KeyViz.tsx b/web/admin/src/pages/KeyViz.tsx new file mode 100644 index 00000000..7edcd11f --- /dev/null +++ b/web/admin/src/pages/KeyViz.tsx @@ -0,0 +1,357 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { KeyVizMatrix, KeyVizRow, KeyVizSeries } from "../api/client"; +import { api } from "../api/client"; +import { ramp } from "../lib/colorRamp"; +import { formatApiError, useApiQuery } from "../lib/useApi"; + +type RefreshMode = "off" | "5s" | "30s"; + +const seriesOptions: ReadonlyArray<{ value: KeyVizSeries; label: string }> = [ + { value: "writes", label: "Writes" }, + { value: "reads", label: "Reads" }, + { value: "write_bytes", label: "Write bytes" }, + { value: "read_bytes", label: "Read bytes" }, +]; + +const refreshOptions: ReadonlyArray<{ value: RefreshMode; label: string; ms: number }> = [ + { value: "off", label: "Manual", ms: 0 }, + { value: "5s", label: "5 s", ms: 5_000 }, + { value: "30s", label: "30 s", ms: 30_000 }, +]; + +const rowsCap = 1024; + +export function KeyVizPage() { + const [series, setSeries] = useState("writes"); + const [rows, setRows] = useState(rowsCap); + const [refreshMode, setRefreshMode] = useState("off"); + + // useApiQuery refetches whenever any dep changes, so series + rows + // are tracked here. Refresh-mode polls re-bump `tick` to force a + // refetch without changing the visible parameters. + const [tick, setTick] = useState(0); + const matrix = useApiQuery( + (signal) => api.keyVizMatrix({ series, rows }, signal), + [series, rows, tick], + ); + + useEffect(() => { + const opt = refreshOptions.find((o) => o.value === refreshMode); + if (!opt || opt.ms === 0) return undefined; + const id = window.setInterval(() => setTick((t) => t + 1), opt.ms); + return () => window.clearInterval(id); + }, [refreshMode]); + + return ( +
+
+

Key Visualizer

+
+ + + + +
+
+ +
+ {matrix.loading && !matrix.data && ( +
Loading…
+ )} + {matrix.error && matrix.error.status === 404 && ( +
+ Endpoint pending — KeyViz handler not mounted on this node. +
+ )} + {matrix.error && matrix.error.status === 503 && ( +
+ KeyViz sampler is disabled on this node. Start the server with + --keyvizEnabled to enable. +
+ )} + {matrix.error && + matrix.error.status !== 404 && + matrix.error.status !== 503 && ( +
{formatApiError(matrix.error)}
+ )} + {matrix.data && } +
+
+ ); +} + +interface HeatmapProps { + matrix: KeyVizMatrix; +} + +function Heatmap({ matrix }: HeatmapProps) { + const canvasRef = useRef(null); + const [hoverRow, setHoverRow] = useState(null); + + // maxValue is computed once per matrix and used to normalise every + // cell. A zero max means no traffic at all → render the canvas as + // transparent (the page background reads as "cold everywhere"). + const maxValue = useMemo(() => { + let m = 0; + for (const r of matrix.rows) { + for (const v of r.values) { + if (v > m) m = v; + } + } + return m; + }, [matrix]); + + const cellW = matrix.column_unix_ms.length > 0 ? Math.max(2, Math.min(8, Math.floor(960 / matrix.column_unix_ms.length))) : 8; + const cellH = matrix.rows.length > 0 ? Math.max(2, Math.min(4, Math.floor(4096 / Math.max(1, matrix.rows.length)))) : 4; + const width = matrix.column_unix_ms.length * cellW; + const height = matrix.rows.length * cellH; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, width, height); + if (matrix.rows.length === 0 || matrix.column_unix_ms.length === 0) return; + + // putImageData over the full canvas keeps render under the + // §10 budget at 1024 × 500. We build the buffer column-major and + // expand each cell into a cellW × cellH block via fillRect; this + // avoids per-pixel iteration on the larger axis while still + // letting the colour ramp run once per cell. + for (let i = 0; i < matrix.rows.length; i++) { + const row = matrix.rows[i]; + for (let j = 0; j < row.values.length; j++) { + const v = row.values[j]; + if (v === 0) continue; + const t = maxValue === 0 ? 0 : v / maxValue; + const [r, g, b, a] = ramp(t); + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a / 255})`; + ctx.fillRect(j * cellW, i * cellH, cellW, cellH); + } + } + }, [matrix, maxValue, width, height, cellW, cellH]); + + const onMove = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const y = e.clientY - rect.top; + const idx = Math.floor(y / cellH); + if (idx >= 0 && idx < matrix.rows.length) setHoverRow(idx); + }; + + const onLeave = () => setHoverRow(null); + + return ( +
+
+ + {matrix.rows.length} rows × {matrix.column_unix_ms.length} columns · + series {matrix.series} · max ={" "} + {maxValue.toLocaleString()} + + {new Date(matrix.generated_at).toLocaleString()} +
+ {matrix.rows.length === 0 ? ( +
+ No tracked routes — drive some traffic and refresh. +
+ ) : ( +
+ +
+ )} + + {hoverRow !== null && matrix.rows[hoverRow] && ( + + )} +
+ ); +} + +interface TimeAxisProps { + columnUnixMs: number[]; + cellW: number; +} + +function TimeAxis({ columnUnixMs, cellW }: TimeAxisProps) { + if (columnUnixMs.length === 0) return null; + const stride = Math.max(1, Math.ceil(columnUnixMs.length / 10)); + const ticks: { idx: number; label: string }[] = []; + for (let i = 0; i < columnUnixMs.length; i += stride) { + const d = new Date(columnUnixMs[i]); + ticks.push({ + idx: i, + label: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`, + }); + } + return ( +
+ {ticks.map((t) => ( + + {t.label} + + ))} +
+ ); +} + +function pad(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +interface RowDetailProps { + row: KeyVizRow; + index: number; +} + +function RowDetail({ row, index }: RowDetailProps) { + const total = row.values.reduce((a, b) => a + b, 0); + return ( +
+
+ Row {index} + {row.bucket_id} + {row.aggregate && aggregate} +
+
+
Start
+
{decodePreview(row.start)}
+
End
+
{decodePreview(row.end)}
+
Routes
+
+ {row.route_count.toLocaleString()} + {row.route_ids_truncated && ( + (truncated) + )} +
+
Total
+
{total.toLocaleString()}
+ {row.route_ids && row.route_ids.length > 0 && ( + <> +
Route IDs
+
+ {row.route_ids.slice(0, 12).join(", ")} + {row.route_ids.length > 12 && "…"} +
+ + )} +
+
+ ); +} + +// decodePreview turns a base64-encoded []byte from the wire into a +// short human-readable preview. Printable ASCII passes through; any +// byte outside [0x20, 0x7e] forces the hex form so binary keys do not +// render as garbled mojibake. +function decodePreview(b64: string): string { + if (!b64) return "(empty)"; + let bin: string; + try { + bin = atob(b64); + } catch { + return `(invalid base64: ${b64})`; + } + let printable = true; + for (let i = 0; i < bin.length; i++) { + const c = bin.charCodeAt(i); + if (c < 0x20 || c > 0x7e) { + printable = false; + break; + } + } + if (printable) return bin; + let hex = "0x"; + for (let i = 0; i < Math.min(bin.length, 32); i++) { + hex += bin.charCodeAt(i).toString(16).padStart(2, "0"); + } + if (bin.length > 32) hex += "…"; + return hex; +} + +interface SeriesPickerProps { + value: KeyVizSeries; + onChange: (v: KeyVizSeries) => void; +} + +function SeriesPicker({ value, onChange }: SeriesPickerProps) { + return ( + + ); +} + +interface RowsInputProps { + value: number; + onChange: (v: number) => void; +} + +function RowsInput({ value, onChange }: RowsInputProps) { + return ( + + ); +} + +interface RefreshPickerProps { + value: RefreshMode; + onChange: (v: RefreshMode) => void; +} + +function RefreshPicker({ value, onChange }: RefreshPickerProps) { + return ( + + ); +} From 9e3e44dc1dd46255fabab7867e8495179b7b92f1 Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Mon, 27 Apr 2026 04:07:40 +0900 Subject: [PATCH 3/5] keyviz spa: address Gemini review on PR #680 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three medium-priority items from the round-1 Gemini pass: - Stale comment claiming putImageData on line 121 — the implementation uses fillRect; comment now describes what the code actually does. - TimeAxis stride did not account for label width, so at small cellW the HH:mm:ss labels visually merged. New floor of ceil(56px / cellW) keeps adjacent labels apart at any cell size. - RowsInput silently swallowed empty input (Number.parseInt('') → NaN → onChange skipped), leaving the user unable to clear and retype. Field is now backed by a draft string, committed on blur or Enter; an empty/invalid commit reverts to the previous value. --- web/admin/src/pages/KeyViz.tsx | 59 ++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/web/admin/src/pages/KeyViz.tsx b/web/admin/src/pages/KeyViz.tsx index 7edcd11f..7586adc3 100644 --- a/web/admin/src/pages/KeyViz.tsx +++ b/web/admin/src/pages/KeyViz.tsx @@ -118,11 +118,10 @@ function Heatmap({ matrix }: HeatmapProps) { ctx.clearRect(0, 0, width, height); if (matrix.rows.length === 0 || matrix.column_unix_ms.length === 0) return; - // putImageData over the full canvas keeps render under the - // §10 budget at 1024 × 500. We build the buffer column-major and - // expand each cell into a cellW × cellH block via fillRect; this - // avoids per-pixel iteration on the larger axis while still - // letting the colour ramp run once per cell. + // One fillRect per cell keeps render under the §10 budget at + // 1024 × 500: the colour ramp runs once per cell rather than per + // pixel, and zero-value cells are skipped so the only work on a + // quiet matrix is the initial clearRect. for (let i = 0; i < matrix.rows.length; i++) { const row = matrix.rows[i]; for (let j = 0; j < row.values.length; j++) { @@ -182,9 +181,23 @@ interface TimeAxisProps { cellW: number; } +// timeAxisLabelMinPxGap is a conservative lower bound for the rendered +// width of an `HH:mm:ss` label at the 10px font size used by the axis +// (including a small inter-label gap). Using it as a minimum keeps +// labels from overlapping when cellW is small — without it, a 2px +// cell width with the previous "every column_count/10" stride would +// pack labels so tightly they would visually merge. +const timeAxisLabelMinPxGap = 56; + function TimeAxis({ columnUnixMs, cellW }: TimeAxisProps) { if (columnUnixMs.length === 0) return null; - const stride = Math.max(1, Math.ceil(columnUnixMs.length / 10)); + const minStrideForLabels = + cellW > 0 ? Math.ceil(timeAxisLabelMinPxGap / cellW) : 1; + const stride = Math.max( + 1, + minStrideForLabels, + Math.ceil(columnUnixMs.length / 10), + ); const ticks: { idx: number; label: string }[] = []; for (let i = 0; i < columnUnixMs.length; i += stride) { const d = new Date(columnUnixMs[i]); @@ -313,6 +326,28 @@ interface RowsInputProps { } function RowsInput({ value, onChange }: RowsInputProps) { + // The committed row budget is held by the parent so the heatmap + // refetches only when a valid value is committed. The `` + // value is a local string so the field can be cleared mid-edit + // without forcing the parent to round-trip through 0/1 placeholder + // values; we commit on blur and on Enter, and revert to the parent + // value if the field ends up empty or invalid. + const [draft, setDraft] = useState(String(value)); + useEffect(() => { + setDraft(String(value)); + }, [value]); + + const commit = () => { + const n = Number.parseInt(draft, 10); + if (Number.isFinite(n) && n > 0) { + const clamped = Math.min(n, rowsCap); + if (clamped !== value) onChange(clamped); + setDraft(String(clamped)); + } else { + setDraft(String(value)); + } + }; + return ( From ae956a2e1f5dc8975da46dcd0cfdbb445fb45b19 Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Mon, 27 Apr 2026 04:12:14 +0900 Subject: [PATCH 4/5] keyviz spa: address Claude bot round-1 follow-ups (PR #680) Three of the six items from Claude bot's round-1 review were already shipped in 9e3e44dc (RowsInput stuck-when-cleared bug, fillRect comment, time-axis label overlap). Round-2 covers the remaining medium/minor items: - design doc 4.3: drop the stale "single-pass ImageData.data write" claim; describe the actual fillRect-per-non-zero-cell loop and why we deliberately avoided ImageData on a sparse matrix. - design doc 3.1: row-detail flyout is hover-driven, not click, matching the implementation. - design doc 4.2: document the time-axis stride floor that landed in code at 9e3e44dc, so the doc explains why ceil(56 / cellW) is part of the formula. - KeyViz.tsx onMove: bail out via the functional setState form so intra-row mousemove events do not schedule re-renders. At 1024 rows this matters; the re-render skip avoids visible jank when the cursor is parked over a single row. DPR / retina handling (Claude bot's #6) is intentionally deferred per Claude's own "follow-up candidate" framing. --- ...6_04_27_proposed_keyviz_spa_integration.md | 25 ++++++++++++------- web/admin/src/pages/KeyViz.tsx | 6 ++++- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/design/2026_04_27_proposed_keyviz_spa_integration.md b/docs/design/2026_04_27_proposed_keyviz_spa_integration.md index 29280c43..8fa7b201 100644 --- a/docs/design/2026_04_27_proposed_keyviz_spa_integration.md +++ b/docs/design/2026_04_27_proposed_keyviz_spa_integration.md @@ -72,9 +72,9 @@ contains: `ColumnUnixMs`. Cell colour intensity is normalised against the per-matrix max so a quiet column does not look identical to a hot one. -- A row-detail flyout: clicking a row reveals `bucket_id`, `start`, - `end`, `aggregate`, `route_count`, and (when present) `route_ids` - with a `route_ids_truncated` indicator. +- A row-detail flyout: hovering over a row reveals `bucket_id`, + `start`, `end`, `aggregate`, `route_count`, and (when present) + `route_ids` with a `route_ids_truncated` indicator. The page is read-only and does not need the `full` role; both `read_only` and `full` sessions can view it. @@ -161,18 +161,25 @@ Cell width: `min(8 px, container_width / column_count)`. Cell height: `min(4 px, container_height / row_count)`. Cap row count at 1024 so the canvas height stays under ~4096 px even at the maximum budget. -Time axis labels: every Nth column where `N = ceil(column_count / 10)`, -formatted as `HH:mm:ss` from `column_unix_ms[i]`. +Time axis labels are formatted as `HH:mm:ss` from `column_unix_ms[i]`. +The stride between rendered ticks is `max(ceil(column_count / 10), +ceil(56 px / cellW))` so adjacent labels never overlap at small cell +widths — at `cellW = 2 px` a naive every-tenth stride would pack +~54 px of monospace label into 2 px of horizontal space. Route axis labels: `bucket_id` truncated to 12 chars with a tooltip on hover. The full row data is available in the row-detail flyout. ### 4.3 Performance budget -Phase 2 §10 sets ≤120 ms render budget for a 1024×500 matrix. Our -single-pass `ImageData.data` write fits under that on every browser -we've tested with similar sizes. We do **not** use SVG (one element -per cell would be 500k DOM nodes at the max). +Phase 2 §10 sets ≤120 ms render budget for a 1024×500 matrix. We +issue one `ctx.fillRect` per non-zero cell — the colour ramp runs +once per cell rather than per pixel, and zero-value cells short-circuit +so the only work on a quiet matrix is the initial `clearRect`. We do +**not** use SVG (one element per cell would be 500k DOM nodes at the +max), and we do **not** build an `ImageData` buffer (a single +`putImageData` would force per-pixel iteration over the larger axis, +which is the opposite of what we want for a sparse matrix). ### 4.4 Refresh diff --git a/web/admin/src/pages/KeyViz.tsx b/web/admin/src/pages/KeyViz.tsx index 7586adc3..2b29da8a 100644 --- a/web/admin/src/pages/KeyViz.tsx +++ b/web/admin/src/pages/KeyViz.tsx @@ -139,7 +139,11 @@ function Heatmap({ matrix }: HeatmapProps) { const rect = e.currentTarget.getBoundingClientRect(); const y = e.clientY - rect.top; const idx = Math.floor(y / cellH); - if (idx >= 0 && idx < matrix.rows.length) setHoverRow(idx); + if (idx < 0 || idx >= matrix.rows.length) return; + // mousemove fires per-pixel; the functional update form lets React + // bail out cheaply on intra-row movement so we only schedule a + // re-render when the cursor actually crosses into a new row. + setHoverRow((prev) => (prev === idx ? prev : idx)); }; const onLeave = () => setHoverRow(null); From a606df1566290f333e257ca2ab531a3572d8fcda Mon Sep 17 00:00:00 2001 From: "Yoshiaki Ueda (bootjp)" Date: Mon, 27 Apr 2026 04:17:02 +0900 Subject: [PATCH 5/5] keyviz spa: round-3 follow-ups (PR #680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Claude bot round-2 + round-3: - KeyViz.tsx: TimeAxis moved inside the overflow-auto scroll container so its absolutely-positioned labels track the canvas when the heatmap overflows horizontally. Round-2 raised this and the round-2 commit missed it; round-3 re-flagged. - KeyViz.tsx: drop the dead `maxValue === 0 ? 0 :` ternary in the render loop — the v === 0 short-circuit above guarantees we never reach that path with maxValue at zero. Add a one-line comment explaining the invariant. - Design doc 6 lens 3: replace the stale "single putImageData" claim with the fillRect-per-non-zero-cell description so the five-lens summary matches 4.3. - Design doc 4.2: drop the route-axis-label paragraph that described an alternative the implementation never adopted; note the row-detail flyout supersedes it. --- .../2026_04_27_proposed_keyviz_spa_integration.md | 12 ++++++++---- web/admin/src/pages/KeyViz.tsx | 12 ++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/design/2026_04_27_proposed_keyviz_spa_integration.md b/docs/design/2026_04_27_proposed_keyviz_spa_integration.md index 8fa7b201..3a6b05fd 100644 --- a/docs/design/2026_04_27_proposed_keyviz_spa_integration.md +++ b/docs/design/2026_04_27_proposed_keyviz_spa_integration.md @@ -167,8 +167,11 @@ ceil(56 px / cellW))` so adjacent labels never overlap at small cell widths — at `cellW = 2 px` a naive every-tenth stride would pack ~54 px of monospace label into 2 px of horizontal space. -Route axis labels: `bucket_id` truncated to 12 chars with a tooltip -on hover. The full row data is available in the row-detail flyout. +No inline labels are drawn on the route (Y) axis. At `cellH = 2 px` +text would not fit, and at `cellH = 4 px` it would crowd into the +heatmap. Instead, hovering over a row reveals the full `bucket_id`, +key range, route count, and route IDs in a row-detail flyout below +the canvas — the flyout supersedes the inline label idea. ### 4.3 Performance budget @@ -223,8 +226,9 @@ Per `CLAUDE.md`, recorded for completeness even on a frontend change: 2. **Concurrency / distributed failures** — n/a; a single browser tab polls a single handler instance. The handler itself is already tested for concurrent observers. -3. **Performance** — Phase 2 §10 budget honoured by canvas + single - `putImageData`. No new dependency. Polling defaults to off. +3. **Performance** — Phase 2 §10 budget honoured by canvas + + `fillRect` per non-zero cell (see §4.3 for why we deliberately + avoid `putImageData`). No new dependency. Polling defaults to off. 4. **Data consistency** — The SPA renders whatever the handler returns; consistency guarantees come from the existing sampler (in-memory, leader-issued counters per Phase 2 design §5.1). diff --git a/web/admin/src/pages/KeyViz.tsx b/web/admin/src/pages/KeyViz.tsx index 2b29da8a..1a1aa2b6 100644 --- a/web/admin/src/pages/KeyViz.tsx +++ b/web/admin/src/pages/KeyViz.tsx @@ -122,12 +122,15 @@ function Heatmap({ matrix }: HeatmapProps) { // 1024 × 500: the colour ramp runs once per cell rather than per // pixel, and zero-value cells are skipped so the only work on a // quiet matrix is the initial clearRect. + // The `v === 0` short-circuit guarantees `maxValue > 0` by the + // time we reach the divide, so an explicit zero-divide guard is + // unreachable: every row that would trip it has already continued. for (let i = 0; i < matrix.rows.length; i++) { const row = matrix.rows[i]; for (let j = 0; j < row.values.length; j++) { const v = row.values[j]; if (v === 0) continue; - const t = maxValue === 0 ? 0 : v / maxValue; + const t = v / maxValue; const [r, g, b, a] = ramp(t); ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a / 255})`; ctx.fillRect(j * cellW, i * cellH, cellW, cellH); @@ -163,6 +166,11 @@ function Heatmap({ matrix }: HeatmapProps) { No tracked routes — drive some traffic and refresh. ) : ( + // TimeAxis lives inside the scroll container so its labels — + // which are absolutely positioned at `idx * cellW` — track the + // canvas as the user scrolls horizontally. Putting it outside + // would freeze the labels under the left edge whenever the + // canvas overflows.
+
)} - {hoverRow !== null && matrix.rows[hoverRow] && ( )}