Freshness diagnostics API, UI trust notes, mission control/stats updates, and deploy scripts.

Made-with: Cursor
This commit is contained in:
defiQUG
2026-04-12 06:33:54 -07:00
parent 0972178cc5
commit 3fdb812a29
63 changed files with 5163 additions and 826 deletions

View File

@@ -8,6 +8,12 @@ import { readWatchlistFromStorage } from '@/utils/watchlist'
import PageIntro from '@/components/common/PageIntro'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeTransaction } from '@/services/api/blockscout'
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 } from '@/utils/explorerFreshness'
function normalizeAddress(value: string) {
const trimmed = value.trim()
@@ -16,6 +22,9 @@ function normalizeAddress(value: string) {
interface AddressesPageProps {
initialRecentTransactions: Transaction[]
initialLatestBlocks: Array<{ number: number; timestamp: string }>
initialStats: ExplorerStats | null
initialBridgeStatus: MissionControlBridgeStatusResponse | null
}
function serializeRecentTransactions(transactions: Transaction[]): Transaction[] {
@@ -26,17 +35,43 @@ function serializeRecentTransactions(transactions: Transaction[]): Transaction[]
block_number: transaction.block_number,
from_address: transaction.from_address,
to_address: transaction.to_address ?? null,
created_at: transaction.created_at,
})),
),
) as Transaction[]
}
export default function AddressesPage({ initialRecentTransactions }: AddressesPageProps) {
export default function AddressesPage({
initialRecentTransactions,
initialLatestBlocks,
initialStats,
initialBridgeStatus,
}: AddressesPageProps) {
const router = useRouter()
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [query, setQuery] = useState('')
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
const [watchlist, setWatchlist] = useState<string[]>([])
const activityContext = useMemo(
() =>
summarizeChainActivity({
blocks: initialLatestBlocks.map((block) => ({
chain_id: chainId,
number: block.number,
hash: '',
timestamp: block.timestamp,
miner: '',
transaction_count: 0,
gas_used: 0,
gas_limit: 0,
})),
transactions: recentTransactions,
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
}),
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, recentTransactions],
)
useEffect(() => {
if (initialRecentTransactions.length > 0) {
@@ -111,6 +146,17 @@ export default function AddressesPage({ initialRecentTransactions }: AddressesPa
]}
/>
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Recent Address Activity Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={initialStats}
bridgeStatus={initialBridgeStatus}
scopeLabel="Recently active addresses are derived from the latest visible indexed transactions."
/>
</div>
<Card className="mb-6" title="Open An Address">
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
<input
@@ -158,7 +204,7 @@ export default function AddressesPage({ initialRecentTransactions }: AddressesPa
<Card title="Recently Active Addresses">
{activeAddresses.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent address activity is unavailable right now. You can still open an address directly above.
Recent address activity is unavailable in the latest visible transaction sample. You can still open an address directly above.
</p>
) : (
<div className="space-y-3">
@@ -177,14 +223,30 @@ export default function AddressesPage({ initialRecentTransactions }: AddressesPa
export const getServerSideProps: GetServerSideProps<AddressesPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null)
const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null),
fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null),
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
])
const initialRecentTransactions = Array.isArray(transactionsResult?.items)
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
: []
const initialLatestBlocks = Array.isArray(blocksResult?.items)
? blocksResult.items
.map((item) => ({
number: Number(item.height || 0),
timestamp: item.timestamp || '',
}))
.filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp)
: []
return {
props: {
initialRecentTransactions: serializeRecentTransactions(initialRecentTransactions),
initialLatestBlocks,
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
initialBridgeStatus: bridgeResult,
},
}
}

View File

@@ -1,20 +1,55 @@
import type { GetServerSideProps } from 'next'
import { useCallback, useEffect, useState } from 'react'
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 } from '@/services/api/blockscout'
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
}
export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
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')
@@ -47,8 +82,43 @@ export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
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),
}),
[blocks, initialBridgeStatus, initialStats, recentTransactions],
)
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
@@ -63,12 +133,30 @@ export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
]}
/>
<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>
@@ -161,13 +249,23 @@ export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
export const getServerSideProps: GetServerSideProps<BlocksPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const blocksResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null)
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,
},
}
}

