Files
explorer-monorepo/frontend/src/pages/blocks/index.tsx
defiQUG b5a2e0c0a4 feat(freshness): enhance diagnostics and update snapshot structure
- Introduced a new Diagnostics struct to capture transaction visibility state and activity state.
- Updated BuildSnapshot function to return diagnostics alongside snapshot, completeness, and sampling.
- Enhanced test cases to validate the new diagnostics data.
- Updated frontend components to utilize the new diagnostics information for improved user feedback on freshness context.

This change improves the observability of transaction activity and enhances the user experience by providing clearer insights into the freshness of data.
2026-04-12 18:22:08 -07:00

273 lines
10 KiB
TypeScript

import type { GetServerSideProps } from 'next'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { blocksApi, Block } from '@/services/api/blocks'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import PageIntro from '@/components/common/PageIntro'
import { formatTimestamp } from '@/utils/format'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout'
import type { Transaction } from '@/services/api/transactions'
import { transactionsApi } from '@/services/api/transactions'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
interface BlocksPageProps {
initialBlocks: Block[]
initialRecentTransactions: Transaction[]
initialStats: ExplorerStats | null
initialBridgeStatus: MissionControlBridgeStatusResponse | null
}
function serializeTransactions(transactions: Transaction[]): Transaction[] {
return JSON.parse(
JSON.stringify(
transactions.map((transaction) => ({
hash: transaction.hash,
block_number: transaction.block_number,
from_address: transaction.from_address,
to_address: transaction.to_address ?? null,
value: transaction.value,
status: transaction.status ?? null,
contract_address: transaction.contract_address ?? null,
fee: transaction.fee ?? null,
created_at: transaction.created_at,
})),
),
) as Transaction[]
}
export default function BlocksPage({
initialBlocks,
initialRecentTransactions,
initialStats,
initialBridgeStatus,
}: BlocksPageProps) {
const pageSize = 20
const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
const [loading, setLoading] = useState(initialBlocks.length === 0)
const [page, setPage] = useState(1)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const loadBlocks = useCallback(async () => {
setLoading(true)
try {
const response = await blocksApi.list({
chain_id: chainId,
page,
page_size: pageSize,
sort: 'number',
order: 'desc',
})
setBlocks(response.data)
} catch (error) {
console.error('Failed to load blocks:', error)
setBlocks([])
} finally {
setLoading(false)
}
}, [chainId, page, pageSize])
useEffect(() => {
if (page === 1 && initialBlocks.length > 0) {
setBlocks(initialBlocks)
setLoading(false)
return
}
void loadBlocks()
}, [initialBlocks, loadBlocks, page])
useEffect(() => {
if (initialRecentTransactions.length > 0) {
setRecentTransactions(initialRecentTransactions)
return
}
let active = true
transactionsApi.listSafe(chainId, 1, 5)
.then(({ ok, data }) => {
if (active && ok && data.length > 0) {
setRecentTransactions(data)
}
})
.catch(() => {
if (active) {
setRecentTransactions([])
}
})
return () => {
active = false
}
}, [chainId, initialRecentTransactions])
const showPagination = page > 1 || blocks.length > 0
const canGoNext = blocks.length === pageSize
const activityContext = useMemo(
() =>
summarizeChainActivity({
blocks,
transactions: recentTransactions,
latestBlockNumber: blocks[0]?.number ?? null,
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null,
}),
[blocks, initialBridgeStatus, initialStats, recentTransactions],
)
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
eyebrow="Chain Activity"
title="Blocks"
description="Browse recent Chain 138 blocks, then pivot into transactions, addresses, and indexed search without falling into a dead end."
actions={[
{ href: '/transactions', label: 'Open transactions' },
{ href: '/addresses', label: 'Browse addresses' },
{ href: '/search', label: 'Search explorer' },
]}
/>
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Block Production Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={initialStats}
bridgeStatus={initialBridgeStatus}
scopeLabel="This page focuses on recent visible head blocks."
/>
</div>
{loading ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Loading blocks...</p>
</Card>
) : (
<div className="space-y-4">
{shouldExplainEmptyHeadBlocks(blocks, activityContext) ? (
<Card className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/40 dark:bg-amber-950/20">
<p className="text-sm text-amber-900 dark:text-amber-100">
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
</p>
</Card>
) : null}
{blocks.length === 0 ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Recent blocks are unavailable right now.</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/transactions" className="text-primary-600 hover:underline">
Open recent transactions
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search by block number
</Link>
</div>
</Card>
) : (
blocks.map((block) => (
<Card key={block.number}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link
href={`/blocks/${block.number}`}
className="text-lg font-semibold text-primary-600 hover:text-primary-700"
>
Block #{block.number}
</Link>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
<Address address={block.hash} truncate showCopy={false} />
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Miner:{' '}
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
<Address address={block.miner} truncate showCopy={false} />
</Link>
</div>
</div>
<div className="text-left sm:text-right">
<div className="text-sm">
{formatTimestamp(block.timestamp)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{block.transaction_count} transactions
</div>
</div>
</div>
</Card>
))
)}
</div>
)}
{showPagination && (
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={loading || page === 1}
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
>
Previous
</button>
<span className="px-3 py-2 text-sm sm:px-4">Page {page}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={loading || !canGoNext}
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</div>
)}
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<Card title="Keep Exploring">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Need a different entry point? Open transaction flow, search directly by block number, or jump into recently active addresses.
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/transactions" className="text-primary-600 hover:underline">
Transactions
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Addresses
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search
</Link>
</div>
</Card>
</div>
</div>
)
}
export const getServerSideProps: GetServerSideProps<BlocksPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [blocksResult, transactionsResult, statsResult, bridgeResult] = await Promise.all([
fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null),
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5').catch(() => null),
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
])
return {
props: {
initialBlocks: Array.isArray(blocksResult?.items)
? blocksResult.items.map((item) => normalizeBlock(item as never, chainId))
: [],
initialRecentTransactions: Array.isArray(transactionsResult?.items)
? serializeTransactions(transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId)))
: [],
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
initialBridgeStatus: bridgeResult,
},
}
}