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
52 changes: 40 additions & 12 deletions src/app/chains/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import {
hasAnyKpi,
} from "@/lib/chain-kpis";
import { ChainKpiStrip } from "@/components/chain-kpi-strip";
import { ChainRpcSection } from "@/components/chain-rpc-section";
import {
VenueKpiToggle,
type VenueToggleSection,
} from "@/components/venue-kpi-toggle";
import { getRpcChainRow } from "@/lib/rpc-hub-stats";
import { Breadcrumb } from "@/components/breadcrumb";
import { Pill } from "@/components/pill";
import { ProviderLogo } from "@/components/provider-logo";
Expand Down Expand Up @@ -96,14 +102,46 @@ export default async function ChainPage({
// TVL full history is fetched directly from DefiLlama (single ~50 KB
// call, revalidated every 15 min). Client renders the active range
// (7D/30D/90D/1Y/All) without extra network round-trips.
const [kpis, tvlHistory] = await Promise.all([
const [kpis, tvlHistory, rpcRow] = await Promise.all([
fetchChainKpis(slug),
fetchChainTvlFullHistory(slug),
getRpcChainRow(slug),
]);

const byCategory = groupByCategory(benches);
const url = `${SITE.url}/chains/${slug}`;

// KPI domain pill bar (same pattern as /products/<slug>): the existing
// Live KPIs strip (including the TVL chart) becomes the "Chain KPIs"
// section, and chains covered by a <chain>-rpc bench gain an "RPC"
// section. Chains without a hub row keep a single "Chain KPIs" pill;
// the strip's own gating (kpis && hasAnyKpi) is unchanged.
const kpiSections: VenueToggleSection[] = [];
if (kpis && hasAnyKpi(kpis)) {
kpiSections.push({
id: "chain-kpis",
label: "Chain KPIs",
content: (
<div className="mt-6">
<ChainKpiStrip
slug={slug}
kpis={kpis}
nativeSymbol={chain.nativeSymbol}
tvlHistory={tvlHistory}
chainLabel={chain.label}
/>
</div>
),
});
}
if (rpcRow) {
kpiSections.push({
id: "rpc",
label: "RPC",
content: <ChainRpcSection chainSlug={slug} />,
});
}

// ItemList of Datasets, one entry per benchmark that touches this chain.
// Previously CollectionPage — Google's rich result validator doesn't
// recognise CollectionPage as a supported type and flagged every chain
Expand Down Expand Up @@ -183,17 +221,7 @@ export default async function ChainPage({
the chain scoped bench page when one exists.
</p>

{kpis && hasAnyKpi(kpis) && (
<div className="mt-10">
<ChainKpiStrip
slug={slug}
kpis={kpis}
nativeSymbol={chain.nativeSymbol}
tvlHistory={tvlHistory}
chainLabel={chain.label}
/>
</div>
)}
<VenueKpiToggle sections={kpiSections} />

<div className="mt-12 space-y-12">
{Array.from(byCategory.entries()).map(([category, list]) => (
Expand Down
106 changes: 106 additions & 0 deletions src/components/chain-rpc-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
import {
fetchRpcHub,
getRpcChainRow,
RPC_REGION_KEYS,
type RpcRegionKey,
} from "@/lib/rpc-hub-stats";

/**
* Compact RPC summary panel for /chains/<slug>: the chain centric cut of
* the rpc-hub cohort snapshot (the provider centric cut lives in
* RpcProviderChainsSection on product pages). Shows the 24h p50 leader,
* the best provider per probe region, and links out to the full
* <chain>-rpc bench page for the complete leaderboard.
*
* Server component, snapshot only (fetchRpcHub reads the worker written
* cohort blob via unstable_cache; zero Prometheus traffic). Returns null
* when the chain has no rpc-hub row, so the page can render it
* unconditionally, though the chain page gates the pill upfront via
* getRpcChainRow (same cached read, no extra roundtrip).
*/

const REGION_LABELS: Record<RpcRegionKey, string> = {
"us-east": "US East",
"eu-west": "EU West",
sgp: "Singapore",
};

function fmtMs(v: number): string {
if (v < 1000) return `${Math.round(v)} ms`;
return `${(v / 1000).toFixed(2)} s`;
}

function fmtUtc(iso: string): string | null {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`;
}

export async function ChainRpcSection({ chainSlug }: { chainSlug: string }) {
const [snapshot, row] = await Promise.all([
fetchRpcHub(),
getRpcChainRow(chainSlug),
]);
if (!snapshot || !row || !row.best) return null;

const asOf = snapshot.generatedAt ? fmtUtc(snapshot.generatedAt) : null;

return (
<section className="mt-6">
<h2 className="text-[11px] font-medium uppercase tracking-[0.18em] text-ink-muted">
Fastest public RPC on {row.name}
</h2>
<p className="mt-2 text-sm text-ink-soft leading-snug max-w-2xl">
The fastest public RPC endpoint on {row.name} right now is{" "}
<span className="font-medium text-ink">{row.best.providerName}</span>{" "}
at{" "}
<span className="font-medium text-ink tabular-nums">
{fmtMs(row.best.p50Ms)}
</span>{" "}
p50 over the last 24 hours, counting successful calls only, across{" "}
{row.providerCount} live provider
{row.providerCount === 1 ? "" : "s"} measured from 3 probe regions.
</p>
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-3 border-y border-rule divide-y sm:divide-y-0 sm:divide-x divide-rule">
{RPC_REGION_KEYS.map((region) => {
const best = row.regions[region];
return (
<div key={region} className="py-3 sm:px-4 sm:first:pl-0">
<dt className="text-[10px] font-medium uppercase tracking-[0.16em] text-ink-muted">
{REGION_LABELS[region]}
</dt>
<dd className="mt-1 text-[12.5px]">
{best ? (
<>
<span className="font-medium text-ink">
{best.providerName}
</span>{" "}
<span className="text-ink-soft tabular-nums">
{fmtMs(best.p50Ms)}
</span>
</>
) : (
<span className="text-ink-faint">no data</span>
)}
</dd>
</div>
);
})}
</dl>
<div className="mt-3 flex flex-wrap items-baseline gap-x-4 gap-y-1">
<Link
href={`/benchmarks/${row.slug}`}
className="inline-flex items-center gap-1.5 text-sm font-medium text-ink hover:underline underline-offset-2"
>
Full {row.name} RPC benchmark
<ArrowUpRight size={14} strokeWidth={2} />
</Link>
{asOf && (
<span className="text-[10.5px] text-ink-faint">Data as of {asOf}</span>
)}
</div>
</section>
);
}
20 changes: 20 additions & 0 deletions src/lib/rpc-hub-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import { REMOVED_BENCH_SLUGS } from "@/lib/removed-benches";
import { readMaterialized, storeConfigured } from "@/lib/materialize/store";
import { chainLabelForSlug } from "@/lib/chains";
import { matchesChainSlug } from "@/lib/chain-aliases";
import type { Benchmark, ProviderResult } from "@/types/benchmark";
import type { Spec } from "@/lib/spec-schema";

Expand Down Expand Up @@ -452,6 +453,25 @@ export async function fetchRpcHub(): Promise<RpcHubSnapshot | null> {
* a page that also renders the section costs no extra roundtrip. Used
* to decide upfront whether the RPC domain gets a KPI pill.
*/
/**
* Chain centric availability helper for /chains/<slug>: the hub row for
* a site chain slug, or null when the chain has no `<chain>-rpc` bench
* in the snapshot. Alias aware (matchesChainSlug), so a renamed site
* slug still lands on a hub row carrying the legacy slug. Reads the
* same cached snapshot as ChainRpcSection, so gating the RPC KPI pill
* costs no extra roundtrip.
*/
export async function getRpcChainRow(
siteChainSlug: string,
): Promise<RpcHubChain | null> {
const snapshot = await fetchRpcHub();
if (!snapshot) return null;
return (
snapshot.chains.find((c) => matchesChainSlug(c.chain, siteChainSlug)) ??
null
);
}

export async function hasRpcProviderData(slug: string): Promise<boolean> {
const snapshot = await fetchRpcHub();
if (!snapshot) return false;
Expand Down
Loading