View File

@@ -6,12 +6,12 @@ import PageIntro from '@/components/common/PageIntro'
const docsCards = [
{
title: 'GRU guide',
title: 'GRU Guide',
href: '/docs/gru',
description: 'Understand GRU standards, x402 readiness, wrapped transport posture, and forward-canonical versioning as surfaced by the explorer.',
},
{
title: 'Transaction evidence matrix',
title: 'Transaction Evidence Matrix',
href: '/docs/transaction-review',
description: 'See how the explorer scores transaction evidence quality, decode richness, asset posture, and counterparty traceability.',
},

View File

@@ -11,16 +11,21 @@ import {
} from '@/services/api/stats'
import {
summarizeMissionControlRelay,
type MissionControlBridgeStatusResponse,
type MissionControlRelaySummary,
} from '@/services/api/missionControl'
import type { Block } from '@/services/api/blocks'
import type { Transaction } from '@/services/api/transactions'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeTransaction } from '@/services/api/blockscout'
interface IndexPageProps {
initialStats: ExplorerStats | null
initialRecentBlocks: Block[]
initialRecentTransactions: Transaction[]
initialTransactionTrend: ExplorerTransactionTrendPoint[]
initialActivitySnapshot: ExplorerRecentActivitySnapshot | null
initialBridgeStatus: MissionControlBridgeStatusResponse | null
initialRelaySummary: MissionControlRelaySummary | null
}
@@ -28,10 +33,28 @@ export default function IndexPage(props: IndexPageProps) {
return <HomePage {...props} />
}
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 const getServerSideProps: GetServerSideProps<IndexPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [statsResult, blocksResult, trendResult, activityResult, bridgeResult] = await Promise.allSettled([
const [statsResult, blocksResult, transactionsResult, trendResult, activityResult, bridgeResult] = await Promise.allSettled([
fetchPublicJson<{
total_blocks?: number | string | null
total_transactions?: number | string | null
@@ -39,6 +62,7 @@ export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () =
latest_block?: number | string | null
}>('/api/v2/stats'),
fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=10'),
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5'),
fetchPublicJson<{ chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }> }>(
'/api/v2/stats/charts/transactions'
),
@@ -60,10 +84,18 @@ export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () =
blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items)
? blocksResult.value.items.map((item) => normalizeBlock(item as never, chainId))
: [],
initialRecentTransactions:
transactionsResult.status === 'fulfilled' && Array.isArray(transactionsResult.value?.items)
? serializeTransactions(
transactionsResult.value.items.map((item) => normalizeTransaction(item as never, chainId)),
)
: [],
initialTransactionTrend:
trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value) : [],
initialActivitySnapshot:
activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value) : null,
initialBridgeStatus:
bridgeResult.status === 'fulfilled' ? (bridgeResult.value as MissionControlBridgeStatusResponse) : null,
initialRelaySummary:
bridgeResult.status === 'fulfilled' ? summarizeMissionControlRelay(bridgeResult.value as never) : null,
},

View File

