Skip to content

Commit 458e7d2

Browse files
committed
chore: add indexing card on chain stats pages
1 parent 6085e0a commit 458e7d2

5 files changed

Lines changed: 193 additions & 62 deletions

File tree

app/(home)/stats/chain-list/page.tsx

Lines changed: 102 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ import {
1919
Eye,
2020
Twitter,
2121
Linkedin,
22+
Clock,
2223
} from "lucide-react";
24+
25+
const REQUEST_INDEXING_FORM_URL = "https://forms.gle/N4QkRo9UR45xeTTp9";
2326
import l1ChainsData from "@/constants/l1-chains.json";
2427
import { L1Chain } from "@/types/stats";
2528
import { AvalancheLogo } from "@/components/navigation/avalanche-logo";
@@ -56,8 +59,10 @@ export default function ChainListPage() {
5659
// Load custom chains from localStorage
5760
const [customChains, setCustomChains] = useState<L1Chain[]>([]);
5861

59-
// Track Glacier support for each chain
60-
const [glacierSupportMap, setGlacierSupportMap] = useState<Map<string, boolean>>(new Map());
62+
// Track whether the Stats page is available for each chain (i.e. Glacier supports it)
63+
const [statsSupportMap, setStatsSupportMap] = useState<Map<string, boolean>>(new Map());
64+
// Track whether each chain has any indexed metrics activity. Undefined while loading.
65+
const [isIndexedMap, setIsIndexedMap] = useState<Map<string, boolean>>(new Map());
6166

6267
useEffect(() => {
6368
setIsMounted(true);
@@ -125,41 +130,46 @@ export default function ChainListPage() {
125130
return { total: allChains.length, mainnet, testnet, console };
126131
}, [allChains]);
127132

128-
// Fetch Glacier support for all chains
133+
// Fetch Glacier support + indexing status for all chains
129134
useEffect(() => {
130-
const fetchGlacierSupport = async () => {
135+
const fetchChainStatus = async () => {
131136
const supportMap = new Map<string, boolean>();
132-
133-
// Fetch support for all chains in parallel (with batching to avoid overwhelming the API)
137+
const indexedMap = new Map<string, boolean>();
138+
139+
// Fetch status for all chains in parallel (with batching to avoid overwhelming the API)
134140
const chainsToCheck = allChains.filter(chain => chain.chainId);
135141
const batchSize = 10;
136-
142+
137143
for (let i = 0; i < chainsToCheck.length; i += batchSize) {
138144
const batch = chainsToCheck.slice(i, i + batchSize);
139-
145+
140146
await Promise.all(
141147
batch.map(async (chain) => {
142148
try {
143149
const response = await fetch(`/api/explorer/${chain.chainId}?priceOnly=true`);
144150
if (response.ok) {
145151
const data = await response.json();
146152
supportMap.set(chain.chainId, data.glacierSupported === true);
153+
indexedMap.set(chain.chainId, data.isIndexed === true);
147154
} else {
148155
supportMap.set(chain.chainId, false);
156+
indexedMap.set(chain.chainId, false);
149157
}
150158
} catch (error) {
151-
console.warn(`Failed to check Glacier support for chain ${chain.chainId}:`, error);
159+
console.warn(`Failed to check chain status for ${chain.chainId}:`, error);
152160
supportMap.set(chain.chainId, false);
161+
indexedMap.set(chain.chainId, false);
153162
}
154163
})
155164
);
156165
}
157-
158-
setGlacierSupportMap(supportMap);
166+
167+
setStatsSupportMap(supportMap);
168+
setIsIndexedMap(indexedMap);
159169
};
160170

161171
if (allChains.length > 0) {
162-
fetchGlacierSupport();
172+
fetchChainStatus();
163173
}
164174
}, [allChains]);
165175

@@ -222,12 +232,17 @@ export default function ChainListPage() {
222232
chain.networkToken?.symbol?.toLowerCase().includes(searchTerm.toLowerCase()) ||
223233
chain.slug.toLowerCase().includes(searchTerm.toLowerCase());
224234

225-
// RPC URL filter
226-
const matchesRpcFilter = !hideWithoutRpc || !!chain.rpcUrl;
235+
// RPC URL filter — never hide chains we've confirmed are not indexed,
236+
// since their card itself acts as a "request indexing" CTA. The
237+
// chain.isIndexed === false manual override also forces visibility.
238+
const isManuallyNotIndexed = chain.isIndexed === false;
239+
const isConfirmedNotIndexed = isManuallyNotIndexed
240+
|| (isIndexedMap.has(chain.chainId) && !isIndexedMap.get(chain.chainId));
241+
const matchesRpcFilter = !hideWithoutRpc || !!chain.rpcUrl || isConfirmedNotIndexed;
227242

228243
return matchesNetwork && matchesCategory && matchesSearch && matchesRpcFilter;
229244
});
230-
}, [allChains, selectedNetwork, selectedCategory, searchTerm, hideWithoutRpc]);
245+
}, [allChains, selectedNetwork, selectedCategory, searchTerm, hideWithoutRpc, isIndexedMap]);
231246

