Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/app/api/speedtest/cell/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from "next/server";
import { unstable_cache } from "next/cache";
import { redisPipeline, storeConfigured } from "@/lib/materialize/store";
import { median, monthKeys, parseLatEntry } from "@/lib/speedtest/geo";
import { RPC_DIRECTORY } from "@/lib/speedtest/rpc-directory";

export const runtime = "nodejs";

/**
* Detail view for one map cell: the full retained contribution history
* (last 24 readings per provider over the rolling two-month window),
* with timestamps, powering the click-through panel on /rpc-map.
*/

const KNOWN_CHAINS = new Set(RPC_DIRECTORY.map((c) => c.slug));
const GH_RE = /^[0-9b-hj-km-np-z]{4}$/;

async function buildCell(chain: string, gh: string) {
const [cur, prev] = monthKeys(new Date());
const [curPairs, prevPairs, metaFlat] = (await redisPipeline([
["SMEMBERS", `stm:idx:${cur}:${chain}`],
["SMEMBERS", `stm:idx:${prev}:${chain}`],
["HGETALL", `stm:meta:${gh}`],
])) as [string[] | null, string[] | null, string[] | Record<string, string> | null];

const slugs = Array.from(
new Set(
[...(curPairs ?? []), ...(prevPairs ?? [])]
.filter((p) => p.startsWith(`${gh}:`))
.map((p) => p.slice(5)),
),
);
let meta: Record<string, string> = {};
if (Array.isArray(metaFlat)) {
for (let j = 0; j < metaFlat.length; j += 2) meta[metaFlat[j]] = metaFlat[j + 1];
} else if (metaFlat && typeof metaFlat === "object") {
meta = metaFlat as Record<string, string>;
}
if (slugs.length === 0) return { gh, city: meta.city ?? "Unknown", country: meta.country ?? "??", providers: [] };

const readCmds: (string | number)[][] = [];
for (const slug of slugs) {
readCmds.push(["LRANGE", `stm:lat:${cur}:${gh}:${chain}:${slug}`, 0, -1]);
readCmds.push(["LRANGE", `stm:lat:${prev}:${gh}:${chain}:${slug}`, 0, -1]);
}
const results = await redisPipeline(readCmds);

const providers = slugs
.map((slug, i) => {
const entries = [
...((results[i * 2] as string[] | null) ?? []),
...((results[i * 2 + 1] as string[] | null) ?? []),
]
.map((raw) => parseLatEntry(String(raw)))
.filter((e): e is { ts: number | null; p50: number } => e !== null)
// LPUSH order is newest first; keep it that way for the panel.
.slice(0, 24);
if (entries.length === 0) return null;
const values = entries.map((e) => e.p50);
const tss = entries.map((e) => e.ts).filter((t): t is number => t !== null);
return {
slug,
p50: Math.round(median(values) * 10) / 10,
samples: entries.length,
lastTs: tss.length > 0 ? Math.max(...tss) : null,
history: entries.map((e) => ({ ts: e.ts, p50: Math.round(e.p50 * 10) / 10 })),
};
})
.filter((p): p is NonNullable<typeof p> => p !== null)
.sort((a, b) => a.p50 - b.p50);

return { gh, city: meta.city ?? "Unknown", country: meta.country ?? "??", providers };
}

const cachedCell = unstable_cache(
async (chain: string, gh: string) => buildCell(chain, gh),
["speedtest-cell-v1"],
{ revalidate: 120 },
);

export async function GET(req: NextRequest) {
const chain = req.nextUrl.searchParams.get("chain") ?? "";
const gh = req.nextUrl.searchParams.get("gh") ?? "";
if (!KNOWN_CHAINS.has(chain) || !GH_RE.test(gh)) {
return NextResponse.json({ error: "bad_params" }, { status: 400 });
}
if (!storeConfigured()) {
return NextResponse.json({ gh, providers: [] });
}
try {
const data = await cachedCell(chain, gh);
return NextResponse.json(data, {
headers: {
"cache-control": "public, s-maxage=120, stale-while-revalidate=600",
"access-control-allow-origin": "*",
},
});
} catch {
return NextResponse.json({ gh, providers: [] });
}
}
5 changes: 4 additions & 1 deletion src/app/api/speedtest/contribute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ export async function POST(req: NextRequest) {
const pair = `${gh}:${e.slug}`;
const latKey = `stm:lat:${ym}:${gh}:${parsed.chain}:${e.slug}`;
cmds.push(["SADD", `stm:idx:${ym}:${parsed.chain}`, pair]);
cmds.push(["LPUSH", latKey, e.p50]);
// "unixSeconds:p50" so the map can show when each area was last
// measured and render per-provider contribution history. The map
// reader also accepts bare legacy numbers.
cmds.push(["LPUSH", latKey, `${Math.floor(Date.now() / 1000)}:${e.p50}`]);
cmds.push(["LTRIM", latKey, 0, RESERVOIR - 1]);
cmds.push(["EXPIRE", latKey, TTL_SEC]);
}
Expand Down
11 changes: 8 additions & 3 deletions src/app/api/speedtest/map/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { unstable_cache } from "next/cache";
import { redisPipeline, storeConfigured } from "@/lib/materialize/store";
import { geohashCenter, median, monthKeys } from "@/lib/speedtest/geo";
import { geohashCenter, median, monthKeys, parseLatEntry } from "@/lib/speedtest/geo";
import { RPC_DIRECTORY } from "@/lib/speedtest/rpc-directory";