@@ -14,6 +14,7 @@ import {
} from '@/utils/search'
import PageIntro from '@/components/common/PageIntro'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { useUiMode } from '@/components/common/UiModeContext'
type SearchFilterMode = 'all' | 'gru' | 'x402' | 'wrapped'
@@ -28,6 +29,7 @@ export default function SearchPage({
initialRawResults,
initialCuratedTokens,
}: SearchPageProps) {
const { mode } = useUiMode()
const router = useRouter()
const routerQuery = typeof router.query.q === 'string' ? router.query.q : ''
const [query, setQuery] = useState(initialQuery)
@@ -193,7 +195,11 @@ export default function SearchPage({
<PageIntro
eyebrow="Explorer Lookup"
title="Search"
description="Search by address, transaction hash, block number, or token symbol. Direct identifiers can jump straight into detail pages, while broader terms fall back to indexed search."
description={
mode === 'guided'
? 'Search by address, transaction hash, block number, or token symbol. Direct identifiers can jump straight into detail pages, while broader terms fall back to indexed search.'
: 'Search address, tx hash, block, or token symbol. Direct identifiers jump straight to detail pages.'
}
actions={[
{ href: '/tokens', label: 'Token shortcuts' },
{ href: '/addresses', label: 'Browse addresses' },
@@ -207,7 +213,7 @@ export default function SearchPage({
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by address, transaction hash, block number..."
placeholder={mode === 'guided' ? 'Search by address, transaction hash, block number...' : 'Search tx / addr / block / token'}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<button
@@ -237,7 +243,9 @@ export default function SearchPage({
{!loading && tokenTarget && (
<Card className="mb-6" title="Direct Token Match">
<p className="text-sm text-gray-600 dark:text-gray-400">
This matches a curated Chain 138 token, so you can go straight to the token detail page instead of sifting through generic search results.
{mode === 'guided'
? 'This matches a curated Chain 138 token, so you can go straight to the token detail page instead of sifting through generic search results.'
: 'Curated Chain 138 token match.'}
</p>
<div className="mt-4">
<Link href={tokenTarget.href} className="text-primary-600 hover:underline">
@@ -250,7 +258,9 @@ export default function SearchPage({
{!loading && !tokenTarget && directTarget && (
<Card className="mb-6" title="Direct Match">
<p className="text-sm text-gray-600 dark:text-gray-400">
This looks like a direct explorer identifier. You can open it without waiting for indexed search results.
{mode === 'guided'
? 'This looks like a direct explorer identifier. You can open it without waiting for indexed search results.'
: 'Direct explorer identifier detected.'}
</p>
<div className="mt-4">
<Link href={directTarget.href} className="text-primary-600 hover:underline">

View File

@@ -8,9 +8,18 @@ import EntityBadge from '@/components/common/EntityBadge'
import PageIntro from '@/components/common/PageIntro'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeTransaction } from '@/services/api/blockscout'
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 } from '@/utils/explorerFreshness'
interface TransactionsPageProps {
initialTransactions: Transaction[]
initialLatestBlocks: Array<{ number: number; timestamp: string }>
initialStats: ExplorerStats | null
initialBridgeStatus: MissionControlBridgeStatusResponse | null
}
function serializeTransactionList(transactions: Transaction[]): Transaction[] {
@@ -33,12 +42,37 @@ function serializeTransactionList(transactions: Transaction[]): Transaction[] {
) as Transaction[]
}
export default function TransactionsPage({ initialTransactions }: TransactionsPageProps) {
export default function TransactionsPage({
initialTransactions,
initialLatestBlocks,
initialStats,
initialBridgeStatus,
}: TransactionsPageProps) {
const pageSize = 20
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
const [loading, setLoading] = useState(initialTransactions.length === 0)
const [page, setPage] = useState(1)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const activityContext = useMemo(
() =>
summarizeChainActivity({
blocks: initialLatestBlocks.map((block) => ({
chain_id: chainId,
number: block.number,
hash: '',
timestamp: block.timestamp,
miner: '',
transaction_count: 0,
gas_used: 0,
gas_limit: 0,
})),
transactions,
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
}),
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, transactions],
)
const loadTransactions = useCallback(async () => {
setLoading(true)
@@ -163,6 +197,17 @@ export default function TransactionsPage({ initialTransactions }: TransactionsPa
]}
/>
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Transaction Recency Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={initialStats}
bridgeStatus={initialBridgeStatus}
scopeLabel="This page reflects the latest indexed visible transaction activity."
/>
</div>
{!loading && transactions.length > 0 && (
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
@@ -250,14 +295,30 @@ export default function TransactionsPage({ initialTransactions }: TransactionsPa
export const getServerSideProps: GetServerSideProps<TransactionsPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null)
const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null),
fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null),
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
])
const initialTransactions = Array.isArray(transactionsResult?.items)
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
: []
const initialLatestBlocks = Array.isArray(blocksResult?.items)
? blocksResult.items
.map((item) => ({
number: Number(item.height || 0),
timestamp: item.timestamp || '',
}))
.filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp)
: []
return {
props: {
initialTransactions: serializeTransactionList(initialTransactions),
initialLatestBlocks,
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
initialBridgeStatus: bridgeResult,
},
}
}