From 8f2040eea48eea831425a62232dc2fc87e4beb27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?cryptofixyup=F0=9F=94=A5?= Date: Tue, 2 Jun 2026 12:28:43 +0200 Subject: [PATCH 1/2] feat: WebGPU time-series chart for live metrics Repurposes the storage-buffer + instanced-draw GPU technique to render the 4 CRDT metric series as animated line charts: - WebGpuMetricsChart: one instanced line-strip per series, value transitions interpolated on the GPU (mix(prev, cur, lerp)); Canvas2D fallback when WebGPU is unavailable so the chart renders everywhere - GET /api/metrics/history + getAllHistory() feed the 60-point series - Wire @webgpu/types via types/webgpu.d.ts so the WebGPU components typecheck under next build; fix a latent context-null narrowing in WebGpuParticles - 5 new getAllHistory tests (shape, empty, monotonic, 60-cap, copy-safety) https://claude.ai/code/session_01U3aerN98mswMJu6FBc7CqX --- .../app/api/metrics/history/route.ts | 9 + nextjs-performance/app/dashboard/page.tsx | 10 + .../components/WebGpuMetricsChart.tsx | 307 ++++++++++++++++++ .../components/WebGpuParticles.tsx | 4 +- .../lib/server/metrics-store.ts | 7 + nextjs-performance/package.json | 1 + .../tests/metrics-store.test.ts | 41 +++ nextjs-performance/types/webgpu.d.ts | 5 + 8 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 nextjs-performance/app/api/metrics/history/route.ts create mode 100644 nextjs-performance/components/WebGpuMetricsChart.tsx create mode 100644 nextjs-performance/types/webgpu.d.ts diff --git a/nextjs-performance/app/api/metrics/history/route.ts b/nextjs-performance/app/api/metrics/history/route.ts new file mode 100644 index 0000000..1285b0a --- /dev/null +++ b/nextjs-performance/app/api/metrics/history/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; +import { getAllHistory } from '@/lib/server/metrics-store'; + +export const dynamic = 'force-dynamic'; + +// Full per-metric time-series consumed by the WebGPU chart (polled every ~2s). +export async function GET(): Promise { + return NextResponse.json(getAllHistory()); +} diff --git a/nextjs-performance/app/dashboard/page.tsx b/nextjs-performance/app/dashboard/page.tsx index c258d25..2d68d07 100644 --- a/nextjs-performance/app/dashboard/page.tsx +++ b/nextjs-performance/app/dashboard/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react'; import { getMetricsForUser } from '@/lib/server/data-access-layer'; import { WebGpuParticles } from '@/components/WebGpuParticles'; +import { WebGpuMetricsChart } from '@/components/WebGpuMetricsChart'; import LiveMetricsPanel from '@/components/LiveMetricsPanel'; export default async function DashboardPage() { @@ -49,6 +50,15 @@ export default async function DashboardPage() { + {/* GPU-rendered time-series — same storage-buffer technique as the particles, + one instanced line-strip per metric, interpolated between polls on the GPU. */} +
+

+ WebGPU Metrics Chart (live time-series) +

+ +
+ {/* WebGPU particle system — 1M particles, Render Bundles, explicit GPU cleanup */}

