- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation. - Changed default base URL for Playwright tests and updated security headers to reflect the new branding. - Enhanced README and API documentation to include new authentication endpoints and product access details. This refactor aligns the project branding and improves clarity in the API documentation.
355 lines
18 KiB
TypeScript
355 lines
18 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { Card } from '@/libs/frontend-ui-primitives'
|
|
import Link from 'next/link'
|
|
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 ExplorerRecentActivitySnapshot,
|
|
type ExplorerStats,
|
|
type ExplorerTransactionTrendPoint,
|
|
} from '@/services/api/stats'
|
|
import { transactionsApi, type Transaction } from '@/services/api/transactions'
|
|
import { formatWeiAsEth } from '@/utils/format'
|
|
import OperationsPageShell, {
|
|
MetricCard,
|
|
StatusBadge,
|
|
formatNumber,
|
|
relativeAge,
|
|
truncateMiddle,
|
|
} from './OperationsPageShell'
|
|
|
|
interface AnalyticsOperationsPageProps {
|
|
initialStats?: ExplorerStats | null
|
|
initialTransactionTrend?: ExplorerTransactionTrendPoint[]
|
|
initialActivitySnapshot?: ExplorerRecentActivitySnapshot | null
|
|
initialBlocks?: Block[]
|
|
initialTransactions?: Transaction[]
|
|
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
|
}
|
|
|
|
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({
|
|
initialStats = null,
|
|
initialTransactionTrend = [],
|
|
initialActivitySnapshot = null,
|
|
initialBlocks = [],
|
|
initialTransactions = [],
|
|
initialBridgeStatus = null,
|
|
}: AnalyticsOperationsPageProps) {
|
|
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
|
const [transactionTrend, setTransactionTrend] = useState<ExplorerTransactionTrendPoint[]>(initialTransactionTrend)
|
|
const [activitySnapshot, setActivitySnapshot] = useState<ExplorerRecentActivitySnapshot | null>(initialActivitySnapshot)
|
|
const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
|
|
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
|
|
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
|
const [loadingError, setLoadingError] = useState<string | null>(null)
|
|
const page = explorerFeaturePages.analytics
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
const load = async () => {
|
|
const [statsResult, trendResult, snapshotResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
|
|
statsApi.get(),
|
|
statsApi.getTransactionTrend(),
|
|
statsApi.getRecentActivitySnapshot(),
|
|
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 (trendResult.status === 'fulfilled') setTransactionTrend(trendResult.value)
|
|
if (snapshotResult.status === 'fulfilled') setActivitySnapshot(snapshotResult.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, trendResult, snapshotResult, blocksResult, transactionsResult, bridgeResult].filter(
|
|
(result) => result.status === 'rejected'
|
|
).length
|
|
|
|
if (failedCount === 6) {
|
|
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])
|
|
const trailingWindow = useMemo(() => transactionTrend.slice(0, 7), [transactionTrend])
|
|
const sevenDayAverage = useMemo(() => {
|
|
if (trailingWindow.length === 0) return 0
|
|
const total = trailingWindow.reduce((sum, point) => sum + point.transaction_count, 0)
|
|
return total / trailingWindow.length
|
|
}, [trailingWindow])
|
|
const topDay = useMemo(() => {
|
|
if (trailingWindow.length === 0) return null
|
|
return trailingWindow.reduce((best, point) => (point.transaction_count > best.transaction_count ? point : best))
|
|
}, [trailingWindow])
|
|
const averageGasUtilization = useMemo(() => {
|
|
if (blocks.length === 0) return 0
|
|
return blocks.reduce((sum, block) => {
|
|
const ratio = block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0
|
|
return sum + ratio
|
|
}, 0) / blocks.length
|
|
}, [blocks])
|
|
const trendPeak = useMemo(
|
|
() => trailingWindow.reduce((max, point) => Math.max(max, point.transaction_count), 0),
|
|
[trailingWindow],
|
|
)
|
|
|
|
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.'
|
|
}
|
|
/>
|
|
<MetricCard
|
|
title="7d Avg Tx"
|
|
value={formatNumber(Math.round(sevenDayAverage))}
|
|
description="Average daily transactions over the latest seven charted days."
|
|
className="border border-violet-200 bg-violet-50/70 dark:border-violet-900/50 dark:bg-violet-950/20"
|
|
/>
|
|
<MetricCard
|
|
title="Recent Success Rate"
|
|
value={activitySnapshot ? `${Math.round(activitySnapshot.success_rate * 100)}%` : 'Unknown'}
|
|
description="Success rate across the public main-page transaction sample."
|
|
/>
|
|
<MetricCard
|
|
title="Failure Rate"
|
|
value={activitySnapshot ? `${Math.round(activitySnapshot.failure_rate * 100)}%` : 'Unknown'}
|
|
description="The complement to the recent success rate in the visible sample."
|
|
className="border border-rose-200 bg-rose-50/70 dark:border-rose-900/50 dark:bg-rose-950/20"
|
|
/>
|
|
<MetricCard
|
|
title="Avg Gas Used"
|
|
value={activitySnapshot ? formatNumber(Math.round(activitySnapshot.average_gas_used)) : 'Unknown'}
|
|
description="Average gas used in the recent sampled transactions."
|
|
/>
|
|
<MetricCard
|
|
title="Avg Block Gas"
|
|
value={`${Math.round(averageGasUtilization * 100)}%`}
|
|
description="Average gas utilization across the latest visible blocks."
|
|
className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
|
<Card title="Activity Trend">
|
|
<div className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Peak Day</div>
|
|
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
|
{topDay ? formatNumber(topDay.transaction_count) : 'Unknown'}
|
|
</div>
|
|
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{topDay?.date || 'No trend data yet'}</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Contract Creations</div>
|
|
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
|
{formatNumber(activitySnapshot?.contract_creations)}
|
|
</div>
|
|
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Within the sampled recent transaction feed.</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Avg Sample Fee</div>
|
|
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
|
{activitySnapshot ? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6) : 'Unknown'}
|
|
</div>
|
|
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Average fee from the recent public transaction sample.</div>
|
|
</div>
|
|
</div>
|
|
|
|
{activitySnapshot ? (
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token Transfer Share</div>
|
|
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
|
{Math.round(activitySnapshot.token_transfer_share * 100)}%
|
|
</div>
|
|
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions involving token transfers.</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Contract Call Share</div>
|
|
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
|
{Math.round(activitySnapshot.contract_call_share * 100)}%
|
|
</div>
|
|
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions calling contracts.</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Creation Share</div>
|
|
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
|
{Math.round(activitySnapshot.contract_creation_share * 100)}%
|
|
</div>
|
|
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions deploying contracts.</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-3">
|
|
{trailingWindow.map((point) => {
|
|
const width = trendPeak > 0 ? Math.max(8, Math.round((point.transaction_count / trendPeak) * 100)) : 0
|
|
return (
|
|
<div key={point.date}>
|
|
<div className="mb-1 flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
|
|
<span>{point.date}</span>
|
|
<span>{formatNumber(point.transaction_count)} tx</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-gray-200 dark:bg-gray-800">
|
|
<div className="h-2 rounded-full bg-primary-600" style={{ width: `${width}%` }} />
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
{trailingWindow.length === 0 ? (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">Trend data is temporarily unavailable.</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<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>
|
|
<Link href={`/blocks/${block.number}`} className="text-base font-semibold text-primary-600 hover:underline">
|
|
Block {formatNumber(block.number)}
|
|
</Link>
|
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{truncateMiddle(block.hash)} · miner{' '}
|
|
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
|
|
{truncateMiddle(block.miner)}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
{formatNumber(block.transaction_count)} tx · {Math.round((block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0) * 100)}% gas · {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>
|
|
<Link href={`/transactions/${transaction.hash}`} className="text-base font-semibold text-primary-600 hover:underline">
|
|
{truncateMiddle(transaction.hash, 12, 10)}
|
|
</Link>
|
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
Block{' '}
|
|
<Link href={`/blocks/${transaction.block_number}`} className="text-primary-600 hover:underline">
|
|
{formatNumber(transaction.block_number)}
|
|
</Link>
|
|
{' '}· from{' '}
|
|
<Link href={`/addresses/${transaction.from_address}`} className="text-primary-600 hover:underline">
|
|
{truncateMiddle(transaction.from_address)}
|
|
</Link>
|
|
{transaction.to_address ? (
|
|
<>
|
|
{' '}· to{' '}
|
|
<Link href={`/addresses/${transaction.to_address}`} className="text-primary-600 hover:underline">
|
|
{truncateMiddle(transaction.to_address)}
|
|
</Link>
|
|
</>
|
|
) : null}
|
|
</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 className="mt-3 flex flex-wrap gap-2 text-xs">
|
|
{transaction.method ? <StatusBadge status={transaction.method} tone="warning" /> : null}
|
|
{transaction.contract_address ? <StatusBadge status="contract creation" tone="warning" /> : null}
|
|
{transaction.token_transfers && transaction.token_transfers.length > 0 ? (
|
|
<StatusBadge status={`${transaction.token_transfers.length} token transfer${transaction.token_transfers.length === 1 ? '' : 's'}`} />
|
|
) : null}
|
|
</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>
|
|
)
|
|
}
|