178 lines
7.2 KiB
TypeScript
178 lines
7.2 KiB
TypeScript
|
|
import { useEffect, useMemo, useState } from 'react'
|
||
|
|
import { Card } from '@/libs/frontend-ui-primitives'
|
||
|
|
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||
|
|
import { blocksApi, type Block } from '@/services/api/blocks'
|
||
|
|
import {
|
||
|
|
missionControlApi,
|
||
|
|
type MissionControlBridgeStatusResponse,
|
||
|
|
type MissionControlChainStatus,
|
||
|
|
} from '@/services/api/missionControl'
|
||
|
|
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||
|
|
import { transactionsApi, type Transaction } from '@/services/api/transactions'
|
||
|
|
import OperationsPageShell, {
|
||
|
|
MetricCard,
|
||
|
|
StatusBadge,
|
||
|
|
formatNumber,
|
||
|
|
relativeAge,
|
||
|
|
truncateMiddle,
|
||
|
|
} from './OperationsPageShell'
|
||
|
|
|
||
|
|
function getChainStatus(bridgeStatus: MissionControlBridgeStatusResponse | null): MissionControlChainStatus | null {
|
||
|
|
const chains = bridgeStatus?.data?.chains
|
||
|
|
if (!chains) return null
|
||
|
|
const [firstChain] = Object.values(chains)
|
||
|
|
return firstChain || null
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function AnalyticsOperationsPage() {
|
||
|
|
const [stats, setStats] = useState<ExplorerStats | null>(null)
|
||
|
|
const [blocks, setBlocks] = useState<Block[]>([])
|
||
|
|
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||
|
|
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||
|
|
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||
|
|
const page = explorerFeaturePages.analytics
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
let cancelled = false
|
||
|
|
|
||
|
|
const load = async () => {
|
||
|
|
const [statsResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
|
||
|
|
statsApi.get(),
|
||
|
|
blocksApi.list({ chain_id: 138, page: 1, page_size: 5 }),
|
||
|
|
transactionsApi.list(138, 1, 5),
|
||
|
|
missionControlApi.getBridgeStatus(),
|
||
|
|
])
|
||
|
|
|
||
|
|
if (cancelled) return
|
||
|
|
|
||
|
|
if (statsResult.status === 'fulfilled') setStats(statsResult.value)
|
||
|
|
if (blocksResult.status === 'fulfilled') setBlocks(blocksResult.value.data)
|
||
|
|
if (transactionsResult.status === 'fulfilled') setTransactions(transactionsResult.value.data)
|
||
|
|
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
|
||
|
|
|
||
|
|
const failedCount = [statsResult, blocksResult, transactionsResult, bridgeResult].filter(
|
||
|
|
(result) => result.status === 'rejected'
|
||
|
|
).length
|
||
|
|
|
||
|
|
if (failedCount === 4) {
|
||
|
|
setLoadingError('Analytics data is temporarily unavailable from the public explorer APIs.')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
load().catch((error) => {
|
||
|
|
if (!cancelled) {
|
||
|
|
setLoadingError(error instanceof Error ? error.message : 'Analytics data is temporarily unavailable from the public explorer APIs.')
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
cancelled = true
|
||
|
|
}
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
const chainStatus = useMemo(() => getChainStatus(bridgeStatus), [bridgeStatus])
|
||
|
|
|
||
|
|
return (
|
||
|
|
<OperationsPageShell page={page}>
|
||
|
|
{loadingError ? (
|
||
|
|
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
||
|
|
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
|
||
|
|
</Card>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||
|
|
<MetricCard
|
||
|
|
title="Total Blocks"
|
||
|
|
value={formatNumber(stats?.total_blocks)}
|
||
|
|
description="Current block count from the public Blockscout stats endpoint."
|
||
|
|
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
|
||
|
|
/>
|
||
|
|
<MetricCard
|
||
|
|
title="Transactions"
|
||
|
|
value={formatNumber(stats?.total_transactions)}
|
||
|
|
description="Total transactions currently indexed by the public explorer."
|
||
|
|
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
|
||
|
|
/>
|
||
|
|
<MetricCard
|
||
|
|
title="Addresses"
|
||
|
|
value={formatNumber(stats?.total_addresses)}
|
||
|
|
description="Known addresses from the public stats surface."
|
||
|
|
/>
|
||
|
|
<MetricCard
|
||
|
|
title="Chain Head"
|
||
|
|
value={chainStatus?.head_age_sec != null ? `${Math.round(chainStatus.head_age_sec)}s` : 'Unknown'}
|
||
|
|
description={
|
||
|
|
chainStatus?.latency_ms != null
|
||
|
|
? `RPC latency ${Math.round(chainStatus.latency_ms)}ms on Chain 138.`
|
||
|
|
: 'Latest public RPC head age from mission control.'
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||
|
|
<Card title="Recent Blocks">
|
||
|
|
<div className="space-y-4">
|
||
|
|
{blocks.map((block) => (
|
||
|
|
<div
|
||
|
|
key={block.hash}
|
||
|
|
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||
|
|
>
|
||
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
|
|
<div>
|
||
|
|
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||
|
|
Block {formatNumber(block.number)}
|
||
|
|
</div>
|
||
|
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
|
|
{truncateMiddle(block.hash)} · miner {truncateMiddle(block.miner)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||
|
|
{formatNumber(block.transaction_count)} tx · {relativeAge(block.timestamp)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
{blocks.length === 0 ? (
|
||
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">No recent block data available.</p>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card title="Recent Transactions">
|
||
|
|
<div className="space-y-4">
|
||
|
|
{transactions.map((transaction) => (
|
||
|
|
<div
|
||
|
|
key={transaction.hash}
|
||
|
|
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||
|
|
>
|
||
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
|
|
<div>
|
||
|
|
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||
|
|
{truncateMiddle(transaction.hash, 12, 10)}
|
||
|
|
</div>
|
||
|
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
|
|
Block {formatNumber(transaction.block_number)} · from {truncateMiddle(transaction.from_address)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<StatusBadge
|
||
|
|
status={transaction.status === 1 ? 'success' : 'failed'}
|
||
|
|
tone={transaction.status === 1 ? 'normal' : 'danger'}
|
||
|
|
/>
|
||
|
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||
|
|
{relativeAge(transaction.created_at)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
{transactions.length === 0 ? (
|
||
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">No recent transaction data available.</p>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
</OperationsPageShell>
|
||
|
|
)
|
||
|
|
}
|