diff --git a/nextjs-performance/components/WebGpuMetricsChart.tsx b/nextjs-performance/components/WebGpuMetricsChart.tsx new file mode 100644 index 0000000..c2436d7 --- /dev/null +++ b/nextjs-performance/components/WebGpuMetricsChart.tsx @@ -0,0 +1,307 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +// GPU-rendered time-series chart for the live CRDT metrics. +// +// Why WebGPU for a 4-line chart? The same storage-buffer + instanced-draw +// technique that renders 1M particles renders N series of M points with a +// single draw call — each series is one instanced line-strip. Smooth value +// transitions are interpolated on the GPU (mix(prev, cur, lerp)) so the chart +// animates between polls without rebuilding geometry on the CPU. + +type MetricName = 'page_views' | 'api_calls' | 'events_processed' | 'errors'; + +interface SeriesDef { + readonly key: MetricName; + readonly label: string; + readonly rgba: readonly [number, number, number, number]; + readonly css: string; +} + +const SERIES: readonly SeriesDef[] = [ + { key: 'page_views', label: 'Page Views', rgba: [0.23, 0.51, 0.96, 1], css: '#3b82f6' }, + { key: 'api_calls', label: 'API Calls', rgba: [0.06, 0.72, 0.51, 1], css: '#10b981' }, + { key: 'events_processed', label: 'Events Processed', rgba: [0.54, 0.36, 0.96, 1], css: '#8b5cf6' }, + { key: 'errors', label: 'Errors', rgba: [0.94, 0.27, 0.27, 1], css: '#ef4444' }, +]; + +const POINT_COUNT = 60; // matches MAX_HISTORY in metrics-store +const POLL_MS = 2000; +const ANIM_MS = 600; +const Y_SPAN = 0.85; // vertical fraction of NDC the chart occupies + +type HistoryResponse = Partial>>; +type RenderMode = 'connecting' | 'webgpu' | 'canvas2d' | 'unavailable'; + +// Normalize the server history into a flat [series][point] NDC-y buffer. +// Series shorter than POINT_COUNT are left-padded with their earliest value +// so the buffer is always full-size and the shader needs no bounds logic. +function buildBuffer(history: HistoryResponse): { ndcY: Float32Array; max: number } { + let max = 1; + for (const s of SERIES) { + for (const point of history[s.key] ?? []) { + if (point.val > max) max = point.val; + } + } + + const ndcY = new Float32Array(SERIES.length * POINT_COUNT); + SERIES.forEach((s, si) => { + const raw = history[s.key] ?? []; + const padCount = POINT_COUNT - raw.length; + const firstVal = raw[0]?.val ?? 0; + for (let i = 0; i < POINT_COUNT; i++) { + const val = i < padCount ? firstVal : (raw[i - padCount]?.val ?? 0); + // Baseline at bottom (-Y_SPAN), grows upward to +Y_SPAN. + ndcY[si * POINT_COUNT + i] = -Y_SPAN + (val / max) * (2 * Y_SPAN); + } + }); + return { ndcY, max }; +} + +const CHART_SHADER = /* wgsl */ ` +struct Uniforms { + lerp: f32, + pointCount: f32, + _pad0: f32, + _pad1: f32, +}; +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var cur: array; +@group(0) @binding(2) var prev: array; +@group(0) @binding(3) var colors: array; + +struct VOut { + @builtin(position) pos: vec4f, + @location(0) color: vec4f, +}; + +@vertex +fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) si: u32) -> VOut { + let pc = u32(u.pointCount); + let idx = si * pc + vi; + let y = mix(prev[idx], cur[idx], u.lerp); + let x = (f32(vi) / (u.pointCount - 1.0)) * 1.8 - 0.9; + var out: VOut; + out.pos = vec4f(x, y, 0.0, 1.0); + out.color = colors[si]; + return out; +} + +@fragment +fn fs(@location(0) color: vec4f) -> @location(0) vec4f { + return color; +} +`; + +export function WebGpuMetricsChart() { + const canvasRef = useRef(null); + const [mode, setMode] = useState('connecting'); + + useEffect(() => { + const canvas = canvasRef.current; + if (canvas === null) return; + + let stop = (): void => {}; + let disposed = false; + + // Shared polling loop — feeds whichever renderer is active. + function startPolling(onData: (buf: Float32Array, max: number) => void): () => void { + let active = true; + async function tick(): Promise { + try { + const res = await fetch('/api/metrics/history'); + const json = (await res.json()) as HistoryResponse; + if (active) { + const { ndcY, max } = buildBuffer(json); + onData(ndcY, max); + } + } catch { /* transient fetch error — retry on next tick */ } + } + void tick(); + const id = setInterval(() => { void tick(); }, POLL_MS); + return () => { active = false; clearInterval(id); }; + } + + async function initWebGpu(gpu: GPU): Promise { + const adapter = await gpu.requestAdapter({ powerPreference: 'high-performance' }); + if (adapter === null) return false; + const device = await adapter.requestDevice(); + const context = canvas!.getContext('webgpu'); + if (context === null) { device.destroy(); return false; } + + const format = gpu.getPreferredCanvasFormat(); + context.configure({ device, format, alphaMode: 'premultiplied' }); + + const bufSize = SERIES.length * POINT_COUNT * Float32Array.BYTES_PER_ELEMENT; + const curBuf = device.createBuffer({ label: 'chart-cur', size: bufSize, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); + const prevBuf = device.createBuffer({ label: 'chart-prev', size: bufSize, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); + const uniformBuf = device.createBuffer({ label: 'chart-uniform', size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + const colorBuf = device.createBuffer({ label: 'chart-colors', size: 4 * 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + + const colorData = new Float32Array(4 * 4); + SERIES.forEach((s, i) => { colorData.set(s.rgba, i * 4); }); + device.queue.writeBuffer(colorBuf, 0, colorData); + + const bgl = device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } }, + { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } }, + { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } }, + { binding: 3, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } }, + ], + }); + + const pipeline = await device.createRenderPipelineAsync({ + layout: device.createPipelineLayout({ bindGroupLayouts: [bgl] }), + vertex: { module: device.createShaderModule({ code: CHART_SHADER }), entryPoint: 'vs' }, + fragment: { + module: device.createShaderModule({ code: CHART_SHADER }), + entryPoint: 'fs', + targets: [{ format }], + }, + primitive: { topology: 'line-strip' }, + }); + + const bindGroup = device.createBindGroup({ + layout: bgl, + entries: [ + { binding: 0, resource: { buffer: uniformBuf } }, + { binding: 1, resource: { buffer: curBuf } }, + { binding: 2, resource: { buffer: prevBuf } }, + { binding: 3, resource: { buffer: colorBuf } }, + ], + }); + + let curArr = new Float32Array(SERIES.length * POINT_COUNT); + let animStart = 0; + let raf = 0; + + device.queue.writeBuffer(curBuf, 0, curArr); + device.queue.writeBuffer(prevBuf, 0, curArr); + + const stopPolling = startPolling((next) => { + device.queue.writeBuffer(prevBuf, 0, curArr); + device.queue.writeBuffer(curBuf, 0, next); + curArr = next; + animStart = performance.now(); + }); + + function frame(): void { + const lerp = Math.min(1, (performance.now() - animStart) / ANIM_MS); + device.queue.writeBuffer(uniformBuf, 0, new Float32Array([lerp, POINT_COUNT, 0, 0])); + + const encoder = device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ + colorAttachments: [{ + view: context.getCurrentTexture().createView(), + clearValue: { r: 0.04, g: 0.04, b: 0.10, a: 1 }, + loadOp: 'clear', + storeOp: 'store', + }], + }); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.draw(POINT_COUNT, SERIES.length); + pass.end(); + device.queue.submit([encoder.finish()]); + raf = requestAnimationFrame(frame); + } + raf = requestAnimationFrame(frame); + + stop = () => { + cancelAnimationFrame(raf); + stopPolling(); + curBuf.destroy(); + prevBuf.destroy(); + uniformBuf.destroy(); + colorBuf.destroy(); + device.destroy(); + }; + return true; + } + + // Canvas2D fallback — same data, no GPU. Keeps the chart useful everywhere. + function initCanvas2d(): void { + const ctx = canvas!.getContext('2d'); + if (ctx === null) { setMode('unavailable'); return; } + + let curArr = new Float32Array(SERIES.length * POINT_COUNT); + let prevArr = curArr; + let animStart = 0; + let raf = 0; + + const stopPolling = startPolling((next) => { + prevArr = curArr; + curArr = next; + animStart = performance.now(); + }); + + function draw(): void { + const lerp = Math.min(1, (performance.now() - animStart) / ANIM_MS); + const w = canvas!.width; + const h = canvas!.height; + ctx!.fillStyle = '#0a0a1a'; + ctx!.fillRect(0, 0, w, h); + + SERIES.forEach((s, si) => { + ctx!.strokeStyle = s.css; + ctx!.lineWidth = 2; + ctx!.beginPath(); + for (let i = 0; i < POINT_COUNT; i++) { + const idx = si * POINT_COUNT + i; + const ndc = prevArr[idx]! + (curArr[idx]! - prevArr[idx]!) * lerp; + const x = (i / (POINT_COUNT - 1)) * w; + const y = h - ((ndc + Y_SPAN) / (2 * Y_SPAN)) * h; + if (i === 0) ctx!.moveTo(x, y); else ctx!.lineTo(x, y); + } + ctx!.stroke(); + }); + raf = requestAnimationFrame(draw); + } + raf = requestAnimationFrame(draw); + + stop = () => { cancelAnimationFrame(raf); stopPolling(); }; + } + + void (async () => { + const gpu = navigator.gpu; + if (gpu !== undefined) { + try { + if (await initWebGpu(gpu)) { if (!disposed) setMode('webgpu'); return; } + } catch { /* fall through to 2D */ } + } + if (disposed) return; + initCanvas2d(); + setMode((m) => (m === 'unavailable' ? m : 'canvas2d')); + })(); + + return () => { disposed = true; stop(); }; + }, []); + + return ( +
+
+ {SERIES.map((s) => ( + + + {s.label} + + ))} + + {mode === 'webgpu' && '● GPU-rendered'} + {mode === 'canvas2d' && '● Canvas2D fallback'} + {mode === 'connecting' && '○ Initializing...'} + {mode === 'unavailable' && '⚠ Rendering unavailable'} + +
+ +
+ ); +} diff --git a/nextjs-performance/components/WebGpuParticles.tsx b/nextjs-performance/components/WebGpuParticles.tsx index 07d4b98..4173be6 100644 --- a/nextjs-performance/components/WebGpuParticles.tsx +++ b/nextjs-performance/components/WebGpuParticles.tsx @@ -73,6 +73,8 @@ export function WebGpuParticles() { const device = await adapter.requestDevice(); const context = canvas.getContext('webgpu'); if (!context) { device.destroy(); return; } + // Stable non-null binding so the rAF closure keeps the narrowed type. + const canvasContext = context; const format = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device, format, alphaMode: 'premultiplied' }); @@ -162,7 +164,7 @@ export function WebGpuParticles() { const renderPass = encoder.beginRenderPass({ colorAttachments: [{ - view: context.getCurrentTexture().createView(), + view: canvasContext.getCurrentTexture().createView(), clearValue: { r: 0.04, g: 0.04, b: 0.10, a: 1.0 }, loadOp: 'clear', storeOp: 'store', diff --git a/nextjs-performance/lib/server/metrics-store.ts b/nextjs-performance/lib/server/metrics-store.ts index 10299c9..c6339fe 100644 --- a/nextjs-performance/lib/server/metrics-store.ts +++ b/nextjs-performance/lib/server/metrics-store.ts @@ -63,6 +63,13 @@ export function getHistory(metric: MetricName): Array<{ ts: number; val: number return [...getOrCreate(metric).history]; } +// Full time-series for every metric — drives the WebGPU chart. +export function getAllHistory(): Record> { + return Object.fromEntries( + METRIC_NAMES.map((name) => [name, [...getOrCreate(name).history]]) + ) as Record>; +} + // Export the full per-node state for every metric — used for gossip / partition recovery. export function exportSnapshot(): SyncSnapshot { const snapshot: SyncSnapshot = {}; diff --git a/nextjs-performance/package.json b/nextjs-performance/package.json index ef25c2a..6a4cecf 100644 --- a/nextjs-performance/package.json +++ b/nextjs-performance/package.json @@ -24,6 +24,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitest/coverage-v8": "^2.0.0", + "@webgpu/types": "^0.1.70", "ts-patch": "^3.3.0", "typescript": "^5.7.0", "vitest": "^2.0.0" diff --git a/nextjs-performance/tests/metrics-store.test.ts b/nextjs-performance/tests/metrics-store.test.ts index 3e1fc0b..f8b7f40 100644 --- a/nextjs-performance/tests/metrics-store.test.ts +++ b/nextjs-performance/tests/metrics-store.test.ts @@ -6,6 +6,7 @@ vi.mock('server-only', () => ({})); import { _resetForTesting, exportSnapshot, + getAllHistory, getHistory, getMetrics, mergeRemoteSnapshot, @@ -128,3 +129,43 @@ describe('metrics-store — multi-region sync', () => { expect(getMetrics().events_processed).toBe(before); }); }); + +describe('metrics-store — getAllHistory (chart feed)', () => { + beforeEach(() => { + _resetForTesting(); + }); + + it('returns an entry for every metric', () => { + const all = getAllHistory(); + expect(Object.keys(all).sort()).toEqual( + ['api_calls', 'errors', 'events_processed', 'page_views'] + ); + }); + + it('returns empty series before any events', () => { + const all = getAllHistory(); + expect(all.page_views).toEqual([]); + expect(all.errors).toEqual([]); + }); + + it('reflects recorded events as monotonic series', () => { + recordEvent('page_views'); + recordEvent('page_views'); + recordEvent('api_calls', 3); + const all = getAllHistory(); + expect(all.page_views.map((p) => p.val)).toEqual([1, 2]); + expect(all.api_calls.at(-1)?.val).toBe(3); + }); + + it('caps each series at 60 points', () => { + for (let i = 0; i < 70; i++) recordEvent('errors'); + expect(getAllHistory().errors).toHaveLength(60); + }); + + it('returns copies — mutating the result does not corrupt the store', () => { + recordEvent('page_views'); + const all = getAllHistory(); + all.page_views.push({ ts: 0, val: 999 }); + expect(getHistory('page_views')).toHaveLength(1); + }); +}); diff --git a/nextjs-performance/types/webgpu.d.ts b/nextjs-performance/types/webgpu.d.ts new file mode 100644 index 0000000..ca81a21 --- /dev/null +++ b/nextjs-performance/types/webgpu.d.ts @@ -0,0 +1,5 @@ +// Global WebGPU type augmentation. Pulls @webgpu/types into the project so the +// WebGPU components (navigator.gpu, GPUBufferUsage, GPUShaderStage, GPUCanvasContext) +// typecheck under `next build`. Browser-runtime modules are excluded from the +// narrow CI typecheck (tsconfig.ci.json) but this keeps editor + build honest. +/// From adc9aa54d96c02aec6ba15b1ea2db787b0718204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?cryptofixyup=F0=9F=94=A5?= Date: Tue, 2 Jun 2026 12:31:20 +0200 Subject: [PATCH 2/2] fix: restore non-null assertion on context in chart rAF closure Matches the type-verified local file; keeps the component clean under next build. https://claude.ai/code/session_01U3aerN98mswMJu6FBc7CqX --- nextjs-performance/components/WebGpuMetricsChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nextjs-performance/components/WebGpuMetricsChart.tsx b/nextjs-performance/components/WebGpuMetricsChart.tsx index c2436d7..3c04771 100644 --- a/nextjs-performance/components/WebGpuMetricsChart.tsx +++ b/nextjs-performance/components/WebGpuMetricsChart.tsx @@ -194,7 +194,7 @@ export function WebGpuMetricsChart() { const encoder = device.createCommandEncoder(); const pass = encoder.beginRenderPass({ colorAttachments: [{ - view: context.getCurrentTexture().createView(), + view: context!.getCurrentTexture().createView(), clearValue: { r: 0.04, g: 0.04, b: 0.10, a: 1 }, loadOp: 'clear', storeOp: 'store',