diff --git a/public/logos/aster.jpg b/public/logos/aster.jpg deleted file mode 100644 index b94df50b..00000000 Binary files a/public/logos/aster.jpg and /dev/null differ diff --git a/public/logos/aster.svg b/public/logos/aster.svg index 6559bc7b..13845d55 100644 --- a/public/logos/aster.svg +++ b/public/logos/aster.svg @@ -1,25 +1,25 @@ - - - - - - + + + + + + - - - + + + - - - + + + - - - + + + - - - + + + diff --git a/public/logos/defi-saver.jpg b/public/logos/defi-saver.jpg new file mode 100644 index 00000000..1173f20d Binary files /dev/null and b/public/logos/defi-saver.jpg differ diff --git a/public/logos/defi-saver.svg b/public/logos/defi-saver.svg deleted file mode 100644 index 61eb4ce2..00000000 --- a/public/logos/defi-saver.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 53d458f7..06f156af 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -28,6 +28,7 @@ import { PERP_PRODUCT_PILL_SLUGS, } from "@/lib/perp-venue-context"; import { PerpVenueSection } from "@/components/perp-venue-section"; +import { VenueKpiToggle } from "@/components/venue-kpi-toggle"; import { PmDataFeedSection } from "@/components/pm-data-feed-section"; import { RpcProviderChainsSection } from "@/components/rpc-provider-chains-section"; @@ -540,16 +541,48 @@ export default async function ProviderPage({ {hlStats && } - {pmContext?.kind === "venue" && ( - - )} + {(() => { + // Cohort sections. When a product belongs to BOTH the PM venue + // cohort and the perp cohort, wrap the two sections in a pill + // toggle so both stay reachable on the same page. With a single + // cohort the section renders directly, no toggle bar. + const pmVenueSection = + pmContext?.kind === "venue" ? ( + + ) : null; + const perpVenueSection = perpContext ? ( + + ) : null; + if (pmVenueSection && perpVenueSection) { + return ( + + ); + } + return pmVenueSection ?? perpVenueSection; + })()} {pmContext?.kind === "feed" && ( )} - {perpContext && ( - - )} - {reg && (

diff --git a/src/components/venue-kpi-toggle.tsx b/src/components/venue-kpi-toggle.tsx new file mode 100644 index 00000000..33435515 --- /dev/null +++ b/src/components/venue-kpi-toggle.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; + +/** + * Pill toggle that swaps between pre-rendered venue sections on + * /products/ without navigation. The sections are server + * components rendered by the page and passed in as ReactNode content, + * so switching tabs costs zero network round trips. + * + * Callers only mount this when at least two sections exist; with a + * single cohort the page renders the section directly (a one-button + * toggle bar would be noise). Inactive sections stay in the DOM under + * `hidden` so tab switches are instant and anchors keep working. + * + * Pill styling mirrors PmHubTabs / PerpHubTabs. + */ + +export type VenueToggleSection = { + id: string; + label: string; + content: React.ReactNode; +}; + +export function VenueKpiToggle({ + sections, +}: { + sections: VenueToggleSection[]; +}) { + const [active, setActive] = useState(sections[0]?.id ?? ""); + + if (sections.length === 0) return null; + if (sections.length === 1) return <>{sections[0].content}; + + return ( +

+
+ {sections.map((s) => ( + + ))} +
+ {sections.map((s) => ( + + ))} +
+ ); +} diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 6d3d7c3d..bd116a8d 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -310,7 +310,7 @@ const RAW: Record = { // Identified via on-chain HL referral codes + brand cross-reference // (DFS, UNITYWALLET, INVO, MARSGO, BITGETWALLET). See builders.json // notes on the HL node for the provenance trail per address. - "defi-saver": "/logos/defi-saver.svg", + "defi-saver": "/logos/defi-saver.jpg", unitywallet: "/logos/unitywallet.png", invo: "/logos/invo.png", marsgo: "/logos/marsgo.jpg", diff --git a/src/lib/perp-stats.ts b/src/lib/perp-stats.ts index 08efd8e5..5e3e6eaf 100644 --- a/src/lib/perp-stats.ts +++ b/src/lib/perp-stats.ts @@ -112,7 +112,7 @@ function promUrl(): string | null { * Bumped any time the PerpCohortSummary shape changes so a stale-shape * blob from a previous deploy can never deserialize into a misaligned * payload. The cohort-snapshot module appends its own `:v1` suffix. */ -const PERP_COHORT_KEY = "perp-cohort"; +export const PERP_COHORT_KEY = "perp-cohort"; /** * Fetch the cohort in one Promise.all fan out. Returns null when Prom @@ -352,7 +352,7 @@ export async function fetchPerpCohort(): Promise { * three asset columns plus the fee column round-trip Prom at most once * every two minutes regardless of page traffic. */ -async function fetchPerpByAssetMatrixRaw(): Promise { +export async function fetchPerpByAssetMatrixFresh(): Promise { const url = promUrl(); if (!url) return []; let prom: Prometheus; @@ -427,9 +427,34 @@ async function fetchPerpByAssetMatrixRaw(): Promise { ); } +const PERP_BY_ASSET_KEY = "perp-by-asset"; + +// Snapshot-first, mirroring fetchPerpCohortRaw: Vercel has no +// PROMETHEUS_URL (the VPS Prom is not public), so the live path only +// works for the worker; readers get the worker-written blob. +async function fetchPerpByAssetMatrixRaw(): Promise { + const snapshot = await readCohortSnapshot(PERP_BY_ASSET_KEY); + if (snapshot && Array.isArray(snapshot.data) && snapshot.data.length > 0) { + return snapshot.data; + } + const fresh = await fetchPerpByAssetMatrixFresh(); + if (fresh.length > 0) { + try { + await writeCohortSnapshot(PERP_BY_ASSET_KEY, fresh); + } catch (err) { + console.warn( + `perp-by-asset writeback failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + return fresh; +} + const fetchPerpByAssetMatrixCached = unstable_cache( fetchPerpByAssetMatrixRaw, - ["perp-by-asset-matrix-v1"], + ["perp-by-asset-matrix-v2"], { revalidate: 120, tags: ["perp-by-asset"] }, ); diff --git a/src/lib/perp-venue-data.ts b/src/lib/perp-venue-data.ts index f134f53d..f0bc396c 100644 --- a/src/lib/perp-venue-data.ts +++ b/src/lib/perp-venue-data.ts @@ -11,6 +11,8 @@ import { unstable_cache } from "next/cache"; import { Prometheus } from "@/lib/prometheus"; +import { readCohortSnapshot } from "@/lib/cohort-snapshot"; +import { PERP_COHORT_KEY, type PerpCohortSummary } from "@/lib/perp-stats"; export type PerpVenueKpis = { /** USD 24h traded volume. */ @@ -47,9 +49,46 @@ function perpFeesLabel(slug: string): string { return slug === "gmx-v2" ? "gmx" : slug; } +/** + * Snapshot-first venue KPIs. The materialize worker writes the + * perp-cohort blob to KV every minute; the Vercel site has no + * PROMETHEUS_URL, so the blob is the only data source that works in + * prod. Returns null when the blob is missing, stale, or has no usable + * row for the venue, and the caller falls through to Prom. + */ +async function fetchPerpVenueKpisFromSnapshot( + slug: string, +): Promise { + const snap = await readCohortSnapshot(PERP_COHORT_KEY); + const row = snap?.data?.venues?.find((v) => v.slug === slug); + if (!row) return null; + + const kpis: PerpVenueKpis = { + volume24h: row.volume24h ?? null, + volume30d: row.volume30d ?? null, + openInterest: row.openInterest ?? null, + fees30d: row.fees30d ?? null, + activeMarkets: row.activeMarkets ?? null, + topMarketVolume24h: row.topMarketVolume24h ?? null, + health: row.health ?? null, + allInFeeBpsEth: row.allInFeeBpsEth ?? null, + funding24hBpsEth: row.funding24hBpsEth ?? null, + }; + + // A row with every field null means the harness has not published + // this venue yet; let the Prom fallback take a shot instead. + const hasData = Object.values(kpis).some((v) => v !== null); + return hasData ? kpis : null; +} + async function fetchPerpVenueKpisRaw( slug: string, ): Promise { + const fromSnapshot = await fetchPerpVenueKpisFromSnapshot(slug); + if (fromSnapshot) return fromSnapshot; + + // Fallback: direct Prom probes. Only works where PROMETHEUS_URL is + // set (the materialize worker context), never on the Vercel site. const url = promUrl(); if (!url) return null; let prom: Prometheus; @@ -112,7 +151,8 @@ async function fetchPerpVenueKpisRaw( const fetchPerpVenueKpisCached = unstable_cache( fetchPerpVenueKpisRaw, - ["perp-venue-kpis-v1"], + // v2: snapshot-first (perp-cohort blob) with Prom fallback. + ["perp-venue-kpis-v2"], { revalidate: 120, tags: ["perp-venue"] }, ); diff --git a/src/lib/pm-stats.ts b/src/lib/pm-stats.ts index 23e866e6..5afd3552 100644 --- a/src/lib/pm-stats.ts +++ b/src/lib/pm-stats.ts @@ -102,7 +102,7 @@ function promUrl(): string | null { * worker after every tierA sweep. Bump the suffix if the summary shape * changes so a stale-shape blob can never deserialize into a misaligned * payload. The cohort-snapshot module appends its own `:v1`. */ -const PM_HUB_KEY = "pm-hub"; +export const PM_HUB_KEY = "pm-hub"; /** * Fetch the venue + data feed cohort in one Promise.all fan out. Returns diff --git a/src/lib/pm-venue-data.ts b/src/lib/pm-venue-data.ts index 379882f6..d3b65b37 100644 --- a/src/lib/pm-venue-data.ts +++ b/src/lib/pm-venue-data.ts @@ -18,6 +18,8 @@ import { unstable_cache } from "next/cache"; import { Prometheus } from "@/lib/prometheus"; +import { readCohortSnapshot } from "@/lib/cohort-snapshot"; +import { PM_HUB_KEY, type PmCohortSummary } from "@/lib/pm-stats"; // `include_tag=true` is required: without it gamma-api returns // `tags: null` for every market, every classifyCategory call falls @@ -254,9 +256,51 @@ function promUrl(): string | null { return process.env.PROMETHEUS_URL?.trim() || null; } +/** + * Snapshot-first venue KPIs. The materialize worker writes the pm-hub + * cohort blob to KV every minute; the Vercel site has no PROMETHEUS_URL, + * so the blob is the only data source that works in prod. The blob lacks + * the p99 / uptime / rate-limit fields, which stay null (the strip skips + * null cards). Returns null when the blob is missing, stale, or has no + * usable row for the venue, and the caller falls through to Prom. + */ +async function fetchPmVenueKpisFromSnapshot( + slug: string, +): Promise { + const snap = await readCohortSnapshot(PM_HUB_KEY); + const row = snap?.data?.venues?.find((v) => v.slug === slug); + if (!row) return null; + + const kpis: PmVenueKpis = { + volume30d: row.volume30d ?? null, + openInterest: row.openInterest ?? null, + activeMarkets: row.activeMarkets ?? null, + medianResolutionSec: + row.medianResolutionDelayMin != null + ? row.medianResolutionDelayMin * 60 + : null, + apiP50Ms: row.p50ApiLatencyMs ?? null, + // Not in the cohort blob; the strip hides these cards. + apiP99Ms: null, + uptime24h: null, + rateLimitHeadroom: null, + marketsAbove1m: row.marketsAbove1m ?? null, + }; + + // A row with every field null means the harness has not published + // this venue yet; let the Prom fallback take a shot instead. + const hasData = Object.values(kpis).some((v) => v !== null); + return hasData ? kpis : null; +} + async function fetchPmVenueKpisRaw( slug: string, ): Promise { + const fromSnapshot = await fetchPmVenueKpisFromSnapshot(slug); + if (fromSnapshot) return fromSnapshot; + + // Fallback: direct Prom probes. Only works where PROMETHEUS_URL is + // set (the materialize worker context), never on the Vercel site. const url = promUrl(); if (!url) return null; let prom: Prometheus; @@ -321,7 +365,8 @@ async function fetchPmVenueKpisRaw( const fetchPmVenueKpisCached = unstable_cache( fetchPmVenueKpisRaw, - ["pm-venue-kpis-v1"], + // v2: snapshot-first (pm-hub cohort blob) with Prom fallback. + ["pm-venue-kpis-v2"], { revalidate: 120, tags: ["pm-venue"] }, ); diff --git a/worker/index.ts b/worker/index.ts index be61811a..5ce7be12 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -43,7 +43,8 @@ import { cohortSnapshotConfigured, writeCohortSnapshot, } from "@/lib/cohort-snapshot"; -import { fetchPerpCohortFresh } from "@/lib/perp-stats"; +import { fetchPerpCohortFresh, + fetchPerpByAssetMatrixFresh } from "@/lib/perp-stats"; import { fetchHlBuilderStatsFresh, fetchHlCohortFresh, @@ -369,6 +370,7 @@ async function sweep(iteration: number): Promise { build: () => Promise; }> = [ { key: "perp-cohort", build: () => fetchPerpCohortFresh() }, + { key: "perp-by-asset", build: () => fetchPerpByAssetMatrixFresh() }, { key: "hl-frontends", build: () => Promise.resolve(hlCohort) }, { key: "hl-hip3", build: () => fetchHlHip3CohortFresh() }, { key: "hl-history", build: () => fetchHlHistoryFresh() },