export const runtime = "nodejs";
Expand All @@ -21,7 +21,7 @@ type CellOut = {
lon: number;
city: string;
country: string;
providers: { slug: string; p50: number; samples: number }[];
providers: { slug: string; p50: number; samples: number; lastTs: number | null }[];
best: string;
};

Expand Down Expand Up @@ -68,7 +68,10 @@ async function buildMap(chain: string): Promise<{ cells: CellOut[]; total: numbe
const slug = pair.slice(5);
const curList = (results[i * 2] as string[] | null) ?? [];
const prevList = (results[i * 2 + 1] as string[] | null) ?? [];
const values = [...curList, ...prevList].map(Number).filter((v) => Number.isFinite(v));
const entries = [...curList, ...prevList]
.map((raw) => parseLatEntry(String(raw)))
.filter((e): e is { ts: number | null; p50: number } => e !== null);
const values = entries.map((e) => e.p50);
if (values.length === 0) continue;
let cell = byCell.get(gh);
if (!cell) {
Expand All @@ -85,10 +88,12 @@ async function buildMap(chain: string): Promise<{ cells: CellOut[]; total: numbe
};
byCell.set(gh, cell);
}
const tss = entries.map((e) => e.ts).filter((t): t is number => t !== null);
cell.providers.push({
slug,
p50: Math.round(median(values) * 10) / 10,
samples: values.length,
lastTs: tss.length > 0 ? Math.max(...tss) : null,
});
}
const cells = Array.from(byCell.values());
Expand Down
128 changes: 126 additions & 2 deletions src/components/speedtest/rpc-map-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,34 @@ type MapCell = {
lon: number;
city: string;
country: string;
providers: { slug: string; p50: number; samples: number }[];
providers: { slug: string; p50: number; samples: number; lastTs: number | null }[];
best: string;
};

type MapData = { chain: string; cells: MapCell[]; total: number };

type CellDetail = {
gh: string;
city: string;
country: string;
providers: {
slug: string;
p50: number;
samples: number;
lastTs: number | null;
history: { ts: number | null; p50: number }[];
}[];
};

function timeAgo(ts: number | null): string {
if (ts == null) return "recently";
const s = Math.max(0, Math.floor(Date.now() / 1000) - ts);
if (s < 90) return "just now";
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
return `${Math.floor(s / 86400)} d ago`;
}

const FEATURED = ["ethereum", "base", "arbitrum", "bnb", "polygon", "optimism"];

function project(lat: number, lon: number): [number, number] {
Expand Down Expand Up @@ -65,6 +87,9 @@ export function RpcMapClient() {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [hover, setHover] = useState<MapCell | null>(null);
const [hoverXY, setHoverXY] = useState<[number, number]>([0, 0]);
const [selectedGh, setSelectedGh] = useState<string | null>(null);
const [cellDetail, setCellDetail] = useState<CellDetail | null>(null);
const [cellLoading, setCellLoading] = useState(false);
// viewBox as [x, y, w, h]; wheel zooms toward the cursor, drag pans.
const [vb, setVb] = useState<[number, number, number, number]>([0, 0, WORLD_W, WORLD_H]);
const svgRef = useRef<SVGSVGElement | null>(null);
Expand Down Expand Up @@ -209,8 +234,26 @@ export function RpcMapClient() {
setChainQuery("");
setSelected(new Set());
setVb([0, 0, WORLD_W, WORLD_H]);
setSelectedGh(null);
setCellDetail(null);
};

const openCell = useCallback(
(c: MapCell) => {
setSelectedGh(c.gh);
setCellLoading(true);
setCellDetail(null);
fetch(`/api/speedtest/cell?chain=${chain}&gh=${c.gh}`)
.then((r) => r.json())
.then((d) => {
setCellDetail(d);
setCellLoading(false);
})
.catch(() => setCellLoading(false));
},
[chain],
);

return (
<div className="mt-8">
{/* Chain picker: featured chips + search over all 87 chains */}
Expand Down Expand Up @@ -338,6 +381,9 @@ export function RpcMapClient() {
<g key={c.gh}>
{/* soft halo then crisp dot: reads as data, not sticker */}
<circle cx={x} cy={y} r={r * 2.1} fill={color} fillOpacity="0.14" />
{selectedGh === c.gh && (
<circle cx={x} cy={y} r={r * 1.7} fill="none" stroke="var(--color-ink)" strokeWidth={r * 0.22} strokeDasharray={`${r * 0.6} ${r * 0.4}`} />
)}
<circle
cx={x}
cy={y}
Expand All @@ -352,6 +398,9 @@ export function RpcMapClient() {
if (rect) setHoverXY([e.clientX - rect.left, e.clientY - rect.top]);
}}
onMouseLeave={() => setHover(null)}
onClick={() => {
if (!dragRef.current?.moved) openCell(c);
}}
/>
{showCityLabels && (
<text
Expand Down Expand Up @@ -413,7 +462,14 @@ export function RpcMapClient() {
))}
</div>
<p className="mt-1.5 label-mono text-[9px] text-ink-faint">
{hover.providers.reduce((s, p) => s + p.samples, 0)} samples in this area
{hover.providers.reduce((s, p) => s + p.samples, 0)} samples · last measured{" "}
{timeAgo(
hover.providers.reduce<number | null>(
(m, p) => (p.lastTs != null && (m == null || p.lastTs > m) ? p.lastTs : m),
null,
),
)}{" "}
· click for history
</p>
</div>
)}
Expand Down Expand Up @@ -450,6 +506,74 @@ export function RpcMapClient() {
)}
</div>

