From 665a5ef000b0fbe4cab188b6a71c722239274984 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:56:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(speedtest):=20live=20instrument=20feel=20?= =?UTF-8?q?=E2=80=94=20concurrent=20probes,=20rAF=20needle,=20Speedtest-st?= =?UTF-8?q?yle=20gradient=20dial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/speedtest-rpc/page.tsx | 7 +- .../speedtest/speedtest-rpc-client.tsx | 248 +++++++++++++----- 2 files changed, 189 insertions(+), 66 deletions(-) diff --git a/src/app/speedtest-rpc/page.tsx b/src/app/speedtest-rpc/page.tsx index f0453143..ae1291cb 100644 --- a/src/app/speedtest-rpc/page.tsx +++ b/src/app/speedtest-rpc/page.tsx @@ -77,9 +77,10 @@ export default function SpeedtestRpcPage() { {" "} POST with a rotating request id, so no edge cache can answer without touching a real node. One warmup request absorbs the TCP + TLS - handshake, then endpoints are probed in randomized round-robin order - until the clock runs out — network wake-ups hit every endpoint - evenly. Responses are classified ok / http_err / jsonrpc_err / + handshake, then all endpoints are probed once per second in + staggered, randomized rounds until the clock runs out — request + bursts never align and network wake-ups hit every endpoint evenly. + Responses are classified ok / http_err / jsonrpc_err / timeout, and an endpoint reporting a block more than 20 behind the best tip in the same round is flagged stale. Latency percentiles use ok responses only, exactly like the{" "} diff --git a/src/components/speedtest/speedtest-rpc-client.tsx b/src/components/speedtest/speedtest-rpc-client.tsx index a2083917..16607bb3 100644 --- a/src/components/speedtest/speedtest-rpc-client.tsx +++ b/src/components/speedtest/speedtest-rpc-client.tsx @@ -169,45 +169,152 @@ function msColor(ms: number): string { return "#ef4444"; } +/** Smooth green→amber→red interpolation across the dial (t in 0..1), + * Speedtest-style gradient fill but with latency semantics. */ +const GRAD_STOPS: [number, [number, number, number]][] = [ + [0.0, [16, 185, 129]], // emerald (fast) + [0.55, [245, 158, 11]], // amber + [1.0, [239, 68, 68]], // red (slow) +]; +function gradientColor(t: number): string { + const x = Math.max(0, Math.min(1, t)); + for (let i = 1; i < GRAD_STOPS.length; i++) { + const [t1, c1] = GRAD_STOPS[i]; + const [t0, c0] = GRAD_STOPS[i - 1]; + if (x <= t1) { + const f = (x - t0) / (t1 - t0); + const c = c0.map((v, j) => Math.round(v + (c1[j] - v) * f)); + return `rgb(${c[0]},${c[1]},${c[2]})`; + } + } + return "rgb(239,68,68)"; +} + +/** rAF-driven exponential easing towards a moving target — the needle + * and the readout glide continuously instead of jumping per sample, + * which is what makes the dial read as a live instrument. */ +function useEased(target: number | null, tauMs = 220): number | null { + const [value, setValue] = useState(target); + const valueRef = useRef(target); + useEffect(() => { + if (target == null) { + valueRef.current = null; + setValue(null); + return; + } + if (valueRef.current == null) { + valueRef.current = target; + setValue(target); + return; + } + let raf = 0; + let last = performance.now(); + const step = (t: number) => { + const dt = t - last; + last = t; + const cur = valueRef.current ?? target; + const next = cur + (target - cur) * Math.min(1, dt / tauMs); + valueRef.current = next; + setValue(next); + if (Math.abs(target - next) > 0.4) raf = requestAnimationFrame(step); + else { + valueRef.current = target; + setValue(target); + } + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [target, tauMs]); + return value; +} + +/** Last-N samples as tiny colored bars — motion + history at a glance. */ +function MiniSparkline({ samples }: { samples: Sample[] }) { + const last = samples.slice(-14); + if (last.length === 0) return
; + return ( +
+ {last.map((s, i) => { + const h = 4 + (Math.log(Math.max(1, Math.min(1000, s.ms))) / Math.log(1000)) * 18; + const bad = s.verdict !== "ok"; + return ( + + ); + })} +
+ ); +} + function Gauge({ ms, label, sub, + live = false, className = "mx-auto w-[280px] sm:w-[340px]", }: { ms: number | null; label: string; sub: string; + live?: boolean; className?: string; }) { - const angle = ms == null ? GAUGE_START : msToAngle(ms); + const eased = useEased(ms); + const angle = eased == null ? GAUGE_START : msToAngle(eased); + const color = eased == null ? "var(--color-ink-faint)" : msColor(eased); + // Speedtest-style progressive fill: the arc is rendered as many thin + // segments; those before the needle carry a smooth green→amber→red + // gradient, the rest stay on the grey track. Because `eased` updates + // per animation frame, the fill grows/retracts fluidly with the needle. + const SEGS = 56; + const fillT = (angle - GAUGE_START) / GAUGE_SWEEP; // 0..1 return (
+ + + + + + + {/* Track */} - {/* Colored zones: <50 good, 50-200 warn, >200 danger */} - - - - {/* Scale labels */} + {/* Gradient fill up to the needle */} + {Array.from({ length: SEGS }, (_, i) => { + const t0 = i / SEGS; + if (eased == null || t0 >= fillT) return null; + const t1 = Math.min((i + 1) / SEGS, fillT); + const a0 = GAUGE_START + t0 * GAUGE_SWEEP; + const a1 = GAUGE_START + t1 * GAUGE_SWEEP + 0.4; // overlap kills seams + return ( + + ); + })} + {/* Scale labels, inside the dial like the reference */} {GAUGE_STOPS.map((stop) => { - const [x, y] = polar(100, 100, 60, msToAngle(stop)); + const [x, y] = polar(100, 100, 58, msToAngle(stop)); return ( {stop} ); })} - {/* Needle */} - - - - + {/* Needle: slim tapered blade fading toward the hub — no heavy + center cap, instrument-grade. rAF easing drives the rotation + continuously; a slow wobble runs while live. */} + {eased != null && ( + + + + + + )}
-
- {ms == null ? "—" : Math.round(ms)} - ms +
+ {eased == null ? "—" : Math.round(eased)} + + + ms +

{label}

{sub}

@@ -424,8 +539,12 @@ export function SpeedtestRpcClient() { } } - // Phase 3: measured rounds until the clock runs out. Order is - // re-shuffled every round so network wake-ups hit endpoints evenly. + // Phase 3: measured rounds until the clock runs out. Every endpoint + // is probed CONCURRENTLY each round (starts staggered by ~90ms so + // request bursts never align) at roughly one probe per second per + // endpoint — every needle lives simultaneously, speedtest-style. + // RPC payloads are a few KB, so uplink contention is negligible + // against the 10-500ms latencies being measured. const tEnd = performance.now() + durationSec * 1000; const t0 = performance.now(); let roundNo = 0; @@ -433,28 +552,30 @@ export function SpeedtestRpcClient() { () => setElapsed(Math.min(durationSec, (performance.now() - t0) / 1000)), 200, ); + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); try { while (performance.now() < tEnd && !abortRef.current.stop) { roundNo += 1; setRound(roundNo); + const roundStart = performance.now(); const order = [...live].sort(() => Math.random() - 0.5); const roundBlocks: Record = {}; - for (const ep of order) { - if (performance.now() >= tEnd || abortRef.current.stop) break; - setActiveId(ep.id); - let sample: Sample; - try { - const r = await rpcCall(ep.url, "eth_getBlockByNumber", ["latest", false]); - sample = { ms: r.ms, verdict: r.verdict, block: r.block, round: roundNo }; - } catch { - sample = { ms: PROBE_TIMEOUT_MS, verdict: "timeout", block: null, round: roundNo }; - } - roundBlocks[ep.id] = sample.block; - ep.samples.push(sample); - patch(ep.id, { samples: [...ep.samples], lastMs: sample.ms }); - // Politeness gap so we never hammer a provider. - await new Promise((r) => setTimeout(r, 150)); - } + await Promise.all( + order.map(async (ep, idx) => { + await sleep(idx * 90); + if (abortRef.current.stop) return; + let sample: Sample; + try { + const r = await rpcCall(ep.url, "eth_getBlockByNumber", ["latest", false]); + sample = { ms: r.ms, verdict: r.verdict, block: r.block, round: roundNo }; + } catch { + sample = { ms: PROBE_TIMEOUT_MS, verdict: "timeout", block: null, round: roundNo }; + } + roundBlocks[ep.id] = sample.block; + ep.samples.push(sample); + patch(ep.id, { samples: [...ep.samples], lastMs: sample.ms }); + }), + ); // Stale detection: an endpoint more than STALE_BLOCKS behind the // best tip seen this round is serving an old head. const tips = Object.values(roundBlocks).filter((b): b is number => b != null); @@ -468,6 +589,9 @@ export function SpeedtestRpcClient() { } } } + // Pace: ~1 round/second regardless of how fast the round ran. + const roundDur = performance.now() - roundStart; + if (performance.now() < tEnd) await sleep(Math.max(0, 1050 - roundDur)); } } finally { clearInterval(timer); @@ -507,6 +631,8 @@ export function SpeedtestRpcClient() { @keyframes st-fade-up { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } } @keyframes st-fade { from { opacity: 0; } to { opacity: 1; } } @keyframes st-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } } + @keyframes st-wobble-kf { 0%,100% { transform: rotate(-0.5deg); } 50% { transform: rotate(0.5deg); } } + .st-wobble { animation: st-wobble-kf 1.7s ease-in-out infinite; } .st-stage { animation: st-fade-up 0.5s cubic-bezier(0.22,1,0.36,1) both; } .st-row { animation: st-fade-up 0.55s cubic-bezier(0.22,1,0.36,1) both; } .st-live { animation: st-pulse 1.6s ease-in-out infinite; } @@ -716,16 +842,12 @@ export function SpeedtestRpcClient() { -
+ +
p50 {Number.isNaN(s.p50) ? "—" : `${Math.round(s.p50)}ms`} {s.n} probes {s.successPct}% ok