232247
const getThemedLogoUrl = (logoUrl: string): string => {
233248
if (!isMounted || !logoUrl) return logoUrl;
@@ -477,7 +492,11 @@ export default function ChainListPage() {
477492
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
478493
{filteredChains.map((chain) => {
479494
const chainIdHex = formatChainIdHex(chain.chainId);
480-
495+
// Manual override from l1-chains.json wins; otherwise wait for the live probe
496+
// so we don't briefly show the banner during initial load.
497+
const isNotIndexed = chain.isIndexed === false
498+
|| (isIndexedMap.has(chain.chainId) && !isIndexedMap.get(chain.chainId));
499+
481500
return (
482501
<Card
483502
key={`${chain.chainId}-${chain.slug}`}
@@ -628,50 +647,74 @@ export default function ChainListPage() {
628647
</div>
629648
</div>
630649

631-
{/* Action Buttons - pushed to bottom */}
632-
<div className="space-y-2.5 mt-auto">
633-
{/* Connect Wallet Button */}
634-
{chain.rpcUrl ? (
635-
<AddToWalletButton
636-
rpcUrl={chain.rpcUrl}
637-
chainName={chain.chainName}
638-
chainId={parseInt(chain.chainId)}
639-
tokenSymbol={chain.networkToken?.symbol}
640-
variant="default"
641-
className="w-full h-10 text-sm font-semibold shadow-sm hover:shadow-md transition-all"
642-
/>
643-
) : (
644-
<div className="w-full h-10 flex items-center justify-center text-xs text-zinc-400 dark:text-zinc-500 bg-zinc-100 dark:bg-zinc-800 rounded-md">
645-
No RPC URL available
646-
</div>
647-
)}
648-
649-
{/* Stats and Explorer Buttons */}
650-
{chain.slug && (
651-
<div className="grid grid-cols-2 gap-2.5">
652-
<Button
653-
variant="outline"
654-
size="sm"
655-
onClick={() => glacierSupportMap.get(chain.chainId) && (window.location.href = `/stats/l1/${chain.slug}`)}
656-
disabled={glacierSupportMap.size > 0 && !glacierSupportMap.get(chain.chainId)}
657-
className="h-10 gap-2 font-medium border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700 hover:text-zinc-900 dark:hover:text-zinc-100 transition-all shadow-sm hover:shadow-md disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-white dark:disabled:hover:bg-zinc-900 disabled:hover:border-zinc-200 dark:disabled:hover:border-zinc-800"
658-
>
659-
<BarChart3 className="w-4 h-4" />
660-
<span className="text-sm">Stats</span>
661-
</Button>
662-
<Button
663-
variant="outline"
664-
size="sm"
665-
onClick={() => chain.rpcUrl && (window.location.href = `/explorer/${chain.slug}`)}
666-
disabled={!chain.rpcUrl}
667-
className="h-10 gap-2 font-medium border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700 hover:text-zinc-900 dark:hover:text-zinc-100 transition-all shadow-sm hover:shadow-md disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-white dark:disabled:hover:bg-zinc-900 disabled:hover:border-zinc-200 dark:disabled:hover:border-zinc-800"
668-
>
669-
<Eye className="w-4 h-4" />
670-
<span className="text-sm">Explorer</span>
671-
</Button>
650+
{/* Bottom slot — for not-indexed chains, replace action buttons with a CTA card */}
651+
{isNotIndexed ? (
652+
<div className="mt-auto rounded-lg border border-dashed border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/40 px-4 py-4 flex flex-col items-center justify-center text-center gap-2">
653+
<div className="flex items-center gap-1.5">
654+
<Clock className="w-4 h-4 text-amber-500 dark:text-amber-400" />
655+
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-200">
656+
Not indexed yet
657+
</span>
672658
</div>
673-
)}
674-
</div>
659+
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-snug">
660+
Activity, stats, and explorer data will be available once this chain is indexed.
661+
</p>
662+
<a
663+
href={REQUEST_INDEXING_FORM_URL}
664+
target="_blank"
665+
rel="noopener noreferrer"
666+
onClick={(e) => e.stopPropagation()}
667+
className="mt-1 inline-flex items-center gap-1 px-3 py-1.5 rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-xs font-medium text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600 transition-colors shadow-sm"
668+
>
669+
Request indexing
670+
<ChevronRight className="w-3 h-3" />
671+
</a>
672+
</div>
673+
) : (
674+
<div className="space-y-2.5 mt-auto">
675+
{/* Connect Wallet Button */}
676+
{chain.rpcUrl ? (
677+
<AddToWalletButton
678+
rpcUrl={chain.rpcUrl}
679+
chainName={chain.chainName}
680+
chainId={parseInt(chain.chainId)}
681+
tokenSymbol={chain.networkToken?.symbol}
682+
variant="default"
683+
className="w-full h-10 text-sm font-semibold shadow-sm hover:shadow-md transition-all"
684+
/>
685+
) : (
686+
<div className="w-full h-10 flex items-center justify-center text-xs text-zinc-400 dark:text-zinc-500 bg-zinc-100 dark:bg-zinc-800 rounded-md">
687+
No RPC URL available
688+
</div>
689+
)}
690+
691+
{/* Stats and Explorer Buttons */}
692+
{chain.slug && (
693+
<div className="grid grid-cols-2 gap-2.5">
694+
<Button
695+
variant="outline"
696+
size="sm"
697+
onClick={() => statsSupportMap.get(chain.chainId) && (window.location.href = `/stats/l1/${chain.slug}`)}
698+
disabled={statsSupportMap.size > 0 && !statsSupportMap.get(chain.chainId)}
699+
className="h-10 gap-2 font-medium border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700 hover:text-zinc-900 dark:hover:text-zinc-100 transition-all shadow-sm hover:shadow-md disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-white dark:disabled:hover:bg-zinc-900 disabled:hover:border-zinc-200 dark:disabled:hover:border-zinc-800"
700+
>
701+
<BarChart3 className="w-4 h-4" />
702+
<span className="text-sm">Stats</span>
703+
</Button>
704+
<Button
705+
variant="outline"
706+
size="sm"
707+
onClick={() => chain.rpcUrl && (window.location.href = `/explorer/${chain.slug}`)}
708+
disabled={!chain.rpcUrl}
709+
className="h-10 gap-2 font-medium border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700 hover:text-zinc-900 dark:hover:text-zinc-100 transition-all shadow-sm hover:shadow-md disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-white dark:disabled:hover:bg-zinc-900 disabled:hover:border-zinc-200 dark:disabled:hover:border-zinc-800"
710+
>
711+
<Eye className="w-4 h-4" />
712+
<span className="text-sm">Explorer</span>
713+
</Button>
714+
</div>
715+
)}
716+
</div>
717+
)}
675718
</div>
676719
</div>
677720
</Card>

app/api/explorer/[chainId]/route.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,37 @@ async function checkGlacierSupport(chainId: string): Promise<boolean> {
791791
}
792792
}
793793

794+
// Probe metrics API for any non-zero activity over the last ~30 days.
795+
// Returns false if metrics API is unconfigured, errors out, or every value is zero —
796+
// which is our signal that the chain is not indexed yet.
797+
async function checkChainIndexed(chainId: string): Promise<boolean> {
798+
const metricsApiUrl = process.env.METRICS_API_URL;
799+
if (!metricsApiUrl) return false;
800+
try {
801+
const endTimestamp = Math.floor(Date.now() / 1000);
802+
const startTimestamp = endTimestamp - 30 * 24 * 60 * 60;
803+
const url = new URL(`${metricsApiUrl}/v2/chains/${chainId}/metrics/cumulativeTxCount`);
804+
url.searchParams.set('timeInterval', 'day');
805+
url.searchParams.set('startTimestamp', String(startTimestamp));
806+
url.searchParams.set('endTimestamp', String(endTimestamp));
807+
url.searchParams.set('pageSize', '30');
808+
809+
const controller = new AbortController();
810+
const timeoutId = setTimeout(() => controller.abort(), 5000);
811+
try {
812+
const res = await fetch(url.toString(), { signal: controller.signal });
813+
if (!res.ok) return false;
814+
const data = await res.json();
815+
const results: { value?: number }[] = Array.isArray(data?.results) ? data.results : [];
816+
return results.some((r) => Number(r?.value) > 0);
817+
} finally {
818+
clearTimeout(timeoutId);
819+
}
820+
} catch {
821+
return false;
822+
}
823+
}
824+
794825
export async function GET(
795826
request: NextRequest,
796827
{ params }: { params: Promise<{ chainId: string }> }
@@ -827,9 +858,10 @@ export async function GET(
827858
// If priceOnly, just fetch price and glacier support (for ExplorerContext)
828859
if (priceOnly) {
829860
const priceOnlyStart = Date.now();
830-
const [price, glacierSupported] = await Promise.all([
861+
const [price, glacierSupported, hasMetricsActivity] = await Promise.all([
831862
coingeckoId ? fetchPrice(coingeckoId) : Promise.resolve(undefined),
832863
checkGlacierSupport(chainId),
864+
checkChainIndexed(chainId),
833865
]);
834866
requestTiming.priceOnly = Date.now() - priceOnlyStart;
835867
requestTiming.total = Date.now() - requestStart;
@@ -838,6 +870,7 @@ export async function GET(
838870
price,
839871
tokenSymbol,
840872
glacierSupported,
873+
isIndexed: glacierSupported && hasMetricsActivity,
841874
});
842875
}
843876

components/stats/ChainMetricsPage.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import {
1616
getMAConfig,
1717
timeSeriesMetricToChartData,
1818
} from "@/utils/chart-utils";
19-
import { Users, Activity, FileText, MessageCircleMore, TrendingUp, UserPlus, Hash, Code2, Gauge, DollarSign, Clock, Fuel, ArrowUpRight, Twitter, Linkedin, Download, Camera, Sparkles, Monitor } from "lucide-react";
19+
import { Users, Activity, FileText, MessageCircleMore, TrendingUp, UserPlus, Hash, Code2, Gauge, DollarSign, Clock, Fuel, ArrowUpRight, Twitter, Linkedin, Download, Camera, Sparkles, Monitor, ChevronRight } from "lucide-react";
20+
21+
const REQUEST_INDEXING_FORM_URL = "https://forms.gle/N4QkRo9UR45xeTTp9";
2022
import { ImageExportStudio } from "@/components/stats/image-export";
2123
import { ChainIdChips } from "@/components/ui/copyable-id-chip";
2224
import { AddToWalletButton } from "@/components/ui/add-to-wallet-button";
@@ -1339,6 +1341,26 @@ export default function ChainMetricsPage({
13391341
);
13401342
}
13411343

1344+
// Detect chains that aren't indexed (yet) by the metrics API.
1345+
// Mirrors the chain-list page probe: cumulativeTxCount being N/A or 0 means there's no
1346+
// recorded activity for this chain. We only flag this for single-chain views — the
1347+
// aggregate "all chains" view always has data. A manual override via
1348+
// l1-chains.json `isIndexed: false` always wins (used during reindexing).
1349+
const cumTxRaw = metrics?.cumulativeTxCount?.current_value;
1350+
const cumTxNumeric =
1351+
typeof cumTxRaw === "number"
1352+
? cumTxRaw
1353+
: typeof cumTxRaw === "string"
1354+
? parseFloat(cumTxRaw)
1355+
: NaN;
1356+
const isManuallyNotIndexed = chainData?.isIndexed === false;
1357+
const isNotIndexed =
1358+
!isAllChainsView &&
1359+
(isManuallyNotIndexed
1360+
|| (!loading
1361+
&& metrics !== null
1362+
&& (!isFinite(cumTxNumeric) || cumTxNumeric === 0)));
1363+
13421364
return (
13431365
<div className="min-h-screen bg-white dark:bg-zinc-950">
13441366
{/* Hero - Clean typographic approach with gradient accent */}
@@ -1544,6 +1566,31 @@ export default function ChainMetricsPage({
15441566
</div>
15451567
</div>
15461568

1569+
{isNotIndexed ? (
1570+
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-12 sm:py-20">
1571+
<div className="rounded-2xl border border-dashed border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900/40 px-6 sm:px-10 py-10 sm:py-14 flex flex-col items-center text-center gap-3">
1572+
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-sm">
1573+
<Clock className="w-5 h-5 text-amber-500 dark:text-amber-400" />
1574+
</div>
1575+
<h2 className="text-xl sm:text-2xl font-semibold text-zinc-900 dark:text-white">
1576+
Not indexed yet
1577+
</h2>
1578+
<p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400 leading-relaxed">
1579+
We don&apos;t have activity data for {chainName} yet. Stats and charts will appear here once this chain is indexed.
1580+
</p>
1581+
<a
1582+
href={REQUEST_INDEXING_FORM_URL}
1583+
target="_blank"
1584+
rel="noopener noreferrer"
1585+
className="mt-3 inline-flex items-center gap-1.5 px-4 py-2 rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm font-medium text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600 transition-colors shadow-sm"
1586+
>
1587+
Request indexing
1588+
<ChevronRight className="w-3.5 h-3.5" />
1589+
</a>
1590+
</div>
1591+
</div>
1592+
) : (
1593+
<>
15471594
{/* Sticky Navigation Bar - full width, positioned below main navbar */}
15481595
<StickyNavBar
15491596
categories={chartCategories}
@@ -2211,6 +2258,8 @@ export default function ChainMetricsPage({
22112258
</>
22122259
)}
22132260
</div>
2261+
</>
2262+
)}
22142263

22152264
{/* Bubble Navigation */}
22162265
{chainSlug && !isAllChainsView ? (

constants/l1-chains.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,8 @@
572572
"name": "Cogitus Explorer",
573573
"link": "https://explorer-binaryholdings.cogitus.io"
574574
}
575-
]
575+
],
576+
"isIndexed": false
576577
},
577578
{
578579
"chainId": "46975",

0 commit comments

Comments
 (0)