{/* Cell detail: opened by clicking a dot. Median, freshness and
the retained contribution history per provider. */}
{selectedGh && (
<div className="mt-4 rounded-xl border border-rule card-soft p-4 sm:p-5">
<div className="flex items-center justify-between gap-3 mb-3">
<p className="text-[15px] font-semibold text-ink">
{cellDetail ? `${cellDetail.city}, ${cellDetail.country}` : "Loading area…"}
<span className="label-mono text-[10px] text-ink-faint ml-2">cell {selectedGh}</span>
</p>
<button
type="button"
onClick={() => {
setSelectedGh(null);
setCellDetail(null);
}}
className="label-mono text-[11px] text-ink-faint hover:text-ink"
>
close ×
</button>
</div>
{cellLoading && <p className="label-mono text-[11px] text-ink-faint">Loading history…</p>}
{cellDetail && cellDetail.providers.length === 0 && !cellLoading && (
<p className="text-[13px] text-ink-soft">No retained history for this area yet.</p>
)}
<div className="grid gap-3 sm:grid-cols-2">
{(cellDetail?.providers ?? [])
.filter((p) => selected.size === 0 || selected.has(p.slug))
.map((p) => {
const chrono = [...p.history].reverse(); // oldest → newest
const maxV = Math.max(...chrono.map((h) => h.p50), 1);
return (
<div key={p.slug} className="rounded-lg border border-rule px-3.5 py-3">
<div className="flex items-center gap-2">
<ProviderLogo slug={p.slug} name={providerName(p.slug)} size={18} />
<span className="text-[13px] font-semibold text-ink flex-1 truncate">
{providerName(p.slug)}
</span>
<span className="label-mono tabular-nums text-[13px]" style={{ color: latencyColor(p.p50) }}>
{Math.round(p.p50)} ms
</span>
</div>
<div className="mt-2.5 flex items-end gap-[3px] h-[34px]">
{chrono.map((h, i) => (
<span
key={i}
className="flex-1 max-w-[10px] rounded-sm"
title={`${Math.round(h.p50)} ms · ${h.ts != null ? new Date(h.ts * 1000).toLocaleString() : "no timestamp"}`}
style={{
height: `${Math.max(12, (h.p50 / maxV) * 100)}%`,
background: latencyColor(h.p50),
opacity: 0.4 + (i / Math.max(1, chrono.length - 1)) * 0.6,
}}
/>
))}
</div>
<p className="mt-2 label-mono text-[10px] text-ink-faint">
median of {p.samples} contribution{p.samples > 1 ? "s" : ""} · last measured {timeAgo(p.lastTs)}
</p>
</div>
);
})}
</div>
<p className="mt-3 label-mono text-[9px] text-ink-faint">
Bars are the last {Math.min(24, Math.max(...(cellDetail?.providers ?? [{ samples: 0 }]).map((p) => p.samples)))} contributed medians for this area, oldest to newest. Hover a bar for the exact reading and time.
</p>
</div>
)}

{/* Legend */}
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2 label-mono text-[10px] text-ink-faint">
<span>Median latency:</span>
Expand Down
14 changes: 14 additions & 0 deletions src/lib/speedtest/geo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ export function median(values: number[]): number {
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}

/** Parse a stored latency entry: "unixSeconds:p50" (current format) or
* a bare legacy number (ts null). Invalid entries return null. */
export function parseLatEntry(raw: string): { ts: number | null; p50: number } | null {
const idx = raw.indexOf(":");
if (idx === -1) {
const v = Number(raw);
return Number.isFinite(v) ? { ts: null, p50: v } : null;
}
const ts = Number(raw.slice(0, idx));
const v = Number(raw.slice(idx + 1));
if (!Number.isFinite(v)) return null;
return { ts: Number.isFinite(ts) ? ts : null, p50: v };
}

/** Current + previous month keys ("2026-09"), the map's rolling window. */
export function monthKeys(now: Date): [string, string] {
const y = now.getUTCFullYear();
Expand Down
Loading