diff --git a/docs/admin_ui_key_visualizer_design.md b/docs/admin_ui_key_visualizer_design.md index 0ee75404a..1e24e6623 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/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 000000000..3a6b05fd2 --- /dev/null +++ b/docs/design/2026_04_27_proposed_keyviz_spa_integration.md @@ -0,0 +1,259 @@ +--- +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: 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. + +### 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 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. + +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 + +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 + +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 + + `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). +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. diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index 08311dfcf..1cde4db3e 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 ee1dcea40..98b9e3177 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 26620cdb8..ed174e7d3 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 000000000..47e88e1a2 --- /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 000000000..1a1aa2b63 --- /dev/null +++ b/web/admin/src/pages/KeyViz.tsx @@ -0,0 +1,408 @@ +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; + + // 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. + // 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 = 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) 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); + + 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. +
+ ) : ( + // 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] && ( + + )} +
+ ); +} + +interface TimeAxisProps { + columnUnixMs: number[]; + 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 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]); + 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) { + // 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 ( + + ); +} + +interface RefreshPickerProps { + value: RefreshMode; + onChange: (v: RefreshMode) => void; +} + +function RefreshPicker({ value, onChange }: RefreshPickerProps) { + return ( + + ); +}