feat: explorer API, wallet, CCIP scripts, and config refresh
- Backend REST/gateway/track routes, analytics, Blockscout proxy paths. - Frontend wallet and liquidity surfaces; MetaMask token list alignment. - Deployment docs, verification scripts, address inventory updates. Check: go build ./... under backend/ (pass). Made-with: Cursor
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import './globals.css'
|
||||
import type { ReactNode } from 'react'
|
||||
import ExplorerChrome from '@/components/common/ExplorerChrome'
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<ExplorerChrome>{children}</ExplorerChrome>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,261 +1,5 @@
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives/Card'
|
||||
|
||||
const publicApiBase = '/token-aggregation/api/v1'
|
||||
|
||||
const livePools = [
|
||||
{
|
||||
pair: 'cUSDT / cUSDC',
|
||||
poolAddress: '0xff8d3b8fDF7B112759F076B69f4271D4209C0849',
|
||||
reserves: '10,000,000 / 10,000,000',
|
||||
},
|
||||
{
|
||||
pair: 'cUSDT / USDT',
|
||||
poolAddress: '0x6fc60DEDc92a2047062294488539992710b99D71',
|
||||
reserves: '10,000,000 / 10,000,000',
|
||||
},
|
||||
{
|
||||
pair: 'cUSDC / USDC',
|
||||
poolAddress: '0x0309178ae30302D83c76d6Dd402a684eF3160eec',
|
||||
reserves: '10,000,000 / 10,000,000',
|
||||
},
|
||||
{
|
||||
pair: 'cUSDT / cXAUC',
|
||||
poolAddress: '0x1AA55E2001E5651349AfF5A63FD7A7Ae44f0F1b0',
|
||||
reserves: '2,666,965 / 519.477000',
|
||||
},
|
||||
{
|
||||
pair: 'cUSDC / cXAUC',
|
||||
poolAddress: '0xEA9Ac6357CaCB42a83b9082B870610363B177cBa',
|
||||
reserves: '1,000,000 / 194.782554',
|
||||
},
|
||||
{
|
||||
pair: 'cEURT / cXAUC',
|
||||
poolAddress: '0xbA99bc1eAAC164569d5AcA96C806934DDaF970Cf',
|
||||
reserves: '1,000,000 / 225.577676',
|
||||
},
|
||||
]
|
||||
|
||||
const publicEndpoints = [
|
||||
{
|
||||
name: 'Canonical route matrix',
|
||||
method: 'GET',
|
||||
href: `${publicApiBase}/routes/matrix`,
|
||||
notes: 'All live and optional non-live route inventory with counts and filters.',
|
||||
},
|
||||
{
|
||||
name: 'Live ingestion export',
|
||||
method: 'GET',
|
||||
href: `${publicApiBase}/routes/ingestion?family=LiFi`,
|
||||
notes: 'Flat live-route export for adapter ingestion and route discovery.',
|
||||
},
|
||||
{
|
||||
name: 'Partner payload templates',
|
||||
method: 'GET',
|
||||
href: `${publicApiBase}/routes/partner-payloads?partner=0x&amount=1000000&includeUnsupported=true`,
|
||||
notes: 'Builds exact 1inch, 0x, and LiFi request templates from live routes.',
|
||||
},
|
||||
{
|
||||
name: 'Resolve supported partner payloads',
|
||||
method: 'POST',
|
||||
href: `${publicApiBase}/routes/partner-payloads/resolve`,
|
||||
notes: 'Accepts partner, amount, and addresses and returns supported payloads by default.',
|
||||
},
|
||||
{
|
||||
name: 'Dispatch supported partner payload',
|
||||
method: 'POST',
|
||||
href: `${publicApiBase}/routes/partner-payloads/dispatch`,
|
||||
notes: 'Resolves then dispatches a single supported partner payload when the chain is supported.',
|
||||
},
|
||||
{
|
||||
name: 'Internal Chain 138 execution plan',
|
||||
method: 'POST',
|
||||
href: `${publicApiBase}/routes/internal-execution-plan`,
|
||||
notes: 'Returns the internal DODO PMM fallback plan when external partner support is unavailable.',
|
||||
},
|
||||
]
|
||||
|
||||
const routeHighlights = [
|
||||
'Direct live routes: cUSDT <-> cUSDC, cUSDT <-> USDT, cUSDC <-> USDC, cUSDT <-> cXAUC, cUSDC <-> cXAUC, cEURT <-> cXAUC.',
|
||||
'Multi-hop public routes exist through cXAUC for cEURT <-> cUSDT, cEURT <-> cUSDC, and an alternate cUSDT <-> cUSDC path.',
|
||||
'Mainnet bridge discovery is live for cUSDT -> USDT and cUSDC -> USDC through the configured UniversalCCIPBridge lane.',
|
||||
'External partner templates are available for 1inch, 0x, and LiFi, but Chain 138 remains unsupported on those public partner networks today.',
|
||||
'When partner support is unavailable, the explorer can surface the internal DODO PMM execution plan instead of a dead end.',
|
||||
]
|
||||
|
||||
const requestExamples = [
|
||||
{
|
||||
title: 'Inspect the full route matrix',
|
||||
code: `GET ${publicApiBase}/routes/matrix?includeNonLive=true`,
|
||||
},
|
||||
{
|
||||
title: 'Filter live same-chain swap routes on Chain 138',
|
||||
code: `GET ${publicApiBase}/routes/ingestion?fromChainId=138&routeType=swap`,
|
||||
},
|
||||
{
|
||||
title: 'Generate partner templates for review',
|
||||
code: `GET ${publicApiBase}/routes/partner-payloads?partner=LiFi&amount=1000000&includeUnsupported=true`,
|
||||
},
|
||||
{
|
||||
title: 'Resolve a dispatch candidate',
|
||||
code: `POST ${publicApiBase}/routes/partner-payloads/resolve`,
|
||||
},
|
||||
{
|
||||
title: 'Build the internal fallback plan',
|
||||
code: `POST ${publicApiBase}/routes/internal-execution-plan`,
|
||||
},
|
||||
]
|
||||
import LiquidityOperationsPage from '@/components/explorer/LiquidityOperationsPage'
|
||||
|
||||
export default function LiquidityPage() {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8 max-w-4xl">
|
||||
<div className="mb-3 inline-flex rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">
|
||||
Chain 138 Liquidity Access
|
||||
</div>
|
||||
<h1 className="mb-3 text-4xl font-bold text-gray-900 dark:text-white">
|
||||
Public liquidity, route discovery, and execution access points
|
||||
</h1>
|
||||
<p className="text-lg leading-8 text-gray-600 dark:text-gray-400">
|
||||
This explorer page pulls together the live public DODO PMM liquidity on Chain 138 and the
|
||||
token-aggregation endpoints that DEX aggregators, integrators, and operators can use for
|
||||
route discovery, payload generation, and internal fallback execution planning.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Live public pools</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">6</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Verified public DODO PMM pools on Chain 138.
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Public access path</div>
|
||||
<div className="mt-2 text-lg font-bold text-gray-900 dark:text-white">/token-aggregation/api/v1</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Explorer-hosted proxy path for route, quote, and reporting APIs.
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Partner status</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">Fallback Ready</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Mainnet stable bridge routing is live; 1inch, 0x, and LiFi templates remain available for partner integrations, with internal fallback for unsupported Chain 138 execution.
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<Card title="Live Pool Snapshot">
|
||||
<div className="space-y-4">
|
||||
{livePools.map((pool) => (
|
||||
<div
|
||||
key={pool.poolAddress}
|
||||
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-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{pool.pair}</div>
|
||||
<div className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">
|
||||
Pool: {pool.poolAddress}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reserves: {pool.reserves}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="What Integrators Need To Know">
|
||||
<div className="space-y-3 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{routeHighlights.map((item) => (
|
||||
<p key={item}>{item}</p>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Explorer Access Points">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{publicEndpoints.map((endpoint) => (
|
||||
<a
|
||||
key={endpoint.href}
|
||||
href={endpoint.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 transition hover:border-primary-400 hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{endpoint.name}</div>
|
||||
<span className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
{endpoint.method}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 break-all rounded-xl bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||||
{endpoint.href}
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-400">{endpoint.notes}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Quick Request Examples">
|
||||
<div className="space-y-4">
|
||||
{requestExamples.map((example) => (
|
||||
<div key={example.title} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{example.title}</div>
|
||||
<code className="block break-all text-xs leading-6 text-gray-700 dark:text-gray-300">
|
||||
{example.code}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Related Explorer Tools">
|
||||
<div className="space-y-4 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
Use the wallet page for network onboarding and the explorer token list URL, then use this
|
||||
page for route and execution discovery.
|
||||
</p>
|
||||
<p>
|
||||
The route APIs complement the existing route decision tree and market-data APIs already
|
||||
proxied through the explorer.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="rounded-full bg-primary-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-primary-700"
|
||||
>
|
||||
Open wallet tools
|
||||
</Link>
|
||||
<a
|
||||
href={`${publicApiBase}/routes/tree?chainId=138&amountIn=1000000`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Route tree API
|
||||
</a>
|
||||
<a
|
||||
href="/docs.html"
|
||||
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Explorer docs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
return <LiquidityOperationsPage />
|
||||
}
|
||||
|
||||
@@ -3,97 +3,180 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives/Card'
|
||||
import Link from 'next/link'
|
||||
import { blocksApi } from '@/services/api/blocks'
|
||||
import { blocksApi, type Block } from '@/services/api/blocks'
|
||||
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
import {
|
||||
missionControlApi,
|
||||
type MissionControlRelaySummary,
|
||||
} from '@/services/api/missionControl'
|
||||
import { loadDashboardData } from '@/utils/dashboard'
|
||||
|
||||
interface NetworkStats {
|
||||
current_block: number
|
||||
tps: number
|
||||
gps: number
|
||||
avg_gas_price: number
|
||||
pending_transactions: number
|
||||
}
|
||||
type HomeStats = ExplorerStats
|
||||
|
||||
export default function Home() {
|
||||
const [stats, setStats] = useState<NetworkStats | null>(null)
|
||||
const [recentBlocks, setRecentBlocks] = useState<any[]>([])
|
||||
const [stats, setStats] = useState<HomeStats | null>(null)
|
||||
const [recentBlocks, setRecentBlocks] = useState<Block[]>([])
|
||||
const [relaySummary, setRelaySummary] = useState<MissionControlRelaySummary | null>(null)
|
||||
const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>('connecting')
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const latestBlock = stats?.latest_block ?? recentBlocks[0]?.number ?? null
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
// This would call analytics API
|
||||
// For now, placeholder
|
||||
setStats({
|
||||
current_block: 0,
|
||||
tps: 0,
|
||||
gps: 0,
|
||||
avg_gas_price: 0,
|
||||
pending_transactions: 0,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error)
|
||||
}
|
||||
}, [])
|
||||
const loadDashboard = useCallback(async () => {
|
||||
const dashboardData = await loadDashboardData({
|
||||
loadStats: () => statsApi.get(),
|
||||
loadRecentBlocks: async () => {
|
||||
const response = await blocksApi.list({
|
||||
chain_id: chainId,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
onError: (scope, error) => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(`Failed to load dashboard ${scope}:`, error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const loadRecentBlocks = useCallback(async () => {
|
||||
try {
|
||||
const response = await blocksApi.list({
|
||||
chain_id: chainId,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
})
|
||||
setRecentBlocks(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent blocks:', error)
|
||||
}
|
||||
setStats(dashboardData.stats)
|
||||
setRecentBlocks(dashboardData.recentBlocks)
|
||||
}, [chainId])
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
loadRecentBlocks()
|
||||
}, [loadStats, loadRecentBlocks])
|
||||
loadDashboard()
|
||||
}, [loadDashboard])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadSnapshot = async () => {
|
||||
try {
|
||||
const summary = await missionControlApi.getRelaySummary()
|
||||
if (!cancelled) {
|
||||
setRelaySummary(summary)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to load mission control relay summary:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSnapshot()
|
||||
|
||||
const unsubscribe = missionControlApi.subscribeRelaySummary(
|
||||
(summary) => {
|
||||
if (!cancelled) {
|
||||
setRelaySummary(summary)
|
||||
setRelayFeedState('live')
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
if (!cancelled) {
|
||||
setRelayFeedState('fallback')
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Mission control live stream update issue:', error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const relayToneClasses =
|
||||
relaySummary?.tone === 'danger'
|
||||
? 'border-red-200 bg-red-50 text-red-900 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100'
|
||||
: relaySummary?.tone === 'warning'
|
||||
? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100'
|
||||
: 'border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-100'
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-2">SolaceScanScout</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">The Defi Oracle Meta Explorer</p>
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="mb-2 text-3xl font-bold sm:text-4xl">SolaceScanScout</h1>
|
||||
<p className="text-base text-gray-600 dark:text-gray-400 sm:text-lg">The Defi Oracle Meta Explorer</p>
|
||||
</div>
|
||||
|
||||
{relaySummary && (
|
||||
<Card className={`mb-6 border shadow-sm ${relayToneClasses}`}>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold uppercase tracking-wide opacity-80">Mission Control</div>
|
||||
<div className="mt-1 text-sm sm:text-base">{relaySummary.text}</div>
|
||||
<div className="mt-2 text-xs font-medium uppercase tracking-wide opacity-75">
|
||||
Feed: {relayFeedState === 'live' ? 'Live SSE' : relayFeedState === 'fallback' ? 'Snapshot fallback' : 'Connecting'}
|
||||
</div>
|
||||
{relaySummary.items.length > 1 && (
|
||||
<div className="mt-3 space-y-1 text-sm opacity-90">
|
||||
{relaySummary.items.map((item) => (
|
||||
<div key={item.key}>{item.text}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link href="/explorer-api/v1/mission-control/stream" className="text-sm font-semibold underline-offset-4 hover:underline">
|
||||
Open live stream
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="mb-8 grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Current Block</div>
|
||||
<div className="text-2xl font-bold">{stats.current_block.toLocaleString()}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Latest Block</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">
|
||||
{latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">TPS</div>
|
||||
<div className="text-2xl font-bold">{stats.tps.toFixed(2)}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Total Blocks</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{stats.total_blocks.toLocaleString()}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Gas Price</div>
|
||||
<div className="text-2xl font-bold">{stats.avg_gas_price.toLocaleString()} Gwei</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Total Transactions</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{stats.total_transactions.toLocaleString()}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Pending Tx</div>
|
||||
<div className="text-2xl font-bold">{stats.pending_transactions}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Total Addresses</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{stats.total_addresses.toLocaleString()}</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!stats && (
|
||||
<Card className="mb-8">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Live network stats are temporarily unavailable. Recent blocks and explorer tools are still available below.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Recent Blocks">
|
||||
<div className="space-y-2">
|
||||
{recentBlocks.map((block) => (
|
||||
<div key={block.number} className="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<Link href={`/blocks/${block.number}`} className="text-primary-600 hover:underline">
|
||||
Block #{block.number}
|
||||
</Link>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{block.transaction_count} transactions
|
||||
{recentBlocks.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Recent blocks are unavailable right now.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentBlocks.map((block) => (
|
||||
<div key={block.number} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Link href={`/blocks/${block.number}`} className="text-primary-600 hover:underline">
|
||||
Block #{block.number}
|
||||
</Link>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
|
||||
{block.transaction_count} transactions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Link href="/blocks" className="text-primary-600 hover:underline">
|
||||
View all blocks →
|
||||
</Link>
|
||||
@@ -107,8 +190,8 @@ export default function Home() {
|
||||
partner payload endpoints exposed through the explorer.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/liquidity" className="text-primary-600 hover:underline">
|
||||
Open liquidity access →
|
||||
<Link href="/routes" className="text-primary-600 hover:underline">
|
||||
Open routes and liquidity →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -123,6 +206,28 @@ export default function Home() {
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Bridge & Relay Monitoring">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Open the public bridge monitoring surface for relay status, mission-control links, bridge trace tooling,
|
||||
and the visual command center entry points.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/bridge" className="text-primary-600 hover:underline">
|
||||
Open bridge monitoring →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="More Explorer Tools">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Surface the restored WETH utilities, analytics shortcuts, operator links, system topology views, and
|
||||
other public tools that were previously hidden in the legacy explorer shell.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/more" className="text-primary-600 hover:underline">
|
||||
Open operations hub →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -3,9 +3,9 @@ import Link from 'next/link'
|
||||
|
||||
export default function WalletPage() {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Wallet & MetaMask</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-4 text-2xl font-bold sm:text-3xl">Wallet & MetaMask</h1>
|
||||
<p className="mb-6 text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base">
|
||||
Connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets. Use the token list URL so tokens and oracles are discoverable.
|
||||
</p>
|
||||
<AddToMetaMask />
|
||||
|
||||
@@ -1,48 +1 @@
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface AddressProps {
|
||||
address: string
|
||||
chainId?: number
|
||||
showCopy?: boolean
|
||||
showENS?: boolean
|
||||
truncate?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Address({
|
||||
address,
|
||||
chainId,
|
||||
showCopy = true,
|
||||
showENS = false,
|
||||
truncate = false,
|
||||
className,
|
||||
}: AddressProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const displayAddress = truncate
|
||||
? `${address.slice(0, 6)}...${address.slice(-4)}`
|
||||
: address
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(address)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('flex items-center gap-2', className)}>
|
||||
<span className="font-mono text-sm">{displayAddress}</span>
|
||||
{showCopy && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
title="Copy address"
|
||||
>
|
||||
{copied ? '✓' : '📋'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Address } from '@/libs/frontend-ui-primitives/Address'
|
||||
|
||||
@@ -1,37 +1 @@
|
||||
import { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
'font-medium rounded-lg transition-colors',
|
||||
{
|
||||
'bg-primary-600 text-white hover:bg-primary-700': variant === 'primary',
|
||||
'bg-gray-200 text-gray-900 hover:bg-gray-300': variant === 'secondary',
|
||||
'bg-red-600 text-white hover:bg-red-700': variant === 'danger',
|
||||
'px-3 py-1.5 text-sm': size === 'sm',
|
||||
'px-4 py-2 text-base': size === 'md',
|
||||
'px-6 py-3 text-lg': size === 'lg',
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button } from '@/libs/frontend-ui-primitives/Button'
|
||||
|
||||
@@ -1,27 +1 @@
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function Card({ children, className, title }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-white dark:bg-gray-800 rounded-lg shadow-md p-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card } from '@/libs/frontend-ui-primitives/Card'
|
||||
|
||||
34
frontend/src/components/common/DetailRow.tsx
Normal file
34
frontend/src/components/common/DetailRow.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
valueClassName?: string
|
||||
}
|
||||
|
||||
export function DetailRow({
|
||||
label,
|
||||
children,
|
||||
className,
|
||||
labelClassName,
|
||||
valueClassName,
|
||||
}: DetailRowProps) {
|
||||
return (
|
||||
<div className={clsx('flex flex-col gap-1.5 sm:flex-row sm:items-start sm:gap-4', className)}>
|
||||
<dt
|
||||
className={clsx(
|
||||
'text-sm font-semibold text-gray-700 dark:text-gray-300 sm:w-36 sm:shrink-0',
|
||||
labelClassName,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</dt>
|
||||
<dd className={clsx('min-w-0 text-sm text-gray-900 dark:text-gray-100', valueClassName)}>
|
||||
{children}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
frontend/src/components/common/ExplorerChrome.tsx
Normal file
13
frontend/src/components/common/ExplorerChrome.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Navbar from './Navbar'
|
||||
import Footer from './Footer'
|
||||
|
||||
export default function ExplorerChrome({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<Navbar />
|
||||
<div className="flex-1">{children}</div>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,10 +8,10 @@ export default function Footer() {
|
||||
|
||||
return (
|
||||
<footer className="mt-auto border-t border-gray-200 dark:border-gray-700 bg-white/90 dark:bg-gray-900/90 backdrop-blur">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid gap-6 md:grid-cols-[1.5fr_1fr_1fr]">
|
||||
<div className="space-y-3">
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="grid gap-4 sm:gap-6 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white sm:text-lg">
|
||||
SolaceScanScout
|
||||
</div>
|
||||
<p className="max-w-xl text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
@@ -24,13 +24,16 @@ export default function Footer() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
|
||||
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Resources
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><a className={footerLinkClass} href="/docs.html">Documentation</a></li>
|
||||
<li><Link className={footerLinkClass} href="/bridge">Bridge Monitoring</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/liquidity">Liquidity Access</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/more">More Tools</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/addresses">Addresses</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/watchlist">Watchlist</Link></li>
|
||||
<li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li>
|
||||
@@ -39,7 +42,7 @@ export default function Footer() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
|
||||
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Contact
|
||||
</div>
|
||||
@@ -56,6 +59,12 @@ export default function Footer() {
|
||||
explorer.d-bis.org/snap/
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Command center:{' '}
|
||||
<a className={footerLinkClass} href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer">
|
||||
Chain 138 visual map
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-gray-500 dark:text-gray-500">
|
||||
Questions about the explorer, chain metadata, route discovery, or liquidity access
|
||||
can be sent to the support mailbox above.
|
||||
|
||||
@@ -86,26 +86,42 @@ export default function Navbar() {
|
||||
const [exploreOpen, setExploreOpen] = useState(false)
|
||||
const [toolsOpen, setToolsOpen] = useState(false)
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setMobileMenuOpen((open) => {
|
||||
const nextOpen = !open
|
||||
if (!nextOpen) {
|
||||
setExploreOpen(false)
|
||||
setToolsOpen(false)
|
||||
}
|
||||
return nextOpen
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-4 md:gap-8">
|
||||
<div className="flex h-14 items-center justify-between sm:h-16">
|
||||
<div className="flex min-w-0 items-center gap-3 md:gap-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="group inline-flex flex-col rounded-xl px-3 py-2 text-xl font-bold text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-gray-700/70"
|
||||
className="group inline-flex max-w-[calc(100vw-5rem)] flex-col rounded-xl px-2 py-1.5 text-base font-bold text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-gray-700/70 sm:max-w-none sm:px-3 sm:py-2 sm:text-xl"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label="Go to explorer home"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600 text-white shadow-sm transition-transform group-hover:-translate-y-0.5 dark:bg-primary-500">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary-600 text-white shadow-sm transition-transform group-hover:-translate-y-0.5 dark:bg-primary-500 sm:h-8 sm:w-8">
|
||||
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M12 2.5 3.5 6.5v11l8.5 4 8.5-4v-11L12 2.5Zm0 2.24 6.44 3.03L12 10.8 5.56 7.77 12 4.74Zm-7 4.63L11 13.1v6.07L5 16.4V9.37Zm9 9.8v-6.07l6-2.92v6.03l-6 2.96Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>SolaceScanScout</span>
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="sm:hidden">SolaceScan</span>
|
||||
<span className="hidden sm:inline">SolaceScanScout</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 hidden text-xs font-normal text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200 sm:block">
|
||||
The Defi Oracle Meta Explorer
|
||||
</span>
|
||||
<span className="mt-0.5 text-xs font-normal text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200">The Defi Oracle Meta Explorer</span>
|
||||
</Link>
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
<NavDropdown
|
||||
@@ -117,6 +133,12 @@ export default function Navbar() {
|
||||
<DropdownItem href="/transactions" icon={<span className="text-gray-400">⇄</span>}>Transactions</DropdownItem>
|
||||
<DropdownItem href="/addresses" icon={<span className="text-gray-400">⌗</span>}>Addresses</DropdownItem>
|
||||
</NavDropdown>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className={`hidden md:inline-flex items-center px-3 py-2 rounded-md ${pathname.startsWith('/wallet') ? navLinkActive : navLink}`}
|
||||
>
|
||||
Wallet
|
||||
</Link>
|
||||
<NavDropdown
|
||||
label="Tools"
|
||||
icon={<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>}
|
||||
@@ -127,6 +149,10 @@ export default function Navbar() {
|
||||
<DropdownItem href="/watchlist">Watchlist</DropdownItem>
|
||||
<DropdownItem href="/wallet">Wallet</DropdownItem>
|
||||
<DropdownItem href="/liquidity">Liquidity</DropdownItem>
|
||||
<DropdownItem href="/bridge">Bridge</DropdownItem>
|
||||
<DropdownItem href="/routes">Routes</DropdownItem>
|
||||
<DropdownItem href="/more">More</DropdownItem>
|
||||
<DropdownItem href="/chain138-command-center.html" external>Command Center</DropdownItem>
|
||||
</NavDropdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,7 +160,7 @@ export default function Navbar() {
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
onClick={() => setMobileMenuOpen((o) => !o)}
|
||||
onClick={toggleMobileMenu}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
@@ -147,7 +173,7 @@ export default function Navbar() {
|
||||
</div>
|
||||
</div>
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden py-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="border-t border-gray-200 py-2 pb-3 dark:border-gray-700 md:hidden">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link href="/" className={`px-3 py-2.5 rounded-md ${pathname === '/' ? navLinkActive : navLink}`} onClick={() => setMobileMenuOpen(false)}>Home</Link>
|
||||
<div className="relative">
|
||||
@@ -176,6 +202,10 @@ export default function Navbar() {
|
||||
<li><Link href="/watchlist" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Watchlist</Link></li>
|
||||
<li><Link href="/wallet" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Wallet</Link></li>
|
||||
<li><Link href="/liquidity" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Liquidity</Link></li>
|
||||
<li><Link href="/bridge" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Bridge</Link></li>
|
||||
<li><Link href="/routes" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Routes</Link></li>
|
||||
<li><Link href="/more" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>More</Link></li>
|
||||
<li><a href="/chain138-command-center.html" className={`block px-3 py-2 rounded-md ${navLink}`} target="_blank" rel="noopener noreferrer" onClick={() => setMobileMenuOpen(false)}>Command Center</a></li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,58 +1 @@
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface Column<T> {
|
||||
header: string
|
||||
accessor: (row: T) => ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface TableProps<T> {
|
||||
columns: Column<T>[]
|
||||
data: T[]
|
||||
className?: string
|
||||
/** Stable key for each row (e.g. row => row.id or row => row.hash). Falls back to index if not provided. */
|
||||
keyExtractor?: (row: T) => string | number
|
||||
}
|
||||
|
||||
export function Table<T>({ columns, data, className, keyExtractor }: TableProps<T>) {
|
||||
return (
|
||||
<div className={clsx('overflow-x-auto', className)}>
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={index}
|
||||
className={clsx(
|
||||
'px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider',
|
||||
column.className
|
||||
)}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{data.map((row, rowIndex) => (
|
||||
<tr key={keyExtractor ? keyExtractor(row) : rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
className={clsx(
|
||||
'px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100',
|
||||
column.className
|
||||
)}
|
||||
>
|
||||
{column.accessor(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Table } from '@/libs/frontend-ui-primitives/Table'
|
||||
|
||||
177
frontend/src/components/explorer/AnalyticsOperationsPage.tsx
Normal file
177
frontend/src/components/explorer/AnalyticsOperationsPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
326
frontend/src/components/explorer/BridgeMonitoringPage.tsx
Normal file
326
frontend/src/components/explorer/BridgeMonitoringPage.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import {
|
||||
getMissionControlRelayLabel,
|
||||
getMissionControlRelays,
|
||||
missionControlApi,
|
||||
type MissionControlBridgeStatusResponse,
|
||||
type MissionControlRelayPayload,
|
||||
type MissionControlRelaySnapshot,
|
||||
} from '@/services/api/missionControl'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
|
||||
type FeedState = 'connecting' | 'live' | 'fallback'
|
||||
|
||||
interface RelayLaneCard {
|
||||
key: string
|
||||
label: string
|
||||
status: string
|
||||
profile: string
|
||||
sourceChain: string
|
||||
destinationChain: string
|
||||
queueSize: number
|
||||
processed: number
|
||||
failed: number
|
||||
lastPolled: string
|
||||
bridgeAddress: string
|
||||
}
|
||||
|
||||
const relayOrder = ['mainnet_cw', 'mainnet_weth', 'bsc', 'avax', 'avax_cw', 'avax_to_138']
|
||||
|
||||
function relativeAge(isoString?: string): string {
|
||||
if (!isoString) return 'Unknown'
|
||||
const parsed = Date.parse(isoString)
|
||||
if (!Number.isFinite(parsed)) return 'Unknown'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
function shortAddress(value?: string): string {
|
||||
if (!value) return 'Unspecified'
|
||||
if (value.length <= 14) return value
|
||||
return `${value.slice(0, 6)}...${value.slice(-4)}`
|
||||
}
|
||||
|
||||
function resolveSnapshot(relay?: MissionControlRelayPayload): MissionControlRelaySnapshot | null {
|
||||
return relay?.url_probe?.body || relay?.file_snapshot || null
|
||||
}
|
||||
|
||||
function laneToneClasses(status: string): string {
|
||||
const normalized = status.toLowerCase()
|
||||
if (['degraded', 'stale', 'stopped', 'down', 'snapshot-error'].includes(normalized)) {
|
||||
return 'border-red-200 bg-red-50/80 dark:border-red-900/60 dark:bg-red-950/20'
|
||||
}
|
||||
if (['paused', 'starting'].includes(normalized)) {
|
||||
return 'border-amber-200 bg-amber-50/80 dark:border-amber-900/60 dark:bg-amber-950/20'
|
||||
}
|
||||
return 'border-emerald-200 bg-emerald-50/80 dark:border-emerald-900/60 dark:bg-emerald-950/20'
|
||||
}
|
||||
|
||||
function statusPillClasses(status: string): string {
|
||||
const normalized = status.toLowerCase()
|
||||
if (['degraded', 'stale', 'stopped', 'down', 'snapshot-error'].includes(normalized)) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-100'
|
||||
}
|
||||
if (['paused', 'starting'].includes(normalized)) {
|
||||
return 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-100'
|
||||
}
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-100'
|
||||
}
|
||||
|
||||
function ActionLink({
|
||||
href,
|
||||
label,
|
||||
external,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
external?: boolean
|
||||
}) {
|
||||
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
|
||||
const text = `${label} ->`
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BridgeMonitoringPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [feedState, setFeedState] = useState<FeedState>('connecting')
|
||||
const page = explorerFeaturePages.bridge
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadSnapshot = async () => {
|
||||
try {
|
||||
const snapshot = await missionControlApi.getBridgeStatus()
|
||||
if (!cancelled) {
|
||||
setBridgeStatus(snapshot)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to load bridge monitoring snapshot:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSnapshot()
|
||||
|
||||
const unsubscribe = missionControlApi.subscribeBridgeStatus(
|
||||
(status) => {
|
||||
if (!cancelled) {
|
||||
setBridgeStatus(status)
|
||||
setFeedState('live')
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
if (!cancelled) {
|
||||
setFeedState('fallback')
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Bridge monitoring live stream issue:', error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const relayLanes = useMemo((): RelayLaneCard[] => {
|
||||
const relays = getMissionControlRelays(bridgeStatus)
|
||||
if (!relays) return []
|
||||
|
||||
const orderIndex = new Map(relayOrder.map((key, index) => [key, index]))
|
||||
|
||||
return Object.entries(relays)
|
||||
.map(([key, relay]) => {
|
||||
const snapshot = resolveSnapshot(relay)
|
||||
const status = String(snapshot?.status || (relay.file_snapshot_error ? 'snapshot-error' : 'configured')).toLowerCase()
|
||||
return {
|
||||
key,
|
||||
label: getMissionControlRelayLabel(key),
|
||||
status,
|
||||
profile: snapshot?.service?.profile || key,
|
||||
sourceChain: snapshot?.source?.chain_name || 'Unknown',
|
||||
destinationChain: snapshot?.destination?.chain_name || 'Unknown',
|
||||
queueSize: snapshot?.queue?.size ?? 0,
|
||||
processed: snapshot?.queue?.processed ?? 0,
|
||||
failed: snapshot?.queue?.failed ?? 0,
|
||||
lastPolled: relativeAge(snapshot?.last_source_poll?.at),
|
||||
bridgeAddress:
|
||||
snapshot?.destination?.relay_bridge_default ||
|
||||
snapshot?.destination?.relay_bridge ||
|
||||
snapshot?.source?.bridge_filter ||
|
||||
'',
|
||||
}
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftIndex = orderIndex.get(left.key) ?? Number.MAX_SAFE_INTEGER
|
||||
const rightIndex = orderIndex.get(right.key) ?? Number.MAX_SAFE_INTEGER
|
||||
return leftIndex - rightIndex || left.label.localeCompare(right.label)
|
||||
})
|
||||
}, [bridgeStatus])
|
||||
|
||||
const chainStatus = bridgeStatus?.data?.chains?.['138']
|
||||
const overallStatus = bridgeStatus?.data?.status || 'unknown'
|
||||
const checkedAt = relativeAge(bridgeStatus?.data?.checked_at)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 max-w-4xl sm:mb-8">
|
||||
<div className="mb-3 inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
{page.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{page.note ? (
|
||||
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
|
||||
{page.note}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 grid gap-4 lg:grid-cols-3">
|
||||
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
|
||||
Relay Fleet
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{overallStatus}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{relayLanes.length} managed lanes visible
|
||||
</div>
|
||||
<div className="mt-2 text-xs font-medium uppercase tracking-wide text-sky-800/80 dark:text-sky-100/80">
|
||||
Feed: {feedState === 'live' ? 'Live SSE' : feedState === 'fallback' ? 'Snapshot fallback' : 'Connecting'}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
Chain 138 RPC
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{chainStatus?.status || 'unknown'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Head age: {chainStatus?.head_age_sec != null ? `${chainStatus.head_age_sec.toFixed(1)}s` : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Latency: {chainStatus?.latency_ms != null ? `${chainStatus.latency_ms}ms` : 'Unknown'}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
Last Check
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{checkedAt}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Public status JSON and live stream are both active.
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ActionLink href="/explorer-api/v1/track1/bridge/status" label="Open status JSON" external />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{relayLanes.map((lane) => (
|
||||
<Card key={lane.key} className={`border ${laneToneClasses(lane.status)}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{lane.label}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{`${lane.sourceChain} -> ${lane.destinationChain}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide ${statusPillClasses(lane.status)}`}>
|
||||
{lane.status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Profile</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{lane.profile}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Queue</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{lane.queueSize}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Processed</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{lane.processed}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Failed</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{lane.failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Last polled: {lane.lastPolled}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Bridge: {shortAddress(lane.bridgeAddress)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{action.title}
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink
|
||||
href={action.href}
|
||||
label={action.label}
|
||||
external={'external' in action ? action.external : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
frontend/src/components/explorer/FeatureLandingPage.tsx
Normal file
66
frontend/src/components/explorer/FeatureLandingPage.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import type { ExplorerFeatureAction, ExplorerFeaturePage } from '@/data/explorerOperations'
|
||||
|
||||
function ActionLink({ action }: { action: ExplorerFeatureAction }) {
|
||||
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
|
||||
const label = `${action.label} ->`
|
||||
|
||||
if (action.external) {
|
||||
return (
|
||||
<a href={action.href} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={action.href} className={className}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FeatureLandingPage({ page }: { page: ExplorerFeaturePage }) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 max-w-4xl sm:mb-8">
|
||||
<div className="mb-3 inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
{page.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{page.note ? (
|
||||
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
|
||||
{page.note}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{action.title}
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink action={action} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
419
frontend/src/components/explorer/LiquidityOperationsPage.tsx
Normal file
419
frontend/src/components/explorer/LiquidityOperationsPage.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { configApi, type TokenListResponse } from '@/services/api/config'
|
||||
import {
|
||||
aggregateLiquidityPools,
|
||||
featuredLiquiditySymbols,
|
||||
getLivePlannerProviders,
|
||||
getRouteBackedPoolAddresses,
|
||||
getTopLiquidityRoutes,
|
||||
selectFeaturedLiquidityTokens,
|
||||
type AggregatedLiquidityPool,
|
||||
} from '@/services/api/liquidity'
|
||||
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
|
||||
import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import {
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
const tokenAggregationV1Base = '/token-aggregation/api/v1'
|
||||
const tokenAggregationV2Base = '/token-aggregation/api/v2'
|
||||
|
||||
interface TokenPoolRecord {
|
||||
symbol: string
|
||||
pools: MissionControlLiquidityPool[]
|
||||
}
|
||||
|
||||
function routePairLabel(routeId: string, routeLabel: string, tokenIn?: string, tokenOut?: string): string {
|
||||
return [tokenIn, tokenOut].filter(Boolean).join(' / ') || routeLabel || routeId
|
||||
}
|
||||
|
||||
export default function LiquidityOperationsPage() {
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
|
||||
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>([])
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult] =
|
||||
await Promise.allSettled([
|
||||
configApi.getTokenList(),
|
||||
routesApi.getRouteMatrix(),
|
||||
plannerApi.getCapabilities(),
|
||||
plannerApi.getInternalExecutionPlan(),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value)
|
||||
if (routeMatrixResult.status === 'fulfilled') setRouteMatrix(routeMatrixResult.value)
|
||||
if (plannerCapabilitiesResult.status === 'fulfilled') setPlannerCapabilities(plannerCapabilitiesResult.value)
|
||||
if (planResult.status === 'fulfilled') setInternalPlan(planResult.value)
|
||||
|
||||
if (tokenListResult.status === 'fulfilled') {
|
||||
const featuredTokens = selectFeaturedLiquidityTokens(tokenListResult.value.tokens || [])
|
||||
const poolResults = await Promise.allSettled(
|
||||
featuredTokens.map(async (token) => ({
|
||||
symbol: token.symbol,
|
||||
pools: (await routesApi.getTokenPools(token.address)).pools || [],
|
||||
}))
|
||||
)
|
||||
|
||||
if (!cancelled) {
|
||||
setTokenPoolRecords(
|
||||
poolResults
|
||||
.filter((result): result is PromiseFulfilledResult<TokenPoolRecord> => result.status === 'fulfilled')
|
||||
.map((result) => result.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const failedCount = [
|
||||
tokenListResult,
|
||||
routeMatrixResult,
|
||||
plannerCapabilitiesResult,
|
||||
planResult,
|
||||
].filter((result) => result.status === 'rejected').length
|
||||
|
||||
if (failedCount === 4) {
|
||||
setLoadingError('Live liquidity data is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(
|
||||
error instanceof Error ? error.message : 'Live liquidity data is temporarily unavailable from the public explorer APIs.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const featuredTokens = useMemo(
|
||||
() => selectFeaturedLiquidityTokens(tokenList?.tokens || []),
|
||||
[tokenList?.tokens]
|
||||
)
|
||||
const aggregatedPools = useMemo(
|
||||
() => aggregateLiquidityPools(tokenPoolRecords),
|
||||
[tokenPoolRecords]
|
||||
)
|
||||
const livePlannerProviders = useMemo(
|
||||
() => getLivePlannerProviders(plannerCapabilities),
|
||||
[plannerCapabilities]
|
||||
)
|
||||
const routeBackedPoolAddresses = useMemo(
|
||||
() => getRouteBackedPoolAddresses(routeMatrix),
|
||||
[routeMatrix]
|
||||
)
|
||||
const highlightedRoutes = useMemo(
|
||||
() => getTopLiquidityRoutes(routeMatrix, 6),
|
||||
[routeMatrix]
|
||||
)
|
||||
const dexCount = useMemo(
|
||||
() => new Set(aggregatedPools.map((pool) => pool.dex).filter(Boolean)).size,
|
||||
[aggregatedPools]
|
||||
)
|
||||
|
||||
const insightLines = useMemo(
|
||||
() => [
|
||||
`${formatNumber(routeMatrix?.counts?.liveSwapRoutes)} live swap routes and ${formatNumber(routeMatrix?.counts?.liveBridgeRoutes)} bridge routes are currently published in the public route matrix.`,
|
||||
`${formatNumber(aggregatedPools.length)} unique pools were discovered across ${formatNumber(featuredTokens.length)} featured Chain 138 liquidity tokens.`,
|
||||
`${formatNumber(livePlannerProviders.length)} planner providers are live, and the current internal fallback decision is ${internalPlan?.plannerResponse?.decision || 'unknown'}.`,
|
||||
`${formatNumber(routeBackedPoolAddresses.length)} unique pool addresses are referenced directly by the current live route legs.`,
|
||||
],
|
||||
[routeMatrix, aggregatedPools.length, featuredTokens.length, livePlannerProviders.length, internalPlan?.plannerResponse?.decision, routeBackedPoolAddresses.length]
|
||||
)
|
||||
|
||||
const endpointCards = [
|
||||
{
|
||||
name: 'Canonical route matrix',
|
||||
method: 'GET',
|
||||
href: `${tokenAggregationV1Base}/routes/matrix?includeNonLive=true`,
|
||||
notes: 'All live and non-live route inventory with counts and pool-backed legs.',
|
||||
},
|
||||
{
|
||||
name: 'Planner capabilities',
|
||||
method: 'GET',
|
||||
href: `${tokenAggregationV2Base}/providers/capabilities?chainId=138`,
|
||||
notes: 'Live provider inventory, published pair coverage, and execution modes.',
|
||||
},
|
||||
{
|
||||
name: 'Internal execution plan',
|
||||
method: 'POST',
|
||||
href: `${tokenAggregationV2Base}/routes/internal-execution-plan`,
|
||||
notes: 'Returns the direct-pool fallback posture that the operator surfaces already verify.',
|
||||
},
|
||||
{
|
||||
name: 'Mission-control token pools',
|
||||
method: 'GET',
|
||||
href: `/explorer-api/v1/mission-control/liquidity/token/${featuredTokens[0]?.address || '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'}/pools`,
|
||||
notes: 'Cached public pool inventory for a specific Chain 138 token.',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-8 max-w-4xl">
|
||||
<div className="mb-3 inline-flex rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">
|
||||
Chain 138 Liquidity Access
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
Public liquidity, route discovery, and execution access points
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
This page now reads the live explorer APIs instead of hardcoded pool snapshots. It pulls the
|
||||
public route matrix, planner capabilities, and mission-control token pool inventory together
|
||||
so integrators can inspect what Chain 138 is actually serving right now.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
|
||||
Live Pools
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(aggregatedPools.length)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Dedupe of mission-control pool inventory across featured Chain 138 tokens.
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
|
||||
Route Coverage
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(routeMatrix?.counts?.filteredLiveRoutes)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{formatNumber(routeMatrix?.counts?.liveSwapRoutes)} swaps and {formatNumber(routeMatrix?.counts?.liveBridgeRoutes)} bridges.
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Planner providers</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(livePlannerProviders.length)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatNumber(dexCount)} DEX families in the current discovered pools.
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Fallback posture</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{internalPlan?.plannerResponse?.decision || 'unknown'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Execution contract {truncateMiddle(internalPlan?.execution?.contractAddress)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<Card title="Live Pool Snapshot">
|
||||
<div className="space-y-4">
|
||||
{aggregatedPools.slice(0, 8).map((pool) => (
|
||||
<div
|
||||
key={pool.address}
|
||||
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-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')}
|
||||
</div>
|
||||
<div className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">
|
||||
Pool: {pool.address}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{pool.dex || 'Unknown DEX'} · TVL {formatCurrency(pool.tvl)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Seen from {pool.sourceSymbols.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{aggregatedPools.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No live pool inventory is available right now.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="What Integrators Need To Know">
|
||||
<div className="space-y-3 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{insightLines.map((item) => (
|
||||
<p key={item}>{item}</p>
|
||||
))}
|
||||
<p>
|
||||
Featured symbols in this view: {featuredLiquiditySymbols.join(', ')}.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Top Route-Backed Liquidity Paths">
|
||||
<div className="space-y-4">
|
||||
{highlightedRoutes.map((route) => (
|
||||
<div
|
||||
key={route.routeId}
|
||||
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-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{routePairLabel(route.routeId, route.label || '', route.tokenInSymbol, route.tokenOutSymbol)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatNumber((route.legs || []).length)} legs · {formatNumber((route.legs || []).filter((leg) => leg.poolAddress).length)} pool-backed
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{route.aggregatorFamilies?.join(', ') || 'No provider families listed'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{(route.legs || [])
|
||||
.map((leg) => leg.poolAddress)
|
||||
.filter(Boolean)
|
||||
.map((address) => truncateMiddle(address))
|
||||
.join(' · ') || 'No pool addresses published'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Featured Token Coverage">
|
||||
<div className="space-y-4">
|
||||
{featuredTokens.map((token) => {
|
||||
const matchingRecord = tokenPoolRecords.find((record) => record.symbol === token.symbol)
|
||||
return (
|
||||
<div
|
||||
key={token.address}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{token.symbol}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{token.name || 'Unnamed token'} · {formatNumber(matchingRecord?.pools.length || 0)} mission-control pools
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{truncateMiddle(token.address, 10, 8)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Explorer Access Points">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{endpointCards.map((endpoint) => (
|
||||
<a
|
||||
key={endpoint.href}
|
||||
href={endpoint.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 transition hover:border-primary-400 hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{endpoint.name}</div>
|
||||
<span className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
{endpoint.method}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 break-all rounded-xl bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||||
{endpoint.href}
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-400">{endpoint.notes}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Quick Request Examples">
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
`GET ${tokenAggregationV1Base}/routes/matrix?includeNonLive=true`,
|
||||
`GET ${tokenAggregationV2Base}/providers/capabilities?chainId=138`,
|
||||
`POST ${tokenAggregationV2Base}/routes/internal-execution-plan`,
|
||||
`GET /explorer-api/v1/mission-control/liquidity/token/${featuredTokens[0]?.address || '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'}/pools`,
|
||||
].map((example) => (
|
||||
<div key={example} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<code className="block break-all text-xs leading-6 text-gray-700 dark:text-gray-300">
|
||||
{example}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Related Explorer Tools">
|
||||
<div className="space-y-4 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
Use the wallet page for network onboarding, the pools page for a tighter live inventory
|
||||
view, and this page for the broader route and execution surfaces.
|
||||
</p>
|
||||
<p>
|
||||
The live route matrix was updated {relativeAge(routeMatrix?.updated)}, and the current
|
||||
route-backed pool set references {formatNumber(routeBackedPoolAddresses.length)} unique
|
||||
pool addresses.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/pools"
|
||||
className="rounded-full bg-primary-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-primary-700"
|
||||
>
|
||||
Open pools page
|
||||
</Link>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Open wallet tools
|
||||
</Link>
|
||||
<a
|
||||
href="/docs.html"
|
||||
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Explorer docs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
301
frontend/src/components/explorer/MoreOperationsPage.tsx
Normal file
301
frontend/src/components/explorer/MoreOperationsPage.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config'
|
||||
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
|
||||
function relativeAge(isoString?: string): string {
|
||||
if (!isoString) return 'Unknown'
|
||||
const parsed = Date.parse(isoString)
|
||||
if (!Number.isFinite(parsed)) return 'Unknown'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
function ActionLink({
|
||||
href,
|
||||
label,
|
||||
external,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
external?: boolean
|
||||
}) {
|
||||
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
|
||||
const text = `${label} ->`
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MoreOperationsPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(null)
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
|
||||
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(null)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.more
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [bridgeResult, routesResult, networksResult, tokenListResult, capabilitiesResult] =
|
||||
await Promise.allSettled([
|
||||
missionControlApi.getBridgeStatus(),
|
||||
routesApi.getRouteMatrix(),
|
||||
configApi.getNetworks(),
|
||||
configApi.getTokenList(),
|
||||
configApi.getCapabilities(),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
|
||||
if (routesResult.status === 'fulfilled') setRouteMatrix(routesResult.value)
|
||||
if (networksResult.status === 'fulfilled') setNetworksConfig(networksResult.value)
|
||||
if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value)
|
||||
if (capabilitiesResult.status === 'fulfilled') setCapabilities(capabilitiesResult.value)
|
||||
|
||||
const failedCount = [
|
||||
bridgeResult,
|
||||
routesResult,
|
||||
networksResult,
|
||||
tokenListResult,
|
||||
capabilitiesResult,
|
||||
].filter((result) => result.status === 'rejected').length
|
||||
|
||||
if (failedCount === 5) {
|
||||
setLoadingError('Public explorer operations data is temporarily unavailable.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(
|
||||
error instanceof Error ? error.message : 'Public explorer operations data is temporarily unavailable.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const relayCount = useMemo(() => {
|
||||
const relays = getMissionControlRelays(bridgeStatus)
|
||||
return relays ? Object.keys(relays).length : 0
|
||||
}, [bridgeStatus])
|
||||
|
||||
const totalQueue = useMemo(() => {
|
||||
const relays = getMissionControlRelays(bridgeStatus)
|
||||
if (!relays) return 0
|
||||
return Object.values(relays).reduce((sum, relay) => {
|
||||
const queueSize = relay.url_probe?.body?.queue?.size ?? relay.file_snapshot?.queue?.size ?? 0
|
||||
return sum + queueSize
|
||||
}, 0)
|
||||
}, [bridgeStatus])
|
||||
|
||||
const tokenChainCoverage = useMemo(() => {
|
||||
return new Set((tokenList?.tokens || []).map((token) => token.chainId).filter(Boolean)).size
|
||||
}, [tokenList])
|
||||
|
||||
const topSymbols = useMemo(() => {
|
||||
return Array.from(
|
||||
new Set((tokenList?.tokens || []).map((token) => token.symbol).filter(Boolean) as string[])
|
||||
).slice(0, 8)
|
||||
}, [tokenList])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 max-w-4xl sm:mb-8">
|
||||
<div className="mb-3 inline-flex rounded-full border border-violet-200 bg-violet-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-violet-700">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
{page.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{page.note ? (
|
||||
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
|
||||
{page.note}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{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">
|
||||
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
|
||||
Bridge Fleet
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{bridgeStatus?.data?.status || 'unknown'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{relayCount} managed lanes · queue {totalQueue}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
|
||||
Route Coverage
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{routeMatrix?.counts?.filteredLiveRoutes ?? 0}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{routeMatrix?.counts?.liveSwapRoutes ?? 0} swaps · {routeMatrix?.counts?.liveBridgeRoutes ?? 0} bridges
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
Wallet Surface
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{networksConfig?.chains?.length ?? 0}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Chains · {(tokenList?.tokens || []).length} tokens across {tokenChainCoverage} networks
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
RPC Capabilities
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{capabilities?.http?.supportedMethods?.length ?? 0}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
HTTP methods · {capabilities?.tracing?.supportedMethods?.length ?? 0} tracing methods
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Operations Snapshot">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<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-sm text-gray-500 dark:text-gray-400">Bridge checked</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{relativeAge(bridgeStatus?.data?.checked_at)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Public mission-control snapshot freshness.
|
||||
</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-sm text-gray-500 dark:text-gray-400">Route matrix updated</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{relativeAge(routeMatrix?.updated)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Token-aggregation route inventory timestamp.
|
||||
</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-sm text-gray-500 dark:text-gray-400">Default chain</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{networksConfig?.defaultChainId ?? 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Wallet onboarding points at Chain 138 by default.
|
||||
</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-sm text-gray-500 dark:text-gray-400">Wallet support</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{capabilities?.walletSupport?.walletAddEthereumChain && capabilities?.walletSupport?.walletWatchAsset
|
||||
? 'Ready'
|
||||
: 'Partial'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
`wallet_addEthereumChain` and `wallet_watchAsset` compatibility.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Public Config Highlights">
|
||||
<div className="space-y-4">
|
||||
<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-sm text-gray-500 dark:text-gray-400">Featured symbols</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{topSymbols.map((symbol) => (
|
||||
<span
|
||||
key={symbol}
|
||||
className="rounded-full bg-primary-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||
>
|
||||
{symbol}
|
||||
</span>
|
||||
))}
|
||||
</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-sm text-gray-500 dark:text-gray-400">Tracing posture</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Supported: {(capabilities?.tracing?.supportedMethods || []).join(', ') || 'None'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Unsupported: {(capabilities?.tracing?.unsupportedMethods || []).join(', ') || 'None'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{action.title}
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink
|
||||
href={action.href}
|
||||
label={action.label}
|
||||
external={'external' in action ? action.external : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
frontend/src/components/explorer/OperationsPageShell.tsx
Normal file
148
frontend/src/components/explorer/OperationsPageShell.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import type { ExplorerFeatureAction, ExplorerFeaturePage } from '@/data/explorerOperations'
|
||||
|
||||
export type StatusTone = 'normal' | 'warning' | 'danger'
|
||||
|
||||
function ActionLink({ action }: { action: ExplorerFeatureAction }) {
|
||||
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
|
||||
const label = `${action.label} ->`
|
||||
|
||||
if (action.external) {
|
||||
return (
|
||||
<a href={action.href} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={action.href} className={className}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function relativeAge(isoString?: string): string {
|
||||
if (!isoString) return 'Unknown'
|
||||
const parsed = Date.parse(isoString)
|
||||
if (!Number.isFinite(parsed)) return 'Unknown'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
export function formatNumber(value?: number | null): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '0'
|
||||
return new Intl.NumberFormat('en-US').format(value)
|
||||
}
|
||||
|
||||
export function formatCurrency(value?: number | null): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 'Unknown'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export function truncateMiddle(value?: string, start = 8, end = 6): string {
|
||||
if (!value) return 'Unknown'
|
||||
if (value.length <= start + end + 3) return value
|
||||
return `${value.slice(0, start)}...${value.slice(-end)}`
|
||||
}
|
||||
|
||||
export function StatusBadge({
|
||||
status,
|
||||
tone = 'normal',
|
||||
}: {
|
||||
status: string
|
||||
tone?: StatusTone
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'danger'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-300'
|
||||
: tone === 'warning'
|
||||
? 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300'
|
||||
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
|
||||
|
||||
return (
|
||||
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold uppercase tracking-wide ${toneClass}`}>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function MetricCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
value: string
|
||||
description: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{value}</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">{description}</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OperationsPageShell({
|
||||
page,
|
||||
children,
|
||||
}: {
|
||||
page: ExplorerFeaturePage
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 max-w-4xl sm:mb-8">
|
||||
<div className="mb-3 inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
{page.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{page.note ? (
|
||||
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">{page.note}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{action.title}</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink action={action} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
193
frontend/src/components/explorer/OperatorOperationsPage.tsx
Normal file
193
frontend/src/components/explorer/OperatorOperationsPage.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import {
|
||||
getMissionControlRelays,
|
||||
getMissionControlRelayLabel,
|
||||
missionControlApi,
|
||||
type MissionControlBridgeStatusResponse,
|
||||
} from '@/services/api/missionControl'
|
||||
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
|
||||
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import OperationsPageShell, {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
|
||||
const normalized = String(status || 'unknown').toLowerCase()
|
||||
if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger'
|
||||
if (['paused', 'starting', 'unknown'].includes(normalized)) return 'warning'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
export default function OperatorOperationsPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.operator
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [bridgeResult, routesResult, capabilitiesResult, planResult] = await Promise.allSettled([
|
||||
missionControlApi.getBridgeStatus(),
|
||||
routesApi.getRouteMatrix(),
|
||||
plannerApi.getCapabilities(),
|
||||
plannerApi.getInternalExecutionPlan(),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
|
||||
if (routesResult.status === 'fulfilled') setRouteMatrix(routesResult.value)
|
||||
if (capabilitiesResult.status === 'fulfilled') setPlannerCapabilities(capabilitiesResult.value)
|
||||
if (planResult.status === 'fulfilled') setInternalPlan(planResult.value)
|
||||
|
||||
const failedCount = [bridgeResult, routesResult, capabilitiesResult, planResult].filter(
|
||||
(result) => result.status === 'rejected'
|
||||
).length
|
||||
|
||||
if (failedCount === 4) {
|
||||
setLoadingError('Operator telemetry is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(error instanceof Error ? error.message : 'Operator telemetry is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const relays = useMemo(() => getMissionControlRelays(bridgeStatus), [bridgeStatus])
|
||||
const relayEntries = useMemo(() => Object.entries(relays || {}), [relays])
|
||||
const totalQueue = useMemo(
|
||||
() =>
|
||||
relayEntries.reduce((sum, [, relay]) => {
|
||||
const queueSize = relay.url_probe?.body?.queue?.size ?? relay.file_snapshot?.queue?.size ?? 0
|
||||
return sum + queueSize
|
||||
}, 0),
|
||||
[relayEntries]
|
||||
)
|
||||
const providers = plannerCapabilities?.providers || []
|
||||
const liveProviders = providers.filter((provider) => provider.live)
|
||||
|
||||
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="Relay Fleet"
|
||||
value={bridgeStatus?.data?.status || 'unknown'}
|
||||
description={`${relayEntries.length} managed lanes · queue ${formatNumber(totalQueue)}`}
|
||||
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Live Routes"
|
||||
value={formatNumber(routeMatrix?.counts?.filteredLiveRoutes)}
|
||||
description={`${formatNumber(routeMatrix?.counts?.blockedOrPlannedRoutes)} planned or blocked routes remain in the matrix.`}
|
||||
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Planner Providers"
|
||||
value={formatNumber(liveProviders.length)}
|
||||
description={`${formatNumber(providers.length)} published providers in planner v2 capabilities.`}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Fallback Decision"
|
||||
value={internalPlan?.plannerResponse?.decision || 'unknown'}
|
||||
description={
|
||||
internalPlan?.execution?.contractAddress
|
||||
? `Execution contract ${truncateMiddle(internalPlan.execution.contractAddress)}`
|
||||
: 'Latest internal execution plan posture.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<Card title="Managed Relay Lanes">
|
||||
<div className="space-y-4">
|
||||
{relayEntries.map(([key, relay]) => {
|
||||
const snapshot = relay.url_probe?.body || relay.file_snapshot
|
||||
const status = snapshot?.status || 'unknown'
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
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">
|
||||
{getMissionControlRelayLabel(key)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{snapshot?.destination?.chain_name || 'Unknown destination'} · queue {formatNumber(snapshot?.queue?.size ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={status} tone={relayTone(status)} />
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Last source poll {relativeAge(snapshot?.last_source_poll?.at)} · processed {formatNumber(snapshot?.queue?.processed ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{relayEntries.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No relay lane data available.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Execution Readiness">
|
||||
<div className="space-y-4">
|
||||
<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-sm text-gray-500 dark:text-gray-400">Internal execution plan</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<StatusBadge
|
||||
status={internalPlan?.plannerResponse?.decision || 'unknown'}
|
||||
tone={internalPlan?.plannerResponse?.decision === 'direct-pool' ? 'normal' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Contract {truncateMiddle(internalPlan?.execution?.contractAddress)} · {formatNumber(internalPlan?.plannerResponse?.steps?.length)} planner steps
|
||||
</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-sm text-gray-500 dark:text-gray-400">Live providers</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{liveProviders.map((provider) => (
|
||||
<span
|
||||
key={provider.provider}
|
||||
className="rounded-full bg-primary-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||
>
|
||||
{provider.provider}
|
||||
</span>
|
||||
))}
|
||||
{liveProviders.length === 0 ? (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">No live providers reported.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</OperationsPageShell>
|
||||
)
|
||||
}
|
||||
237
frontend/src/components/explorer/PoolsOperationsPage.tsx
Normal file
237
frontend/src/components/explorer/PoolsOperationsPage.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { configApi, type TokenListResponse } from '@/services/api/config'
|
||||
import {
|
||||
aggregateLiquidityPools,
|
||||
getRouteBackedPoolAddresses,
|
||||
selectFeaturedLiquidityTokens,
|
||||
} from '@/services/api/liquidity'
|
||||
import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import { formatCurrency, formatNumber, truncateMiddle } from './OperationsPageShell'
|
||||
|
||||
interface TokenPoolRecord {
|
||||
symbol: string
|
||||
pools: MissionControlLiquidityPool[]
|
||||
}
|
||||
|
||||
export default function PoolsOperationsPage() {
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>([])
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [tokenListResult, routeMatrixResult] = await Promise.allSettled([
|
||||
configApi.getTokenList(),
|
||||
routesApi.getRouteMatrix(),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value)
|
||||
if (routeMatrixResult.status === 'fulfilled') setRouteMatrix(routeMatrixResult.value)
|
||||
|
||||
if (tokenListResult.status === 'fulfilled') {
|
||||
const featuredTokens = selectFeaturedLiquidityTokens(tokenListResult.value.tokens || [])
|
||||
const poolResults = await Promise.allSettled(
|
||||
featuredTokens.map(async (token) => ({
|
||||
symbol: token.symbol,
|
||||
pools: (await routesApi.getTokenPools(token.address)).pools || [],
|
||||
}))
|
||||
)
|
||||
|
||||
if (!cancelled) {
|
||||
setTokenPoolRecords(
|
||||
poolResults
|
||||
.filter((result): result is PromiseFulfilledResult<TokenPoolRecord> => result.status === 'fulfilled')
|
||||
.map((result) => result.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenListResult.status === 'rejected' && routeMatrixResult.status === 'rejected') {
|
||||
setLoadingError('Live pool inventory is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(
|
||||
error instanceof Error ? error.message : 'Live pool inventory is temporarily unavailable from the public explorer APIs.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const featuredTokens = useMemo(
|
||||
() => selectFeaturedLiquidityTokens(tokenList?.tokens || []),
|
||||
[tokenList?.tokens]
|
||||
)
|
||||
const aggregatedPools = useMemo(
|
||||
() => aggregateLiquidityPools(tokenPoolRecords),
|
||||
[tokenPoolRecords]
|
||||
)
|
||||
const routeBackedPoolAddresses = useMemo(
|
||||
() => getRouteBackedPoolAddresses(routeMatrix),
|
||||
[routeMatrix]
|
||||
)
|
||||
const topPools = aggregatedPools.slice(0, 9)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 max-w-4xl sm:mb-8">
|
||||
<div className="mb-3 inline-flex rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-primary-700">
|
||||
Live Pool Inventory
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
Pools
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
This page now summarizes the live pool inventory discovered through mission-control token
|
||||
pool endpoints and cross-checks it against the current route matrix.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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-8 grid gap-4 md:grid-cols-3">
|
||||
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
|
||||
Unique pools
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(aggregatedPools.length)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Discovered across {formatNumber(featuredTokens.length)} featured Chain 138 tokens.
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
|
||||
Route-backed pools
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(routeBackedPoolAddresses.length)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Unique pool addresses referenced by the live route matrix.
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Featured coverage</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(tokenPoolRecords.filter((record) => record.pools.length > 0).length)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Featured tokens currently returning at least one live pool.
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Live Pool Cards">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{topPools.map((pool) => (
|
||||
<div
|
||||
key={pool.address}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{pool.dex || 'Unknown DEX'} · TVL {formatCurrency(pool.tvl)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{truncateMiddle(pool.address, 10, 8)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Seen from {pool.sourceSymbols.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{topPools.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No live pools available right now.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Featured Token Pool Counts">
|
||||
<div className="space-y-4">
|
||||
{featuredTokens.map((token) => {
|
||||
const record = tokenPoolRecords.find((entry) => entry.symbol === token.symbol)
|
||||
return (
|
||||
<div
|
||||
key={token.address}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{token.symbol}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{token.name || 'Unnamed token'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{formatNumber(record?.pools.length || 0)} pools
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Liquidity Shortcuts">
|
||||
<div className="space-y-4 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
The broader liquidity page now shows live route, planner, and pool access together.
|
||||
</p>
|
||||
<p>
|
||||
The current route matrix publishes {formatNumber(routeMatrix?.counts?.liveSwapRoutes)} live
|
||||
swap routes and {formatNumber(routeMatrix?.counts?.liveBridgeRoutes)} bridge routes.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/liquidity"
|
||||
className="rounded-full bg-primary-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-primary-700"
|
||||
>
|
||||
Open liquidity access
|
||||
</Link>
|
||||
<Link
|
||||
href="/routes"
|
||||
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Open routes page
|
||||
</Link>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Open wallet tools
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
401
frontend/src/components/explorer/RoutesMonitoringPage.tsx
Normal file
401
frontend/src/components/explorer/RoutesMonitoringPage.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import {
|
||||
routesApi,
|
||||
type ExplorerNetwork,
|
||||
type MissionControlLiquidityPool,
|
||||
type RouteMatrixRoute,
|
||||
type RouteMatrixResponse,
|
||||
} from '@/services/api/routes'
|
||||
|
||||
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
|
||||
|
||||
function relativeAge(isoString?: string): string {
|
||||
if (!isoString) return 'Unknown'
|
||||
const parsed = Date.parse(isoString)
|
||||
if (!Number.isFinite(parsed)) return 'Unknown'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
function compactAddress(value?: string): string {
|
||||
if (!value) return 'Unspecified'
|
||||
if (value.length <= 14) return value
|
||||
return `${value.slice(0, 6)}...${value.slice(-4)}`
|
||||
}
|
||||
|
||||
function formatUsd(value?: number): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 'Unknown'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function protocolList(route: RouteMatrixRoute): string {
|
||||
const protocols = Array.from(
|
||||
new Set((route.legs || []).map((leg) => leg.protocol || leg.executor || '').filter(Boolean))
|
||||
)
|
||||
return protocols.length > 0 ? protocols.join(', ') : 'Unspecified'
|
||||
}
|
||||
|
||||
function routeAssetPair(route: RouteMatrixRoute): string {
|
||||
if (route.routeType === 'bridge') {
|
||||
return route.assetSymbol || 'Bridge asset'
|
||||
}
|
||||
return [route.tokenInSymbol, route.tokenOutSymbol].filter(Boolean).join(' -> ') || 'Swap route'
|
||||
}
|
||||
|
||||
function ActionLink({
|
||||
href,
|
||||
label,
|
||||
external,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
external?: boolean
|
||||
}) {
|
||||
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
|
||||
const text = `${label} ->`
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoutesMonitoringPage() {
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [networks, setNetworks] = useState<ExplorerNetwork[]>([])
|
||||
const [pools, setPools] = useState<MissionControlLiquidityPool[]>([])
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.routes
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [matrixResult, networksResult, poolsResult] = await Promise.allSettled([
|
||||
routesApi.getRouteMatrix(),
|
||||
routesApi.getNetworks(),
|
||||
routesApi.getTokenPools(canonicalLiquidityToken),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (matrixResult.status === 'fulfilled') {
|
||||
setRouteMatrix(matrixResult.value)
|
||||
}
|
||||
if (networksResult.status === 'fulfilled') {
|
||||
setNetworks(networksResult.value.networks || [])
|
||||
}
|
||||
if (poolsResult.status === 'fulfilled') {
|
||||
setPools(poolsResult.value.pools || [])
|
||||
}
|
||||
|
||||
if (
|
||||
matrixResult.status === 'rejected' &&
|
||||
networksResult.status === 'rejected' &&
|
||||
poolsResult.status === 'rejected'
|
||||
) {
|
||||
setLoadingError('Live route inventory is temporarily unavailable.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(
|
||||
error instanceof Error ? error.message : 'Live route inventory is temporarily unavailable.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const liveRoutes = useMemo(() => routeMatrix?.liveRoutes || [], [routeMatrix?.liveRoutes])
|
||||
const plannedRoutes = useMemo(
|
||||
() => routeMatrix?.blockedOrPlannedRoutes || [],
|
||||
[routeMatrix?.blockedOrPlannedRoutes]
|
||||
)
|
||||
|
||||
const familyCount = useMemo(() => {
|
||||
return new Set(liveRoutes.flatMap((route) => route.aggregatorFamilies || [])).size
|
||||
}, [liveRoutes])
|
||||
|
||||
const topRoutes = useMemo(() => {
|
||||
const ordered = [...liveRoutes].sort((left, right) => {
|
||||
if ((left.routeType || '') !== (right.routeType || '')) {
|
||||
return left.routeType === 'bridge' ? -1 : 1
|
||||
}
|
||||
return (left.label || '').localeCompare(right.label || '')
|
||||
})
|
||||
return ordered.slice(0, 8)
|
||||
}, [liveRoutes])
|
||||
|
||||
const highlightedNetworks = useMemo(() => {
|
||||
return networks
|
||||
.filter((network) => [138, 1, 651940, 56, 43114].includes(network.chainIdDecimal || 0))
|
||||
.sort((left, right) => (left.chainIdDecimal || 0) - (right.chainIdDecimal || 0))
|
||||
}, [networks])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 max-w-4xl sm:mb-8">
|
||||
<div className="mb-3 inline-flex rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
{page.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{page.note ? (
|
||||
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
|
||||
{page.note}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{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">
|
||||
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
|
||||
Live Swap Routes
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{routeMatrix?.counts?.liveSwapRoutes ?? liveRoutes.filter((route) => route.routeType === 'swap').length}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Planner-visible same-chain routes on Chain 138.
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
|
||||
Live Bridge Routes
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{routeMatrix?.counts?.liveBridgeRoutes ?? liveRoutes.filter((route) => route.routeType === 'bridge').length}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Bridge routes exposed through the current route matrix.
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
Network Catalog
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{networks.length}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Published networks available through the explorer config surface.
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
cUSDT Pool View
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{pools.length}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Live mission-control pools for the canonical cUSDT token.
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card title="Route Matrix Summary">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<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-sm text-gray-500 dark:text-gray-400">Generated</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{relativeAge(routeMatrix?.generatedAt)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Matrix version {routeMatrix?.version || 'unknown'}
|
||||
</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-sm text-gray-500 dark:text-gray-400">Updated Source</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{relativeAge(routeMatrix?.updated)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{familyCount} partner families surfaced in live routes.
|
||||
</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-sm text-gray-500 dark:text-gray-400">Filtered Live Routes</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{routeMatrix?.counts?.filteredLiveRoutes ?? liveRoutes.length}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Includes swap and bridge lanes currently in the public matrix.
|
||||
</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-sm text-gray-500 dark:text-gray-400">Planned / Blocked</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{routeMatrix?.counts?.blockedOrPlannedRoutes ?? plannedRoutes.length}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Remaining lanes still waiting on pools, funding, or routing support.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Highlighted Networks">
|
||||
<div className="space-y-3">
|
||||
{highlightedNetworks.map((network) => (
|
||||
<div
|
||||
key={`${network.chainIdDecimal}-${network.chainName}`}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{network.chainName || 'Unknown chain'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Chain ID {network.chainIdDecimal ?? 'Unknown'} · {network.shortName || 'n/a'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card title="Live Route Snapshot">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{topRoutes.map((route) => (
|
||||
<div
|
||||
key={route.routeId}
|
||||
className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{route.label || route.routeId}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{routeAssetPair(route)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
{route.routeType || 'route'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{`${route.fromChainId} -> ${route.toChainId} · ${route.hopCount ?? 0} hop${
|
||||
(route.hopCount ?? 0) === 1 ? '' : 's'
|
||||
}`}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Protocols: {protocolList(route)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="cUSDT Mission-Control Pools">
|
||||
<div className="space-y-4">
|
||||
{pools.map((pool) => (
|
||||
<div
|
||||
key={pool.address}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{pool.dex || 'Unknown DEX'} · TVL {formatUsd(pool.tvl)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Pool {compactAddress(pool.address)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Planned Route Backlog">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{plannedRoutes.slice(0, 6).map((route) => (
|
||||
<div
|
||||
key={route.routeId}
|
||||
className="rounded-2xl border border-amber-200 bg-amber-50/70 p-4 dark:border-amber-900/50 dark:bg-amber-950/20"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{route.routeId}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{(route.tokenInSymbols || []).join(' / ') || routeAssetPair(route)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{route.reason || 'Pending additional deployment or routing work.'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{action.title}
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink
|
||||
href={action.href}
|
||||
label={action.label}
|
||||
external={'external' in action ? action.external : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
189
frontend/src/components/explorer/SystemOperationsPage.tsx
Normal file
189
frontend/src/components/explorer/SystemOperationsPage.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config'
|
||||
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
import OperationsPageShell, {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
export default function SystemOperationsPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(null)
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
|
||||
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(null)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.system
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [bridgeResult, networksResult, tokenListResult, capabilitiesResult, routesResult, statsResult] =
|
||||
await Promise.allSettled([
|
||||
missionControlApi.getBridgeStatus(),
|
||||
configApi.getNetworks(),
|
||||
configApi.getTokenList(),
|
||||
configApi.getCapabilities(),
|
||||
routesApi.getRouteMatrix(),
|
||||
statsApi.get(),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
|
||||
if (networksResult.status === 'fulfilled') setNetworksConfig(networksResult.value)
|
||||
if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value)
|
||||
if (capabilitiesResult.status === 'fulfilled') setCapabilities(capabilitiesResult.value)
|
||||
if (routesResult.status === 'fulfilled') setRouteMatrix(routesResult.value)
|
||||
if (statsResult.status === 'fulfilled') setStats(statsResult.value)
|
||||
|
||||
const failedCount = [
|
||||
bridgeResult,
|
||||
networksResult,
|
||||
tokenListResult,
|
||||
capabilitiesResult,
|
||||
routesResult,
|
||||
statsResult,
|
||||
].filter((result) => result.status === 'rejected').length
|
||||
|
||||
if (failedCount === 6) {
|
||||
setLoadingError('System inventory data is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(error instanceof Error ? error.message : 'System inventory data is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const relays = useMemo(() => getMissionControlRelays(bridgeStatus), [bridgeStatus])
|
||||
const chainStatus = bridgeStatus?.data?.chains?.['138']
|
||||
const chainCoverage = useMemo(
|
||||
() => new Set((tokenList?.tokens || []).map((token) => token.chainId).filter(Boolean)).size,
|
||||
[tokenList]
|
||||
)
|
||||
|
||||
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="Published Networks"
|
||||
value={formatNumber(networksConfig?.chains?.length)}
|
||||
description={`Default chain ${networksConfig?.defaultChainId ?? 'unknown'} in wallet onboarding.`}
|
||||
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Relay Lanes"
|
||||
value={formatNumber(relays ? Object.keys(relays).length : 0)}
|
||||
description={`${bridgeStatus?.data?.status || 'unknown'} public bridge posture across managed lanes.`}
|
||||
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Token Coverage"
|
||||
value={formatNumber((tokenList?.tokens || []).length)}
|
||||
description={`${formatNumber(chainCoverage)} chain catalogs served through the public token list.`}
|
||||
/>
|
||||
<MetricCard
|
||||
title="RPC Methods"
|
||||
value={formatNumber(capabilities?.http?.supportedMethods?.length)}
|
||||
description={`${formatNumber(capabilities?.tracing?.supportedMethods?.length)} tracing methods published.`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Topology Snapshot">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<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-sm text-gray-500 dark:text-gray-400">Chain 138 RPC</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<StatusBadge status={chainStatus?.status || 'unknown'} tone={chainStatus?.status === 'operational' ? 'normal' : 'warning'} />
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Head age {chainStatus?.head_age_sec != null ? `${Math.round(chainStatus.head_age_sec)}s` : 'Unknown'} · latency {chainStatus?.latency_ms != null ? `${Math.round(chainStatus.latency_ms)}ms` : 'Unknown'}
|
||||
</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-sm text-gray-500 dark:text-gray-400">Route matrix</div>
|
||||
<div className="mt-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{formatNumber(routeMatrix?.counts?.filteredLiveRoutes)} live routes
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Updated {relativeAge(routeMatrix?.updated)} · {formatNumber(routeMatrix?.counts?.blockedOrPlannedRoutes)} planned or blocked
|
||||
</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-sm text-gray-500 dark:text-gray-400">Explorer index</div>
|
||||
<div className="mt-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{formatNumber(stats?.total_blocks)} blocks
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatNumber(stats?.total_transactions)} transactions · {formatNumber(stats?.total_addresses)} addresses
|
||||
</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-sm text-gray-500 dark:text-gray-400">Wallet compatibility</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<StatusBadge
|
||||
status={
|
||||
capabilities?.walletSupport?.walletAddEthereumChain && capabilities?.walletSupport?.walletWatchAsset
|
||||
? 'ready'
|
||||
: 'partial'
|
||||
}
|
||||
tone={
|
||||
capabilities?.walletSupport?.walletAddEthereumChain && capabilities?.walletSupport?.walletWatchAsset
|
||||
? 'normal'
|
||||
: 'warning'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Public capabilities JSON is wired for chain-add and token-add flows.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Network Inventory">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{(networksConfig?.chains || []).map((chain) => (
|
||||
<div
|
||||
key={`${chain.chainIdDecimal}-${chain.shortName}`}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{chain.chainName || chain.shortName || `Chain ${chain.chainIdDecimal}`}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Chain ID {chain.chainIdDecimal ?? 'Unknown'} · short name {chain.shortName || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(networksConfig?.chains || []).length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No network inventory available.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</OperationsPageShell>
|
||||
)
|
||||
}
|
||||
188
frontend/src/components/explorer/WethOperationsPage.tsx
Normal file
188
frontend/src/components/explorer/WethOperationsPage.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import {
|
||||
getMissionControlRelays,
|
||||
missionControlApi,
|
||||
type MissionControlBridgeStatusResponse,
|
||||
type MissionControlRelayPayload,
|
||||
} from '@/services/api/missionControl'
|
||||
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
|
||||
import OperationsPageShell, {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
|
||||
const normalized = String(status || 'unknown').toLowerCase()
|
||||
if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger'
|
||||
if (['paused', 'starting', 'unknown'].includes(normalized)) return 'warning'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function relaySnapshot(relay: MissionControlRelayPayload | undefined) {
|
||||
return relay?.url_probe?.body || relay?.file_snapshot
|
||||
}
|
||||
|
||||
export default function WethOperationsPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.weth
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [bridgeResult, capabilitiesResult, planResult] = await Promise.allSettled([
|
||||
missionControlApi.getBridgeStatus(),
|
||||
plannerApi.getCapabilities(),
|
||||
plannerApi.getInternalExecutionPlan(),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
|
||||
if (capabilitiesResult.status === 'fulfilled') setPlannerCapabilities(capabilitiesResult.value)
|
||||
if (planResult.status === 'fulfilled') setInternalPlan(planResult.value)
|
||||
|
||||
const failedCount = [bridgeResult, capabilitiesResult, planResult].filter(
|
||||
(result) => result.status === 'rejected'
|
||||
).length
|
||||
|
||||
if (failedCount === 3) {
|
||||
setLoadingError('WETH operations data is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(error instanceof Error ? error.message : 'WETH operations data is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const relays = useMemo(() => getMissionControlRelays(bridgeStatus), [bridgeStatus])
|
||||
const mainnetWeth = relaySnapshot(relays?.mainnet_weth)
|
||||
const mainnetCw = relaySnapshot(relays?.mainnet_cw)
|
||||
const wethProviders = useMemo(
|
||||
() =>
|
||||
(plannerCapabilities?.providers || []).filter((provider) =>
|
||||
(provider.pairs || []).some(
|
||||
(pair) => pair.tokenInSymbol === 'WETH' || pair.tokenOutSymbol === 'WETH'
|
||||
)
|
||||
),
|
||||
[plannerCapabilities]
|
||||
)
|
||||
|
||||
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="Mainnet WETH Lane"
|
||||
value={mainnetWeth?.status || 'unknown'}
|
||||
description={`Queue ${formatNumber(mainnetWeth?.queue?.size ?? 0)} · ${mainnetWeth?.destination?.chain_name || 'destination unknown'}`}
|
||||
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Mainnet cW Lane"
|
||||
value={mainnetCw?.status || 'unknown'}
|
||||
description={`Queue ${formatNumber(mainnetCw?.queue?.size ?? 0)} · ${mainnetCw?.destination?.chain_name || 'destination unknown'}`}
|
||||
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="WETH Providers"
|
||||
value={formatNumber(wethProviders.length)}
|
||||
description="Providers that currently publish at least one WETH leg in planner v2."
|
||||
/>
|
||||
<MetricCard
|
||||
title="Fallback Decision"
|
||||
value={internalPlan?.plannerResponse?.decision || 'unknown'}
|
||||
description={
|
||||
internalPlan?.execution?.contractAddress
|
||||
? `Execution contract ${truncateMiddle(internalPlan.execution.contractAddress)}`
|
||||
: 'Current internal execution-plan posture for a WETH route.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Mainnet Bridge Lanes">
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: 'Mainnet WETH', snapshot: mainnetWeth },
|
||||
{ label: 'Mainnet cW', snapshot: mainnetCw },
|
||||
].map(({ label, snapshot }) => (
|
||||
<div
|
||||
key={label}
|
||||
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">{label}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Source {snapshot?.source?.chain_name || 'Unknown'} · destination {snapshot?.destination?.chain_name || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={snapshot?.status || 'unknown'} tone={relayTone(snapshot?.status)} />
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Queue {formatNumber(snapshot?.queue?.size ?? 0)} · last poll {relativeAge(snapshot?.last_source_poll?.at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="WETH Route-Ready Providers">
|
||||
<div className="space-y-4">
|
||||
{wethProviders.map((provider) => {
|
||||
const samplePairs = (provider.pairs || [])
|
||||
.filter((pair) => pair.tokenInSymbol === 'WETH' || pair.tokenOutSymbol === 'WETH')
|
||||
.slice(0, 3)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.provider}
|
||||
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">
|
||||
{provider.provider}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{provider.executionMode || 'unknown mode'} · {(provider.supportedLegTypes || []).join(', ') || 'no leg types'}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={provider.live ? 'live' : 'inactive'} tone={provider.live ? 'normal' : 'warning'} />
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{samplePairs.map((pair) => `${pair.tokenInSymbol} -> ${pair.tokenOutSymbol}`).join(' · ') || 'No WETH pairs published'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{wethProviders.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No WETH-aware providers reported.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</OperationsPageShell>
|
||||
)
|
||||
}
|
||||
@@ -55,9 +55,16 @@ type TokenListCatalog = {
|
||||
|
||||
type CapabilitiesCatalog = {
|
||||
name?: string
|
||||
version?: {
|
||||
major?: number
|
||||
minor?: number
|
||||
patch?: number
|
||||
}
|
||||
timestamp?: string
|
||||
chainId?: number
|
||||
chainName?: string
|
||||
rpcUrl?: string
|
||||
explorerUrl?: string
|
||||
explorerApiUrl?: string
|
||||
generatedBy?: string
|
||||
walletSupport?: {
|
||||
@@ -128,6 +135,80 @@ const FEATURED_TOKEN_SYMBOLS = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAU
|
||||
/** npm-published Snap using open Snap permissions only; stable MetaMask still requires MetaMask’s install allowlist. */
|
||||
const CHAIN138_OPEN_SNAP_ID = 'npm:chain138-open-snap' as const
|
||||
|
||||
const FALLBACK_CAPABILITIES_138: CapabilitiesCatalog = {
|
||||
name: 'Chain 138 RPC Capabilities',
|
||||
version: { major: 1, minor: 1, patch: 0 },
|
||||
timestamp: '2026-03-28T00:00:00Z',
|
||||
generatedBy: 'SolaceScanScout',
|
||||
chainId: 138,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
rpcUrl: 'https://rpc-http-pub.d-bis.org',
|
||||
explorerUrl: 'https://explorer.d-bis.org',
|
||||
explorerApiUrl: 'https://explorer.d-bis.org/api/v2',
|
||||
walletSupport: {
|
||||
walletAddEthereumChain: true,
|
||||
walletWatchAsset: true,
|
||||
notes: [
|
||||
'MetaMask primarily relies on JSON-RPC correctness for balances, gas estimation, calls, and transaction submission.',
|
||||
'Explorer-served network metadata and token list metadata complement wallet UX but do not replace RPC method support.',
|
||||
],
|
||||
},
|
||||
http: {
|
||||
supportedMethods: [
|
||||
'web3_clientVersion',
|
||||
'net_version',
|
||||
'eth_chainId',
|
||||
'eth_blockNumber',
|
||||
'eth_syncing',
|
||||
'eth_gasPrice',
|
||||
'eth_maxPriorityFeePerGas',
|
||||
'eth_feeHistory',
|
||||
'eth_estimateGas',
|
||||
'eth_getCode',
|
||||
],
|
||||
unsupportedMethods: [],
|
||||
notes: [
|
||||
'eth_feeHistory is available for wallet fee estimation.',
|
||||
'eth_maxPriorityFeePerGas is exposed on the public RPC for wallet-grade fee suggestion compatibility.',
|
||||
],
|
||||
},
|
||||
tracing: {
|
||||
supportedMethods: ['trace_block', 'trace_replayBlockTransactions'],
|
||||
unsupportedMethods: ['debug_traceBlockByNumber'],
|
||||
notes: [
|
||||
'TRACE support is enabled for explorer-grade indexing and internal transaction analysis.',
|
||||
'Debug tracing is intentionally not enabled on the public RPC tier.',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
function isTokenListToken(value: unknown): value is TokenListToken {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
|
||||
const candidate = value as Partial<TokenListToken>
|
||||
return (
|
||||
typeof candidate.chainId === 'number' &&
|
||||
typeof candidate.address === 'string' &&
|
||||
candidate.address.trim().length > 0 &&
|
||||
typeof candidate.name === 'string' &&
|
||||
typeof candidate.symbol === 'string' &&
|
||||
typeof candidate.decimals === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isCapabilitiesCatalog(value: unknown): value is CapabilitiesCatalog {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
|
||||
const candidate = value as Partial<CapabilitiesCatalog>
|
||||
return (
|
||||
typeof candidate.chainId === 'number' &&
|
||||
typeof candidate.chainName === 'string' &&
|
||||
candidate.chainName.trim().length > 0 &&
|
||||
typeof candidate.rpcUrl === 'string' &&
|
||||
candidate.rpcUrl.trim().length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function getApiBase() {
|
||||
return resolveExplorerApiBase({
|
||||
serverFallback: 'https://explorer.d-bis.org',
|
||||
@@ -152,6 +233,10 @@ export function AddToMetaMask() {
|
||||
const tokenListUrl = `${apiBase}/api/config/token-list`
|
||||
const networksUrl = `${apiBase}/api/config/networks`
|
||||
const capabilitiesUrl = `${apiBase}/api/config/capabilities`
|
||||
const staticCapabilitiesUrl =
|
||||
typeof window !== 'undefined'
|
||||
? `${window.location.origin.replace(/\/$/, '')}/config/CHAIN138_RPC_CAPABILITIES.json`
|
||||
: `${apiBase}/config/CHAIN138_RPC_CAPABILITIES.json`
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
@@ -180,21 +265,46 @@ export function AddToMetaMask() {
|
||||
fetchJson(capabilitiesUrl),
|
||||
])
|
||||
|
||||
let resolvedCapabilities = capabilitiesResponse
|
||||
if (!isCapabilitiesCatalog(resolvedCapabilities.json)) {
|
||||
const staticCapabilitiesResponse = await fetchJson(staticCapabilitiesUrl)
|
||||
if (isCapabilitiesCatalog(staticCapabilitiesResponse.json)) {
|
||||
resolvedCapabilities = {
|
||||
json: staticCapabilitiesResponse.json,
|
||||
meta: {
|
||||
source: staticCapabilitiesResponse.meta.source || 'public-static-fallback',
|
||||
lastModified: staticCapabilitiesResponse.meta.lastModified,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
resolvedCapabilities = {
|
||||
json: FALLBACK_CAPABILITIES_138,
|
||||
meta: {
|
||||
source: 'frontend-fallback',
|
||||
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!active) return
|
||||
setNetworks(networksResponse.json)
|
||||
setTokenList(tokenListResponse.json)
|
||||
setCapabilities(capabilitiesResponse.json)
|
||||
setCapabilities(resolvedCapabilities.json)
|
||||
setNetworksMeta(networksResponse.meta)
|
||||
setTokenListMeta(tokenListResponse.meta)
|
||||
setCapabilitiesMeta(capabilitiesResponse.meta)
|
||||
setCapabilitiesMeta(resolvedCapabilities.meta)
|
||||
} catch {
|
||||
if (!active) return
|
||||
setNetworks(null)
|
||||
setTokenList(null)
|
||||
setCapabilities(null)
|
||||
setCapabilities(FALLBACK_CAPABILITIES_138)
|
||||
setNetworksMeta(null)
|
||||
setTokenListMeta(null)
|
||||
setCapabilitiesMeta(null)
|
||||
setCapabilitiesMeta({
|
||||
source: 'frontend-fallback',
|
||||
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
|
||||
})
|
||||
} finally {
|
||||
if (active) {
|
||||
timer = setTimeout(() => {
|
||||
@@ -210,7 +320,12 @@ export function AddToMetaMask() {
|
||||
active = false
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [capabilitiesUrl, networksUrl, tokenListUrl])
|
||||
}, [capabilitiesUrl, networksUrl, staticCapabilitiesUrl, tokenListUrl])
|
||||
|
||||
const catalogTokens = useMemo(
|
||||
() => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []),
|
||||
[tokenList],
|
||||
)
|
||||
|
||||
const chains = useMemo(() => {
|
||||
const chainMap = new Map<number, WalletChain>()
|
||||
@@ -230,7 +345,7 @@ export function AddToMetaMask() {
|
||||
|
||||
const featuredTokens = useMemo(() => {
|
||||
const tokenMap = new Map<string, TokenListToken>()
|
||||
for (const token of tokenList?.tokens || []) {
|
||||
for (const token of catalogTokens) {
|
||||
if (token.chainId !== 138) continue
|
||||
if (!FEATURED_TOKEN_SYMBOLS.includes(token.symbol)) continue
|
||||
tokenMap.set(token.symbol, token)
|
||||
@@ -239,7 +354,7 @@ export function AddToMetaMask() {
|
||||
return FEATURED_TOKEN_SYMBOLS
|
||||
.map((symbol) => tokenMap.get(symbol))
|
||||
.filter((token): token is TokenListToken => !!token)
|
||||
}, [tokenList])
|
||||
}, [catalogTokens])
|
||||
|
||||
const addChain = async (chain: WalletChain) => {
|
||||
setError(null)
|
||||
@@ -304,6 +419,11 @@ export function AddToMetaMask() {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
|
||||
if (!isTokenListToken(token)) {
|
||||
setError('Token metadata is incomplete right now. Refresh the page and try again.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
return
|
||||
@@ -312,7 +432,7 @@ export function AddToMetaMask() {
|
||||
try {
|
||||
const added = await ethereum.request({
|
||||
method: 'wallet_watchAsset',
|
||||
params: [{
|
||||
params: {
|
||||
type: 'ERC20',
|
||||
options: {
|
||||
address: token.address,
|
||||
@@ -320,7 +440,7 @@ export function AddToMetaMask() {
|
||||
decimals: token.decimals,
|
||||
image: token.logoURI,
|
||||
},
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
setStatus(added ? `Added ${token.symbol} to your wallet.` : `${token.symbol} request was dismissed.`)
|
||||
@@ -342,11 +462,15 @@ export function AddToMetaMask() {
|
||||
}
|
||||
}
|
||||
|
||||
const tokenCount138 = (tokenList?.tokens || []).filter((token) => token.chainId === 138).length
|
||||
const tokenCount138 = catalogTokens.filter((token) => token.chainId === 138).length
|
||||
const metadataKeywordString = (tokenList?.keywords || []).join(', ')
|
||||
const supportedHTTPMethods = capabilities?.http?.supportedMethods || []
|
||||
const unsupportedHTTPMethods = capabilities?.http?.unsupportedMethods || []
|
||||
const supportedTraceMethods = capabilities?.tracing?.supportedMethods || []
|
||||
const displayedCapabilitiesUrl =
|
||||
capabilitiesMeta?.source === 'public-static-fallback' || capabilitiesMeta?.source === 'frontend-fallback'
|
||||
? staticCapabilitiesUrl
|
||||
: capabilitiesUrl
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
|
||||
@@ -432,12 +556,12 @@ export function AddToMetaMask() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Capabilities URL</p>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{capabilitiesUrl}</code>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{displayedCapabilitiesUrl}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => copyText(capabilitiesUrl, 'capabilities URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
<button type="button" onClick={() => copyText(displayedCapabilitiesUrl, 'capabilities URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
Copy URL
|
||||
</button>
|
||||
<a href={capabilitiesUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
<a href={displayedCapabilitiesUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
Open JSON
|
||||
</a>
|
||||
</div>
|
||||
|
||||
307
frontend/src/data/explorerOperations.ts
Normal file
307
frontend/src/data/explorerOperations.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
export interface ExplorerFeatureAction {
|
||||
title: string
|
||||
description: string
|
||||
href: string
|
||||
label: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
export interface ExplorerFeaturePage {
|
||||
eyebrow: string
|
||||
title: string
|
||||
description: string
|
||||
note?: string
|
||||
actions: ExplorerFeatureAction[]
|
||||
}
|
||||
|
||||
const legacyNote =
|
||||
'These tools were restored in the legacy explorer asset first. The live Next explorer now exposes them here so they are reachable from the public UI without falling back to hidden static routes.'
|
||||
|
||||
export const explorerFeaturePages = {
|
||||
bridge: {
|
||||
eyebrow: 'Bridge Monitoring',
|
||||
title: 'Bridge & Relay Monitoring',
|
||||
description:
|
||||
'Inspect the CCIP relay status, follow the live mission-control stream, trace bridge transactions, and review the managed Mainnet, BSC, Avalanche, Avalanche cW, and Avalanche to Chain 138 lanes.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Mission-control live stream',
|
||||
description: 'Open the server-sent event stream that powers live relay and RPC monitoring.',
|
||||
href: '/explorer-api/v1/mission-control/stream',
|
||||
label: 'Open SSE stream',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Bridge status snapshot',
|
||||
description: 'Review the current relay health payload, queue posture, and destination summary.',
|
||||
href: '/explorer-api/v1/track1/bridge/status',
|
||||
label: 'Open status JSON',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Reverse AVAX lane',
|
||||
description: 'Check the managed Avalanche cW burn-back lane to Chain 138 that now runs as its own relay service.',
|
||||
href: '/explorer-api/v1/track1/bridge/status',
|
||||
label: 'Review AVAX -> 138 lane',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Bridge trace API',
|
||||
description: 'Resolve source and destination addresses for a bridge transaction through mission control.',
|
||||
href: '/explorer-api/v1/mission-control/bridge/trace?tx=0x2f31d4f9a97be754b800f4af1a9eedf3b107d353bfa1a19e81417497a76c05c2',
|
||||
label: 'Open trace example',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Open the interactive topology map for Chain 138, CCIP, Alltra, and adjacent integrations.',
|
||||
href: '/chain138-command-center.html',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Routes & liquidity',
|
||||
description: 'Move from bridge health into route coverage, pools, and execution access points.',
|
||||
href: '/routes',
|
||||
label: 'Open routes page',
|
||||
},
|
||||
],
|
||||
},
|
||||
routes: {
|
||||
eyebrow: 'Route Coverage',
|
||||
title: 'Routes, Pools, and Execution Access',
|
||||
description:
|
||||
'Surface the route matrix, live pool inventory, public liquidity endpoints, and bridge-adjacent execution paths that were previously only visible in the legacy explorer shell.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Liquidity access',
|
||||
description: 'Review the public Chain 138 PMM access points, route helpers, and fallback execution endpoints.',
|
||||
href: '/liquidity',
|
||||
label: 'Open liquidity access',
|
||||
},
|
||||
{
|
||||
title: 'Pools inventory',
|
||||
description: 'Jump to the pool overview page for quick PMM route and asset discovery.',
|
||||
href: '/pools',
|
||||
label: 'Open pools page',
|
||||
},
|
||||
{
|
||||
title: 'Liquidity mission-control example',
|
||||
description: 'Open a live mission-control liquidity lookup for a canonical Chain 138 token.',
|
||||
href: '/explorer-api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools',
|
||||
label: 'Open liquidity JSON',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Bridge monitoring',
|
||||
description: 'Cross-check route availability with live relay and bridge health before operator actions.',
|
||||
href: '/bridge',
|
||||
label: 'Open bridge monitoring',
|
||||
},
|
||||
{
|
||||
title: 'Operations hub',
|
||||
description: 'Open the consolidated page for WETH utilities, analytics, operator shortcuts, and system views.',
|
||||
href: '/more',
|
||||
label: 'Open operations hub',
|
||||
},
|
||||
],
|
||||
},
|
||||
weth: {
|
||||
eyebrow: 'WETH Utilities',
|
||||
title: 'WETH Utilities & Bridge References',
|
||||
description:
|
||||
'Reach the WETH-focused tooling that operators use during support and bridge investigation without depending on the hidden legacy explorer navigation.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Bridge monitoring',
|
||||
description: 'Start with relay and bridge health before reviewing WETH-specific flows.',
|
||||
href: '/bridge',
|
||||
label: 'Open bridge monitoring',
|
||||
},
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Use the interactive topology map for contract placement, hub flow, and system context.',
|
||||
href: '/chain138-command-center.html',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Wallet tools',
|
||||
description: 'Open the wallet page if you need supported network and token setup before testing flows.',
|
||||
href: '/wallet',
|
||||
label: 'Open wallet tools',
|
||||
},
|
||||
{
|
||||
title: 'Operations hub',
|
||||
description: 'Return to the larger operations landing page for adjacent route, analytics, and system shortcuts.',
|
||||
href: '/more',
|
||||
label: 'Open operations hub',
|
||||
},
|
||||
],
|
||||
},
|
||||
analytics: {
|
||||
eyebrow: 'Analytics Access',
|
||||
title: 'Analytics & Network Activity',
|
||||
description:
|
||||
'Use the public explorer pages and live monitoring endpoints as the visible analytics surface for chain activity, recent blocks, and transaction flow.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Blocks',
|
||||
description: 'Inspect recent block production, timestamps, and miner attribution.',
|
||||
href: '/blocks',
|
||||
label: 'Open blocks',
|
||||
},
|
||||
{
|
||||
title: 'Transactions',
|
||||
description: 'Review recent transactions, status, and linked address flow.',
|
||||
href: '/transactions',
|
||||
label: 'Open transactions',
|
||||
},
|
||||
{
|
||||
title: 'Addresses',
|
||||
description: 'Browse saved and active addresses as part of the explorer activity surface.',
|
||||
href: '/addresses',
|
||||
label: 'Open addresses',
|
||||
},
|
||||
{
|
||||
title: 'Mission-control stream',
|
||||
description: 'Supplement the explorer pages with the live relay and RPC event feed.',
|
||||
href: '/explorer-api/v1/mission-control/stream',
|
||||
label: 'Open SSE stream',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
operator: {
|
||||
eyebrow: 'Operator Shortcuts',
|
||||
title: 'Operator Panel Shortcuts',
|
||||
description:
|
||||
'Expose the public operational shortcuts that were restored in the legacy explorer for bridge checks, route validation, liquidity entry points, and documentation.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Bridge monitoring',
|
||||
description: 'Open relay status, queue posture, and bridge trace tools.',
|
||||
href: '/bridge',
|
||||
label: 'Open bridge monitoring',
|
||||
},
|
||||
{
|
||||
title: 'Routes',
|
||||
description: 'Inspect route coverage and liquidity path access before operator intervention.',
|
||||
href: '/routes',
|
||||
label: 'Open routes page',
|
||||
},
|
||||
{
|
||||
title: 'Liquidity access',
|
||||
description: 'Open partner payload helpers, route APIs, and execution-plan endpoints.',
|
||||
href: '/liquidity',
|
||||
label: 'Open liquidity access',
|
||||
},
|
||||
{
|
||||
title: 'Explorer docs',
|
||||
description: 'Use the static documentation landing page for explorer-specific reference material.',
|
||||
href: '/docs.html',
|
||||
label: 'Open docs',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Open the graphical deployment and integration topology in a dedicated page.',
|
||||
href: '/chain138-command-center.html',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
system: {
|
||||
eyebrow: 'System Topology',
|
||||
title: 'System & Topology',
|
||||
description:
|
||||
'Jump straight into the public topology and reference surfaces that describe how Chain 138, bridge monitoring, and adjacent systems fit together.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Open the topology map for Chain 138, CCIP, Alltra, OP Stack, and service flows.',
|
||||
href: '/chain138-command-center.html',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Bridge monitoring',
|
||||
description: 'Correlate topology context with the live bridge and relay status surface.',
|
||||
href: '/bridge',
|
||||
label: 'Open bridge monitoring',
|
||||
},
|
||||
{
|
||||
title: 'Explorer docs',
|
||||
description: 'Open the documentation landing page for static reference material shipped with the explorer.',
|
||||
href: '/docs.html',
|
||||
label: 'Open docs',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Operations hub',
|
||||
description: 'Return to the consolidated operations landing page for adjacent public tools.',
|
||||
href: '/more',
|
||||
label: 'Open operations hub',
|
||||
},
|
||||
],
|
||||
},
|
||||
more: {
|
||||
eyebrow: 'Operations Hub',
|
||||
title: 'More Explorer Tools',
|
||||
description:
|
||||
'This hub exposes the restored public tools that were previously buried in the legacy explorer shell: bridge monitoring, routes, WETH utilities, analytics shortcuts, operator links, and topology views.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Bridge & relay monitoring',
|
||||
description: 'Open mission-control status, SSE monitoring, and bridge trace helpers.',
|
||||
href: '/bridge',
|
||||
label: 'Open bridge monitoring',
|
||||
},
|
||||
{
|
||||
title: 'Routes & liquidity',
|
||||
description: 'Open route coverage, pools, and public liquidity access points.',
|
||||
href: '/routes',
|
||||
label: 'Open routes page',
|
||||
},
|
||||
{
|
||||
title: 'WETH utilities',
|
||||
description: 'Open the WETH-focused landing page and bridge-adjacent shortcuts.',
|
||||
href: '/weth',
|
||||
label: 'Open WETH utilities',
|
||||
},
|
||||
{
|
||||
title: 'Analytics',
|
||||
description: 'Open the public analytics landing page for blocks, transactions, and live monitoring.',
|
||||
href: '/analytics',
|
||||
label: 'Open analytics page',
|
||||
},
|
||||
{
|
||||
title: 'Operator panel shortcuts',
|
||||
description: 'Open the operator landing page for bridge, route, liquidity, and docs shortcuts.',
|
||||
href: '/operator',
|
||||
label: 'Open operator page',
|
||||
},
|
||||
{
|
||||
title: 'System topology',
|
||||
description: 'Open the system landing page for topology references and command-center access.',
|
||||
href: '/system',
|
||||
label: 'Open system page',
|
||||
},
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Open the dedicated interactive topology asset in a new tab.',
|
||||
href: '/chain138-command-center.html',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as const satisfies Record<string, ExplorerFeaturePage>
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { AppProps } from 'next/app'
|
||||
import '../app/globals.css'
|
||||
import ExplorerChrome from '@/components/common/ExplorerChrome'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />
|
||||
return (
|
||||
<ExplorerChrome>
|
||||
<Component {...pageProps} />
|
||||
</ExplorerChrome>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,14 @@ import { useRouter } from 'next/router'
|
||||
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { addressesApi, AddressInfo, TransactionSummary } from '@/services/api/addresses'
|
||||
import { formatWeiAsEth } from '@/utils/format'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
import {
|
||||
isWatchlistEntry,
|
||||
readWatchlistFromStorage,
|
||||
writeWatchlistToStorage,
|
||||
normalizeWatchlistAddress,
|
||||
} from '@/utils/watchlist'
|
||||
|
||||
export default function AddressDetailPage() {
|
||||
const router = useRouter()
|
||||
@@ -13,6 +21,7 @@ export default function AddressDetailPage() {
|
||||
|
||||
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
|
||||
const [transactions, setTransactions] = useState<TransactionSummary[]>([])
|
||||
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadAddressInfo = useCallback(async () => {
|
||||
@@ -54,16 +63,39 @@ export default function AddressDetailPage() {
|
||||
loadTransactions()
|
||||
}, [address, loadAddressInfo, loadTransactions, router.isReady])
|
||||
|
||||
if (!router.isReady) {
|
||||
return <div className="p-8">Loading address...</div>
|
||||
}
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading address...</div>
|
||||
}
|
||||
try {
|
||||
setWatchlistEntries(readWatchlistFromStorage(window.localStorage))
|
||||
} catch {
|
||||
setWatchlistEntries([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!addressInfo) {
|
||||
return <div className="p-8">Address not found</div>
|
||||
const watchlistAddress = normalizeWatchlistAddress(addressInfo?.address || address)
|
||||
const isSavedToWatchlist = watchlistAddress
|
||||
? isWatchlistEntry(watchlistEntries, watchlistAddress)
|
||||
: false
|
||||
|
||||
const handleWatchlistToggle = () => {
|
||||
if (!watchlistAddress || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
setWatchlistEntries((current) => {
|
||||
const next = isSavedToWatchlist
|
||||
? current.filter((entry) => entry.toLowerCase() !== watchlistAddress.toLowerCase())
|
||||
: [...current, watchlistAddress]
|
||||
|
||||
try {
|
||||
writeWatchlistToStorage(window.localStorage, next)
|
||||
} catch {}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const transactionColumns = [
|
||||
@@ -71,7 +103,7 @@ export default function AddressDetailPage() {
|
||||
header: 'Hash',
|
||||
accessor: (tx: TransactionSummary) => (
|
||||
<Link href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.hash} truncate />
|
||||
<Address address={tx.hash} truncate showCopy={false} />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
@@ -87,17 +119,13 @@ export default function AddressDetailPage() {
|
||||
header: 'To',
|
||||
accessor: (tx: TransactionSummary) => tx.to_address ? (
|
||||
<Link href={`/addresses/${tx.to_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.to_address} truncate />
|
||||
<Address address={tx.to_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
) : 'Contract Creation',
|
||||
},
|
||||
{
|
||||
header: 'Value',
|
||||
accessor: (tx: TransactionSummary) => {
|
||||
const value = BigInt(tx.value)
|
||||
const eth = Number(value) / 1e18
|
||||
return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH'
|
||||
},
|
||||
accessor: (tx: TransactionSummary) => formatWeiAsEth(tx.value),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
@@ -110,56 +138,74 @@ export default function AddressDetailPage() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">
|
||||
{addressInfo.label || 'Address'}
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">
|
||||
{addressInfo?.label || 'Address'}
|
||||
</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/addresses" className="text-primary-600 hover:underline">
|
||||
Back to addresses
|
||||
</Link>
|
||||
<Link href={`/search?q=${encodeURIComponent(addressInfo.address)}`} className="text-primary-600 hover:underline">
|
||||
Search this address
|
||||
</Link>
|
||||
{(addressInfo?.address || address) && (
|
||||
<Link href={`/search?q=${encodeURIComponent(addressInfo?.address || address)}`} className="text-primary-600 hover:underline">
|
||||
Search this address
|
||||
</Link>
|
||||
)}
|
||||
{watchlistAddress && router.isReady && !loading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleWatchlistToggle}
|
||||
className="rounded-full border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
{isSavedToWatchlist ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card title="Address Information" className="mb-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold">Address:</span>
|
||||
<Address address={addressInfo.address} className="ml-2" />
|
||||
</div>
|
||||
{addressInfo.tags.length > 0 && (
|
||||
<div>
|
||||
<span className="font-semibold">Tags:</span>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{addressInfo.tags.map((tag, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-sm">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Transactions:</span>
|
||||
<span className="ml-2">{addressInfo.transaction_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Tokens:</span>
|
||||
<span className="ml-2">{addressInfo.token_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Type:</span>
|
||||
<span className="ml-2">{addressInfo.is_contract ? 'Contract' : 'EOA'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{!router.isReady || loading ? (
|
||||
<Card className="mb-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading address...</p>
|
||||
</Card>
|
||||
) : !addressInfo ? (
|
||||
<Card className="mb-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Address not found.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card title="Address Information" className="mb-6">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Address">
|
||||
<Address address={addressInfo.address} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Watchlist">
|
||||
{isSavedToWatchlist ? 'Saved for quick access' : 'Not saved yet'}
|
||||
</DetailRow>
|
||||
{addressInfo.tags.length > 0 && (
|
||||
<DetailRow label="Tags" valueClassName="flex flex-wrap gap-2">
|
||||
{addressInfo.tags.map((tag, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-sm">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Transactions">{addressInfo.transaction_count}</DetailRow>
|
||||
<DetailRow label="Tokens">{addressInfo.token_count}</DetailRow>
|
||||
<DetailRow label="Type">{addressInfo.is_contract ? 'Contract' : 'EOA'}</DetailRow>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card title="Transactions">
|
||||
<Table columns={transactionColumns} data={transactions} keyExtractor={(tx) => tx.hash} />
|
||||
</Card>
|
||||
<Card title="Transactions">
|
||||
<Table
|
||||
columns={transactionColumns}
|
||||
data={transactions}
|
||||
emptyMessage="No recent transactions were found for this address."
|
||||
keyExtractor={(tx) => tx.hash}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import { transactionsApi, Transaction } from '@/services/api/transactions'
|
||||
import { readWatchlistFromStorage } from '@/utils/watchlist'
|
||||
|
||||
function normalizeAddress(value: string) {
|
||||
const trimmed = value.trim()
|
||||
@@ -35,10 +36,12 @@ export default function AddressesPage() {
|
||||
}, [chainId])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem('explorerWatchlist')
|
||||
const entries = raw ? JSON.parse(raw) : []
|
||||
setWatchlist(Array.isArray(entries) ? entries.filter((entry): entry is string => typeof entry === 'string') : [])
|
||||
setWatchlist(readWatchlistFromStorage(window.localStorage))
|
||||
} catch {
|
||||
setWatchlist([])
|
||||
}
|
||||
@@ -70,8 +73,8 @@ export default function AddressesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Addresses</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Addresses</h1>
|
||||
|
||||
<Card className="mb-6" title="Open An Address">
|
||||
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
|
||||
@@ -85,7 +88,7 @@ export default function AddressesPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!normalizeAddress(query)}
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Open address
|
||||
</button>
|
||||
@@ -105,7 +108,7 @@ export default function AddressesPage() {
|
||||
<div className="space-y-3">
|
||||
{watchlist.map((entry) => (
|
||||
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
|
||||
<Address address={entry} />
|
||||
<Address address={entry} showCopy={false} />
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-2">
|
||||
@@ -126,7 +129,7 @@ export default function AddressesPage() {
|
||||
<div className="space-y-3">
|
||||
{activeAddresses.map((entry) => (
|
||||
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
|
||||
<Address address={entry} />
|
||||
<Address address={entry} showCopy={false} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
9
frontend/src/pages/analytics/index.tsx
Normal file
9
frontend/src/pages/analytics/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const AnalyticsOperationsPage = dynamic(() => import('@/components/explorer/AnalyticsOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
return <AnalyticsOperationsPage />
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/router'
|
||||
import { blocksApi, Block } from '@/services/api/blocks'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
|
||||
export default function BlockDetailPage() {
|
||||
const router = useRouter()
|
||||
@@ -40,68 +41,63 @@ export default function BlockDetailPage() {
|
||||
loadBlock()
|
||||
}, [isValidBlock, loadBlock, router.isReady])
|
||||
|
||||
if (!router.isReady) {
|
||||
return <div className="p-8">Loading block...</div>
|
||||
}
|
||||
|
||||
if (!isValidBlock) {
|
||||
return <div className="p-8">Invalid block number. Please use a valid block number from the URL.</div>
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading block...</div>
|
||||
}
|
||||
|
||||
if (!block) {
|
||||
return <div className="p-8">Block not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Block #{block.number}</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">{block ? `Block #${block.number}` : 'Block'}</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/blocks" className="text-primary-600 hover:underline">
|
||||
Back to blocks
|
||||
</Link>
|
||||
{block.number > 0 ? (
|
||||
{block && block.number > 0 ? (
|
||||
<Link href={`/blocks/${block.number - 1}`} className="text-primary-600 hover:underline">
|
||||
Previous block
|
||||
</Link>
|
||||
) : null}
|
||||
<Link href={`/blocks/${block.number + 1}`} className="text-primary-600 hover:underline">
|
||||
Next block
|
||||
</Link>
|
||||
{block && (
|
||||
<Link href={`/blocks/${block.number + 1}`} className="text-primary-600 hover:underline">
|
||||
Next block
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card title="Block Information">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold">Hash:</span>
|
||||
<Address address={block.hash} className="ml-2" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Timestamp:</span>
|
||||
<span className="ml-2">{new Date(block.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Miner:</span>
|
||||
<Link href={`/addresses/${block.miner}`} className="ml-2 text-primary-600 hover:underline">
|
||||
<Address address={block.miner} truncate />
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Transactions:</span>
|
||||
<Link href="/transactions" className="ml-2 text-primary-600 hover:underline">
|
||||
{block.transaction_count}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Gas Used:</span>
|
||||
<span className="ml-2">{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{!router.isReady || loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading block...</p>
|
||||
</Card>
|
||||
) : !isValidBlock ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Invalid block number. Please use a valid block number from the URL.</p>
|
||||
</Card>
|
||||
) : !block ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Block not found.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card title="Block Information">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Hash">
|
||||
<Address address={block.hash} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Timestamp">
|
||||
{new Date(block.timestamp).toLocaleString()}
|
||||
</DetailRow>
|
||||
<DetailRow label="Miner">
|
||||
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
|
||||
<Address address={block.miner} truncate showCopy={false} />
|
||||
</Link>
|
||||
</DetailRow>
|
||||
<DetailRow label="Transactions">
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
{block.transaction_count}
|
||||
</Link>
|
||||
</DetailRow>
|
||||
<DetailRow label="Gas Used">
|
||||
{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function BlocksPage() {
|
||||
const pageSize = 20
|
||||
const [blocks, setBlocks] = useState<Block[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -17,75 +18,89 @@ export default function BlocksPage() {
|
||||
const response = await blocksApi.list({
|
||||
chain_id: chainId,
|
||||
page,
|
||||
page_size: 20,
|
||||
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])
|
||||
}, [chainId, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
loadBlocks()
|
||||
}, [loadBlocks])
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading blocks...</div>
|
||||
}
|
||||
const showPagination = page > 1 || blocks.length > 0
|
||||
const canGoNext = blocks.length === pageSize
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Blocks</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Blocks</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
{blocks.map((block) => (
|
||||
<Card key={block.number}>
|
||||
<div className="flex items-center 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="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
<Address address={block.hash} truncate />
|
||||
{loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading blocks...</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{blocks.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Recent blocks are unavailable right now.</p>
|
||||
</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>
|
||||
<div className="text-left sm:text-right">
|
||||
<div className="text-sm">
|
||||
{new Date(block.timestamp).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{block.transaction_count} transactions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm">
|
||||
{new Date(block.timestamp).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{block.transaction_count} transactions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-4 py-2">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="px-4 py-2 bg-gray-200 rounded"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
9
frontend/src/pages/bridge/index.tsx
Normal file
9
frontend/src/pages/bridge/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const BridgeMonitoringPage = dynamic(() => import('@/components/explorer/BridgeMonitoringPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function BridgePage() {
|
||||
return <BridgeMonitoringPage />
|
||||
}
|
||||
28
frontend/src/pages/home/index.tsx
Normal file
28
frontend/src/pages/home/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function HomeAliasPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
void router.replace('/')
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-12">
|
||||
<div className="mx-auto max-w-xl rounded-xl border border-gray-200 bg-white p-6 text-center shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Redirecting to SolaceScanScout</h1>
|
||||
<p className="mt-3 text-sm leading-7 text-gray-600 dark:text-gray-400">
|
||||
The legacy <code className="rounded bg-gray-100 px-1 py-0.5 text-xs dark:bg-gray-900">/home</code> route now redirects to the main explorer landing page.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-5 inline-flex rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Continue to the explorer
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
9
frontend/src/pages/more/index.tsx
Normal file
9
frontend/src/pages/more/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const MoreOperationsPage = dynamic(() => import('@/components/explorer/MoreOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function MorePage() {
|
||||
return <MoreOperationsPage />
|
||||
}
|
||||
9
frontend/src/pages/operator/index.tsx
Normal file
9
frontend/src/pages/operator/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const OperatorOperationsPage = dynamic(() => import('@/components/explorer/OperatorOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function OperatorPage() {
|
||||
return <OperatorOperationsPage />
|
||||
}
|
||||
@@ -1,88 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
|
||||
const poolCards = [
|
||||
{
|
||||
title: 'Canonical PMM routes',
|
||||
description: 'Review the public Chain 138 DODO PMM route matrix, live pool freshness, and payload examples.',
|
||||
href: '/liquidity',
|
||||
label: 'Open liquidity access',
|
||||
},
|
||||
{
|
||||
title: 'Wallet Funding Path',
|
||||
description: 'Open wallet tools first if you need Chain 138 setup, token import links, or a quick route into supported assets.',
|
||||
href: '/wallet',
|
||||
label: 'Open wallet tools',
|
||||
},
|
||||
{
|
||||
title: 'Explorer Docs',
|
||||
description: 'Static documentation covers the live pool map, expected web content, and route access details.',
|
||||
href: '/docs.html',
|
||||
label: 'Open docs landing page',
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
|
||||
const shortcutCards = [
|
||||
{
|
||||
title: 'cUSDT / USDT',
|
||||
description: 'Open the canonical direct stable route coverage and compare the live pool snapshot.',
|
||||
href: '/liquidity',
|
||||
},
|
||||
{
|
||||
title: 'cUSDC / USDC',
|
||||
description: 'Check the public stable bridge route and inspect the live reserves block.',
|
||||
href: '/liquidity',
|
||||
},
|
||||
{
|
||||
title: 'cUSDT / cXAUC',
|
||||
description: 'Review one of the live gold-backed route families from the liquidity access page.',
|
||||
href: '/liquidity',
|
||||
},
|
||||
]
|
||||
import PoolsOperationsPage from '@/components/explorer/PoolsOperationsPage'
|
||||
|
||||
export default function PoolsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Pools</h1>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{poolCards.map((card) => (
|
||||
<Card key={card.title} title={card.title}>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{card.description}</p>
|
||||
<div className="mt-4">
|
||||
{card.external ? (
|
||||
<a href={card.href} className="text-primary-600 hover:underline">
|
||||
{card.label} →
|
||||
</a>
|
||||
) : (
|
||||
<Link href={card.href} className="text-primary-600 hover:underline">
|
||||
{card.label} →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Card title="Pool operation shortcuts">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{shortcutCards.map((card) => (
|
||||
<Link
|
||||
key={card.title}
|
||||
href={card.href}
|
||||
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-700"
|
||||
>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{card.title}</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{card.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <PoolsOperationsPage />
|
||||
}
|
||||
|
||||
9
frontend/src/pages/routes/index.tsx
Normal file
9
frontend/src/pages/routes/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const RoutesMonitoringPage = dynamic(() => import('@/components/explorer/RoutesMonitoringPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function RoutesPage() {
|
||||
return <RoutesMonitoringPage />
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/router'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { getExplorerApiBase } from '@/services/api/blockscout'
|
||||
import { inferDirectSearchTarget } from '@/utils/search'
|
||||
|
||||
interface SearchResult {
|
||||
type: string
|
||||
@@ -24,17 +25,29 @@ export default function SearchPage() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasSearched, setHasSearched] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const runSearch = async (rawQuery: string) => {
|
||||
if (!rawQuery.trim()) return
|
||||
const trimmedQuery = rawQuery.trim()
|
||||
if (!trimmedQuery) {
|
||||
setHasSearched(false)
|
||||
setResults([])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
setHasSearched(true)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${getExplorerApiBase()}/api/v2/search?q=${encodeURIComponent(rawQuery)}`
|
||||
`${getExplorerApiBase()}/api/v2/search?q=${encodeURIComponent(trimmedQuery)}`
|
||||
)
|
||||
const data = await response.json()
|
||||
const data = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
setResults([])
|
||||
setError('Search is temporarily unavailable right now.')
|
||||
return
|
||||
}
|
||||
const normalizedResults = Array.isArray(data?.items)
|
||||
@@ -59,6 +72,7 @@ export default function SearchPage() {
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
setResults([])
|
||||
setError('Search is temporarily unavailable right now.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -73,15 +87,37 @@ export default function SearchPage() {
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
await runSearch(query)
|
||||
const trimmedQuery = query.trim()
|
||||
if (!trimmedQuery) {
|
||||
return
|
||||
}
|
||||
|
||||
const directTarget = inferDirectSearchTarget(trimmedQuery)
|
||||
if (directTarget) {
|
||||
void router.push(directTarget.href)
|
||||
return
|
||||
}
|
||||
|
||||
void router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: { q: trimmedQuery },
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
)
|
||||
await runSearch(trimmedQuery)
|
||||
}
|
||||
|
||||
const trimmedQuery = query.trim()
|
||||
const directTarget = inferDirectSearchTarget(trimmedQuery)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Search</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Search</h1>
|
||||
|
||||
<Card className="mb-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-4">
|
||||
<form onSubmit={handleSearch} className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
@@ -91,42 +127,75 @@ export default function SearchPage() {
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
disabled={loading || !trimmedQuery}
|
||||
className="w-full rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
|
||||
>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{!loading && error && (
|
||||
<Card className="mb-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{error}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && 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.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href={directTarget.href} className="text-primary-600 hover:underline">
|
||||
{directTarget.label} →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<Card title="Search Results">
|
||||
<div className="space-y-4">
|
||||
{results.map((result, index) => (
|
||||
<div key={result.data?.hash ?? result.data?.address ?? result.data?.number ?? index} className="border-b border-gray-200 dark:border-gray-700 pb-4 last:border-0">
|
||||
<div key={result.data?.hash ?? result.data?.address ?? result.data?.number ?? index} className="border-b border-gray-200 pb-4 last:border-0 dark:border-gray-700">
|
||||
{result.type === 'block' && result.data.number && (
|
||||
<Link href={`/blocks/${result.data.number}`} className="text-primary-600 hover:underline">
|
||||
<Link href={`/blocks/${result.data.number}`} className="inline-flex flex-col gap-1 text-primary-600 hover:underline">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Block</span>
|
||||
Block #{result.data.number}
|
||||
</Link>
|
||||
)}
|
||||
{result.type === 'transaction' && result.data.hash && (
|
||||
<Link href={`/transactions/${result.data.hash}`} className="text-primary-600 hover:underline">
|
||||
Transaction <Address address={result.data.hash} truncate />
|
||||
<Link href={`/transactions/${result.data.hash}`} className="inline-flex flex-col gap-1 text-primary-600 hover:underline">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Transaction</span>
|
||||
<Address address={result.data.hash} truncate showCopy={false} />
|
||||
</Link>
|
||||
)}
|
||||
{result.type === 'address' && result.data.address && (
|
||||
<Link href={`/addresses/${result.data.address}`} className="text-primary-600 hover:underline">
|
||||
Address <Address address={result.data.address} truncate />
|
||||
<Link href={`/addresses/${result.data.address}`} className="inline-flex flex-col gap-1 text-primary-600 hover:underline">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Address</span>
|
||||
<Address address={result.data.address} truncate showCopy={false} />
|
||||
</Link>
|
||||
)}
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
Type: {result.type} | Chain: {result.chain_id ?? 138} | Score: {(result.score ?? 0).toFixed(2)}
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500">
|
||||
<span>Type: {result.type}</span>
|
||||
<span>Chain: {result.chain_id ?? 138}</span>
|
||||
<span>Score: {(result.score ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && hasSearched && !error && results.length === 0 && (
|
||||
<Card title="No Results Found">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No explorer results matched <span className="font-medium text-gray-900 dark:text-white">{trimmedQuery}</span>.
|
||||
Try a full address, transaction hash, token symbol, or block number.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
9
frontend/src/pages/system/index.tsx
Normal file
9
frontend/src/pages/system/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const SystemOperationsPage = dynamic(() => import('@/components/explorer/SystemOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function SystemPage() {
|
||||
return <SystemOperationsPage />
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export default function TokensPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!query.trim()}
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useRouter } from 'next/router'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { transactionsApi, Transaction } from '@/services/api/transactions'
|
||||
import { formatWeiAsEth } from '@/utils/format'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
|
||||
export default function TransactionDetailPage() {
|
||||
const router = useRouter()
|
||||
@@ -42,92 +44,76 @@ export default function TransactionDetailPage() {
|
||||
loadTransaction()
|
||||
}, [hash, loadTransaction, router.isReady])
|
||||
|
||||
if (!router.isReady) {
|
||||
return <div className="p-8">Loading transaction...</div>
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading transaction...</div>
|
||||
}
|
||||
|
||||
if (!transaction) {
|
||||
return <div className="p-8">Transaction not found</div>
|
||||
}
|
||||
|
||||
const value = BigInt(transaction.value)
|
||||
const ethValue = Number(value) / 1e18
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Transaction</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Transaction</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
Back to transactions
|
||||
</Link>
|
||||
<Link href={`/search?q=${encodeURIComponent(transaction.hash)}`} className="text-primary-600 hover:underline">
|
||||
Search this hash
|
||||
</Link>
|
||||
{(transaction?.hash || hash) && (
|
||||
<Link href={`/search?q=${encodeURIComponent(transaction?.hash || hash)}`} className="text-primary-600 hover:underline">
|
||||
Search this hash
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card title="Transaction Information">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold">Hash:</span>
|
||||
<Address address={transaction.hash} className="ml-2" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Block:</span>
|
||||
<Link href={`/blocks/${transaction.block_number}`} className="ml-2 text-primary-600 hover:underline">
|
||||
#{transaction.block_number}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">From:</span>
|
||||
<Link href={`/addresses/${transaction.from_address}`} className="ml-2">
|
||||
<Address address={transaction.from_address} truncate />
|
||||
</Link>
|
||||
</div>
|
||||
{transaction.to_address && (
|
||||
<div>
|
||||
<span className="font-semibold">To:</span>
|
||||
<Link href={`/addresses/${transaction.to_address}`} className="ml-2">
|
||||
<Address address={transaction.to_address} truncate />
|
||||
{!router.isReady || loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading transaction...</p>
|
||||
</Card>
|
||||
) : !transaction ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Transaction not found.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card title="Transaction Information">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Hash">
|
||||
<Address address={transaction.hash} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Block">
|
||||
<Link href={`/blocks/${transaction.block_number}`} className="text-primary-600 hover:underline">
|
||||
#{transaction.block_number}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Value:</span>
|
||||
<span className="ml-2">{ethValue.toFixed(4)} ETH</span>
|
||||
</div>
|
||||
{transaction.gas_price && (
|
||||
<div>
|
||||
<span className="font-semibold">Gas Price:</span>
|
||||
<span className="ml-2">{transaction.gas_price / 1e9} Gwei</span>
|
||||
</div>
|
||||
)}
|
||||
{transaction.gas_used && (
|
||||
<div>
|
||||
<span className="font-semibold">Gas Used:</span>
|
||||
<span className="ml-2">{transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Status:</span>
|
||||
<span className={`ml-2 ${transaction.status === 1 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{transaction.status === 1 ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
{transaction.contract_address && (
|
||||
<div>
|
||||
<span className="font-semibold">Contract Created:</span>
|
||||
<Link href={`/addresses/${transaction.contract_address}`} className="ml-2">
|
||||
<Address address={transaction.contract_address} truncate />
|
||||
</DetailRow>
|
||||
<DetailRow label="From">
|
||||
<Link href={`/addresses/${transaction.from_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={transaction.from_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</DetailRow>
|
||||
{transaction.to_address && (
|
||||
<DetailRow label="To">
|
||||
<Link href={`/addresses/${transaction.to_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={transaction.to_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Value">{formatWeiAsEth(transaction.value)}</DetailRow>
|
||||
<DetailRow label="Gas Price">
|
||||
{transaction.gas_price != null ? `${transaction.gas_price / 1e9} Gwei` : 'N/A'}
|
||||
</DetailRow>
|
||||
{transaction.gas_used != null && (
|
||||
<DetailRow label="Gas Used">
|
||||
{transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()}
|
||||
</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Status">
|
||||
<span className={transaction.status === 1 ? 'text-green-600' : 'text-red-600'}>
|
||||
{transaction.status === 1 ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
{transaction.contract_address && (
|
||||
<DetailRow label="Contract Created">
|
||||
<Link href={`/addresses/${transaction.contract_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={transaction.contract_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
</DetailRow>
|
||||
)}
|
||||
</dl>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Table, Address } from '@/libs/frontend-ui-primitives'
|
||||
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { transactionsApi, Transaction } from '@/services/api/transactions'
|
||||
import { formatWeiAsEth } from '@/utils/format'
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const pageSize = 20
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -14,7 +16,7 @@ export default function TransactionsPage() {
|
||||
const loadTransactions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { ok, data } = await transactionsApi.listSafe(chainId, page, 20)
|
||||
const { ok, data } = await transactionsApi.listSafe(chainId, page, pageSize)
|
||||
setTransactions(ok ? data : [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error)
|
||||
@@ -22,18 +24,21 @@ export default function TransactionsPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [chainId, page])
|
||||
}, [chainId, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
loadTransactions()
|
||||
}, [loadTransactions])
|
||||
|
||||
const showPagination = page > 1 || transactions.length > 0
|
||||
const canGoNext = transactions.length === pageSize
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: 'Hash',
|
||||
accessor: (tx: Transaction) => (
|
||||
<Link href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.hash} truncate />
|
||||
<Address address={tx.hash} truncate showCopy={false} />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
@@ -49,7 +54,7 @@ export default function TransactionsPage() {
|
||||
header: 'From',
|
||||
accessor: (tx: Transaction) => (
|
||||
<Link href={`/addresses/${tx.from_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.from_address} truncate />
|
||||
<Address address={tx.from_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
@@ -57,17 +62,13 @@ export default function TransactionsPage() {
|
||||
header: 'To',
|
||||
accessor: (tx: Transaction) => tx.to_address ? (
|
||||
<Link href={`/addresses/${tx.to_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.to_address} truncate />
|
||||
<Address address={tx.to_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
) : <span className="text-gray-400">Contract Creation</span>,
|
||||
},
|
||||
{
|
||||
header: 'Value',
|
||||
accessor: (tx: Transaction) => {
|
||||
const value = BigInt(tx.value)
|
||||
const eth = Number(value) / 1e18
|
||||
return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH'
|
||||
},
|
||||
accessor: (tx: Transaction) => formatWeiAsEth(tx.value),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
@@ -79,32 +80,42 @@ export default function TransactionsPage() {
|
||||
},
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading transactions...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Transactions</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Transactions</h1>
|
||||
|
||||
<Table columns={columns} data={transactions} keyExtractor={(tx) => tx.hash} />
|
||||
{loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading transactions...</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={transactions}
|
||||
emptyMessage="Recent transactions are unavailable right now."
|
||||
keyExtractor={(tx) => tx.hash}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-4 py-2">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="px-4 py-2 bg-gray-200 rounded"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import {
|
||||
readWatchlistFromStorage,
|
||||
writeWatchlistToStorage,
|
||||
sanitizeWatchlistEntries,
|
||||
} from '@/utils/watchlist'
|
||||
|
||||
export default function WatchlistPage() {
|
||||
const [entries, setEntries] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem('explorerWatchlist')
|
||||
const parsed = raw ? JSON.parse(raw) : []
|
||||
setEntries(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [])
|
||||
setEntries(readWatchlistFromStorage(window.localStorage))
|
||||
} catch {
|
||||
setEntries([])
|
||||
}
|
||||
@@ -21,13 +28,17 @@ export default function WatchlistPage() {
|
||||
setEntries((current) => {
|
||||
const next = current.filter((entry) => entry.toLowerCase() !== address.toLowerCase())
|
||||
try {
|
||||
window.localStorage.setItem('explorerWatchlist', JSON.stringify(next))
|
||||
writeWatchlistToStorage(window.localStorage, next)
|
||||
} catch {}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const exportWatchlist = () => {
|
||||
if (entries.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -45,12 +56,9 @@ export default function WatchlistPage() {
|
||||
|
||||
file.text().then((text) => {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
const next = Array.isArray(parsed)
|
||||
? parsed.filter((entry): entry is string => typeof entry === 'string')
|
||||
: []
|
||||
const next = sanitizeWatchlistEntries(JSON.parse(text))
|
||||
setEntries(next)
|
||||
window.localStorage.setItem('explorerWatchlist', JSON.stringify(next))
|
||||
writeWatchlistToStorage(window.localStorage, next)
|
||||
} catch {}
|
||||
}).catch(() => {})
|
||||
|
||||
@@ -58,8 +66,8 @@ export default function WatchlistPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Watchlist</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Watchlist</h1>
|
||||
|
||||
<Card title="Saved Addresses">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
@@ -75,7 +83,7 @@ export default function WatchlistPage() {
|
||||
type="button"
|
||||
onClick={exportWatchlist}
|
||||
disabled={entries.length === 0}
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 disabled:opacity-100 dark:disabled:bg-gray-700 dark:disabled:text-gray-400"
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
@@ -98,7 +106,7 @@ export default function WatchlistPage() {
|
||||
{entries.map((entry) => (
|
||||
<div key={entry} className="flex flex-col gap-2 rounded-lg border border-gray-200 p-3 dark:border-gray-700 md:flex-row md:items-center md:justify-between">
|
||||
<Link href={`/addresses/${entry}`} className="text-primary-600 hover:underline">
|
||||
<Address address={entry} />
|
||||
<Address address={entry} showCopy={false} />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
9
frontend/src/pages/weth/index.tsx
Normal file
9
frontend/src/pages/weth/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const WethOperationsPage = dynamic(() => import('@/components/explorer/WethOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function WethPage() {
|
||||
return <WethOperationsPage />
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||||
import type { Block } from './blocks'
|
||||
import type { Transaction } from './transactions'
|
||||
import type { TransactionSummary } from './addresses'
|
||||
|
||||
export function getExplorerApiBase() {
|
||||
return (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080').replace(/\/$/, '')
|
||||
return resolveExplorerApiBase()
|
||||
}
|
||||
|
||||
export async function fetchBlockscoutJson<T>(path: string): Promise<T> {
|
||||
|
||||
@@ -11,7 +11,6 @@ function getApiKey(): string | null {
|
||||
}
|
||||
|
||||
export const apiClient = createApiClient(
|
||||
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
|
||||
undefined,
|
||||
getApiKey
|
||||
)
|
||||
|
||||
|
||||
61
frontend/src/services/api/config.ts
Normal file
61
frontend/src/services/api/config.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { getExplorerApiBase } from './blockscout'
|
||||
|
||||
export interface NetworksConfigChain {
|
||||
chainIdDecimal?: number
|
||||
chainName?: string
|
||||
shortName?: string
|
||||
}
|
||||
|
||||
export interface NetworksConfigResponse {
|
||||
defaultChainId?: number
|
||||
chains?: NetworksConfigChain[]
|
||||
}
|
||||
|
||||
export interface TokenListToken {
|
||||
chainId?: number
|
||||
symbol?: string
|
||||
address?: string
|
||||
name?: string
|
||||
decimals?: number
|
||||
logoURI?: string
|
||||
}
|
||||
|
||||
export interface TokenListResponse {
|
||||
tokens?: TokenListToken[]
|
||||
}
|
||||
|
||||
export interface CapabilitiesResponse {
|
||||
chainId?: number
|
||||
chainName?: string
|
||||
walletSupport?: {
|
||||
walletAddEthereumChain?: boolean
|
||||
walletWatchAsset?: boolean
|
||||
}
|
||||
http?: {
|
||||
supportedMethods?: string[]
|
||||
unsupportedMethods?: string[]
|
||||
}
|
||||
tracing?: {
|
||||
supportedMethods?: string[]
|
||||
unsupportedMethods?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${getExplorerApiBase()}${path}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
getNetworks: async (): Promise<NetworksConfigResponse> =>
|
||||
fetchJson<NetworksConfigResponse>('/api/config/networks'),
|
||||
|
||||
getTokenList: async (): Promise<TokenListResponse> =>
|
||||
fetchJson<TokenListResponse>('/api/config/token-list'),
|
||||
|
||||
getCapabilities: async (): Promise<CapabilitiesResponse> =>
|
||||
fetchJson<CapabilitiesResponse>('/api/config/capabilities'),
|
||||
}
|
||||
105
frontend/src/services/api/liquidity.ts
Normal file
105
frontend/src/services/api/liquidity.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { TokenListToken } from './config'
|
||||
import type { MissionControlLiquidityPool, RouteMatrixResponse, RouteMatrixRoute } from './routes'
|
||||
import type { PlannerCapabilitiesResponse, PlannerProviderCapability } from './planner'
|
||||
|
||||
export const featuredLiquiditySymbols = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cEURT', 'WETH10']
|
||||
|
||||
export interface FeaturedLiquidityToken {
|
||||
symbol: string
|
||||
address: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface AggregatedLiquidityPool extends MissionControlLiquidityPool {
|
||||
sourceSymbols: string[]
|
||||
}
|
||||
|
||||
export function selectFeaturedLiquidityTokens(tokens: TokenListToken[] = []): FeaturedLiquidityToken[] {
|
||||
const selected = new Map<string, FeaturedLiquidityToken>()
|
||||
|
||||
for (const symbol of featuredLiquiditySymbols) {
|
||||
const token = tokens.find(
|
||||
(entry) => entry.chainId === 138 && entry.symbol === symbol && typeof entry.address === 'string' && entry.address.trim().length > 0
|
||||
)
|
||||
|
||||
if (token?.address) {
|
||||
selected.set(symbol, {
|
||||
symbol,
|
||||
address: token.address,
|
||||
name: token.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(selected.values())
|
||||
}
|
||||
|
||||
export function aggregateLiquidityPools(
|
||||
records: Array<{ symbol: string; pools: MissionControlLiquidityPool[] }>
|
||||
): AggregatedLiquidityPool[] {
|
||||
const merged = new Map<string, AggregatedLiquidityPool>()
|
||||
|
||||
for (const record of records) {
|
||||
for (const pool of record.pools || []) {
|
||||
if (!pool.address) continue
|
||||
const key = pool.address.toLowerCase()
|
||||
const existing = merged.get(key)
|
||||
|
||||
if (existing) {
|
||||
if (!existing.sourceSymbols.includes(record.symbol)) {
|
||||
existing.sourceSymbols.push(record.symbol)
|
||||
}
|
||||
if ((pool.tvl || 0) > (existing.tvl || 0)) {
|
||||
existing.tvl = pool.tvl
|
||||
}
|
||||
} else {
|
||||
merged.set(key, {
|
||||
...pool,
|
||||
sourceSymbols: [record.symbol],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(merged.values()).sort((left, right) => {
|
||||
const tvlDelta = (right.tvl || 0) - (left.tvl || 0)
|
||||
if (tvlDelta !== 0) return tvlDelta
|
||||
return (left.address || '').localeCompare(right.address || '')
|
||||
})
|
||||
}
|
||||
|
||||
export function getRouteBackedPoolAddresses(routeMatrix: RouteMatrixResponse | null | undefined): string[] {
|
||||
const addresses = new Set<string>()
|
||||
|
||||
for (const route of routeMatrix?.liveRoutes || []) {
|
||||
for (const leg of route.legs || []) {
|
||||
if (leg.poolAddress) {
|
||||
addresses.add(leg.poolAddress.toLowerCase())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(addresses)
|
||||
}
|
||||
|
||||
export function getTopLiquidityRoutes(
|
||||
routeMatrix: RouteMatrixResponse | null | undefined,
|
||||
limit = 8
|
||||
): RouteMatrixRoute[] {
|
||||
const liveRoutes = routeMatrix?.liveRoutes || []
|
||||
return [...liveRoutes]
|
||||
.filter((route) => route.routeType === 'swap')
|
||||
.sort((left, right) => {
|
||||
const leftPools = (left.legs || []).filter((leg) => leg.poolAddress).length
|
||||
const rightPools = (right.legs || []).filter((leg) => leg.poolAddress).length
|
||||
if (leftPools !== rightPools) return rightPools - leftPools
|
||||
return (left.label || left.routeId).localeCompare(right.label || right.routeId)
|
||||
})
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
export function getLivePlannerProviders(
|
||||
capabilities: PlannerCapabilitiesResponse | null | undefined
|
||||
): PlannerProviderCapability[] {
|
||||
return (capabilities?.providers || []).filter((provider) => provider.live)
|
||||
}
|
||||
252
frontend/src/services/api/missionControl.ts
Normal file
252
frontend/src/services/api/missionControl.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { getExplorerApiBase } from './blockscout'
|
||||
|
||||
export interface MissionControlRelaySummary {
|
||||
text: string
|
||||
tone: 'normal' | 'warning' | 'danger'
|
||||
items: MissionControlRelayItemSummary[]
|
||||
}
|
||||
|
||||
export interface MissionControlRelayItemSummary {
|
||||
key: string
|
||||
label: string
|
||||
status: string
|
||||
text: string
|
||||
tone: 'normal' | 'warning' | 'danger'
|
||||
}
|
||||
|
||||
export interface MissionControlRelaySnapshot {
|
||||
status?: string
|
||||
service?: {
|
||||
profile?: string
|
||||
}
|
||||
source?: {
|
||||
chain_name?: string
|
||||
bridge_filter?: string
|
||||
}
|
||||
destination?: {
|
||||
chain_name?: string
|
||||
relay_bridge?: string
|
||||
relay_bridge_default?: string
|
||||
}
|
||||
queue?: {
|
||||
size?: number
|
||||
processed?: number
|
||||
failed?: number
|
||||
}
|
||||
last_source_poll?: {
|
||||
at?: string
|
||||
ok?: boolean
|
||||
logs_fetched?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MissionControlRelayProbe {
|
||||
ok?: boolean
|
||||
body?: MissionControlRelaySnapshot
|
||||
}
|
||||
|
||||
export interface MissionControlRelayPayload {
|
||||
url_probe?: MissionControlRelayProbe
|
||||
file_snapshot?: MissionControlRelaySnapshot
|
||||
file_snapshot_error?: string
|
||||
}
|
||||
|
||||
export interface MissionControlChainStatus {
|
||||
status?: string
|
||||
name?: string
|
||||
head_age_sec?: number
|
||||
latency_ms?: number
|
||||
block_number?: string
|
||||
}
|
||||
|
||||
export interface MissionControlBridgeStatusResponse {
|
||||
data?: {
|
||||
status?: string
|
||||
checked_at?: string
|
||||
chains?: Record<string, MissionControlChainStatus>
|
||||
ccip_relay?: MissionControlRelayPayload
|
||||
ccip_relays?: Record<string, MissionControlRelayPayload>
|
||||
}
|
||||
}
|
||||
|
||||
const missionControlRelayLabels: Record<string, string> = {
|
||||
mainnet: 'Mainnet',
|
||||
mainnet_weth: 'Mainnet WETH',
|
||||
mainnet_cw: 'Mainnet cW',
|
||||
bsc: 'BSC',
|
||||
avax: 'Avalanche',
|
||||
avalanche: 'Avalanche',
|
||||
avax_cw: 'Avalanche cW',
|
||||
avax_to_138: 'Avalanche -> 138',
|
||||
}
|
||||
|
||||
function getMissionControlStreamUrl(): string {
|
||||
return `${getExplorerApiBase()}/explorer-api/v1/mission-control/stream`
|
||||
}
|
||||
|
||||
function getMissionControlBridgeStatusUrl(): string {
|
||||
return `${getExplorerApiBase()}/explorer-api/v1/track1/bridge/status`
|
||||
}
|
||||
|
||||
function relativeAge(isoString?: string): string {
|
||||
if (!isoString) return ''
|
||||
const parsed = Date.parse(isoString)
|
||||
if (!Number.isFinite(parsed)) return ''
|
||||
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
export function summarizeMissionControlRelay(
|
||||
response: MissionControlBridgeStatusResponse | null | undefined
|
||||
): MissionControlRelaySummary | null {
|
||||
const relays = getMissionControlRelays(response)
|
||||
if (!relays) return null
|
||||
|
||||
const items = Object.entries(relays)
|
||||
.map(([key, relay]): MissionControlRelayItemSummary | null => {
|
||||
const label = getMissionControlRelayLabel(key)
|
||||
|
||||
if (relay.url_probe && relay.url_probe.ok === false) {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
status: 'down',
|
||||
text: `${label}: probe failed`,
|
||||
tone: 'danger',
|
||||
}
|
||||
}
|
||||
|
||||
if (relay.file_snapshot_error) {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
status: 'snapshot-error',
|
||||
text: `${label}: snapshot error`,
|
||||
tone: 'danger',
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = relay.url_probe?.body || relay.file_snapshot
|
||||
if (!snapshot) {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
status: 'configured',
|
||||
text: `${label}: probe configured`,
|
||||
tone: 'normal',
|
||||
}
|
||||
}
|
||||
|
||||
const status = String(snapshot.status || 'unknown').toLowerCase()
|
||||
const destination = snapshot.destination?.chain_name
|
||||
const queueSize = snapshot.queue?.size
|
||||
const pollAge = relativeAge(snapshot.last_source_poll?.at)
|
||||
|
||||
let text = `${label}: ${status}`
|
||||
if (destination) text += ` -> ${destination}`
|
||||
if (queueSize != null) text += ` · queue ${queueSize}`
|
||||
if (pollAge) text += ` · polled ${pollAge}`
|
||||
|
||||
let tone: MissionControlRelaySummary['tone'] = 'normal'
|
||||
if (['paused', 'starting'].includes(status)) {
|
||||
tone = 'warning'
|
||||
}
|
||||
if (['degraded', 'stale', 'stopped', 'down'].includes(status)) {
|
||||
tone = 'danger'
|
||||
}
|
||||
|
||||
return { key, label, status, text, tone }
|
||||
})
|
||||
.filter((item): item is MissionControlRelayItemSummary => item !== null)
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
const tone: MissionControlRelaySummary['tone'] = items.some((item) => item.tone === 'danger')
|
||||
? 'danger'
|
||||
: items.some((item) => item.tone === 'warning')
|
||||
? 'warning'
|
||||
: 'normal'
|
||||
|
||||
const text =
|
||||
items.length === 1
|
||||
? items[0].text
|
||||
: tone === 'normal'
|
||||
? `${items.length} relay lanes operational`
|
||||
: `Relay lanes need attention`
|
||||
|
||||
return { text, tone, items }
|
||||
}
|
||||
|
||||
export function getMissionControlRelays(
|
||||
response: MissionControlBridgeStatusResponse | null | undefined
|
||||
): Record<string, MissionControlRelayPayload> | null {
|
||||
return response?.data?.ccip_relays && Object.keys(response.data.ccip_relays).length > 0
|
||||
? response.data.ccip_relays
|
||||
: response?.data?.ccip_relay
|
||||
? { mainnet: response.data.ccip_relay }
|
||||
: null
|
||||
}
|
||||
|
||||
export function getMissionControlRelayLabel(key: string): string {
|
||||
return missionControlRelayLabels[key] || key.toUpperCase()
|
||||
}
|
||||
|
||||
export const missionControlApi = {
|
||||
getBridgeStatus: async (): Promise<MissionControlBridgeStatusResponse> => {
|
||||
const response = await fetch(getMissionControlBridgeStatusUrl())
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as MissionControlBridgeStatusResponse
|
||||
},
|
||||
|
||||
getRelaySummary: async (): Promise<MissionControlRelaySummary | null> => {
|
||||
const json = await missionControlApi.getBridgeStatus()
|
||||
return summarizeMissionControlRelay(json)
|
||||
},
|
||||
|
||||
subscribeBridgeStatus: (
|
||||
onStatus: (status: MissionControlBridgeStatusResponse) => void,
|
||||
onError?: (error: unknown) => void
|
||||
): (() => void) => {
|
||||
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') {
|
||||
onError?.(new Error('EventSource is not available in this environment'))
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const eventSource = new window.EventSource(getMissionControlStreamUrl())
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as MissionControlBridgeStatusResponse
|
||||
onStatus(payload)
|
||||
} catch (error) {
|
||||
onError?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
onError?.(new Error('Mission-control live stream connection lost'))
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
}
|
||||
},
|
||||
|
||||
subscribeRelaySummary: (
|
||||
onSummary: (summary: MissionControlRelaySummary | null) => void,
|
||||
onError?: (error: unknown) => void
|
||||
): (() => void) => {
|
||||
return missionControlApi.subscribeBridgeStatus(
|
||||
(payload) => {
|
||||
onSummary(summarizeMissionControlRelay(payload))
|
||||
},
|
||||
onError
|
||||
)
|
||||
},
|
||||
}
|
||||
62
frontend/src/services/api/planner.ts
Normal file
62
frontend/src/services/api/planner.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { getExplorerApiBase } from './blockscout'
|
||||
|
||||
export interface PlannerProviderPair {
|
||||
tokenInSymbol?: string
|
||||
tokenOutSymbol?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface PlannerProviderCapability {
|
||||
chainId?: number
|
||||
provider?: string
|
||||
executionMode?: string
|
||||
live?: boolean
|
||||
quoteLive?: boolean
|
||||
executionLive?: boolean
|
||||
supportedLegTypes?: string[]
|
||||
pairs?: PlannerProviderPair[]
|
||||
notes?: string[]
|
||||
}
|
||||
|
||||
export interface PlannerCapabilitiesResponse {
|
||||
providers?: PlannerProviderCapability[]
|
||||
}
|
||||
|
||||
export interface InternalExecutionPlanResponse {
|
||||
plannerResponse?: {
|
||||
decision?: string
|
||||
steps?: unknown[]
|
||||
}
|
||||
execution?: {
|
||||
contractAddress?: string
|
||||
}
|
||||
}
|
||||
|
||||
const plannerBase = `${getExplorerApiBase()}/token-aggregation/api/v2`
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, init)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export const plannerApi = {
|
||||
getCapabilities: async (chainId = 138): Promise<PlannerCapabilitiesResponse> =>
|
||||
fetchJson<PlannerCapabilitiesResponse>(`${plannerBase}/providers/capabilities?chainId=${chainId}`),
|
||||
|
||||
getInternalExecutionPlan: async (): Promise<InternalExecutionPlanResponse> =>
|
||||
fetchJson<InternalExecutionPlanResponse>(`${plannerBase}/routes/internal-execution-plan`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sourceChainId: 138,
|
||||
tokenIn: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
tokenOut: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
|
||||
amountIn: '100000000000000000',
|
||||
}),
|
||||
}),
|
||||
}
|
||||
95
frontend/src/services/api/routes.ts
Normal file
95
frontend/src/services/api/routes.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { getExplorerApiBase } from './blockscout'
|
||||
|
||||
export interface RouteMatrixLeg {
|
||||
protocol?: string
|
||||
executor?: string
|
||||
poolAddress?: string
|
||||
}
|
||||
|
||||
export interface RouteMatrixRoute {
|
||||
routeId: string
|
||||
status?: string
|
||||
label?: string
|
||||
routeType?: string
|
||||
fromChainId?: number
|
||||
toChainId?: number
|
||||
tokenInSymbol?: string
|
||||
tokenOutSymbol?: string
|
||||
assetSymbol?: string
|
||||
hopCount?: number
|
||||
aggregatorFamilies?: string[]
|
||||
notes?: string[]
|
||||
reason?: string
|
||||
tokenInSymbols?: string[]
|
||||
legs?: RouteMatrixLeg[]
|
||||
}
|
||||
|
||||
export interface RouteMatrixCounts {
|
||||
liveSwapRoutes?: number
|
||||
liveBridgeRoutes?: number
|
||||
blockedOrPlannedRoutes?: number
|
||||
filteredLiveRoutes?: number
|
||||
}
|
||||
|
||||
export interface RouteMatrixResponse {
|
||||
generatedAt?: string
|
||||
updated?: string
|
||||
version?: string
|
||||
homeChainId?: number
|
||||
liveRoutes?: RouteMatrixRoute[]
|
||||
blockedOrPlannedRoutes?: RouteMatrixRoute[]
|
||||
counts?: RouteMatrixCounts
|
||||
}
|
||||
|
||||
export interface ExplorerNetwork {
|
||||
chainIdDecimal?: number
|
||||
chainName?: string
|
||||
shortName?: string
|
||||
}
|
||||
|
||||
export interface NetworksResponse {
|
||||
version?: string
|
||||
source?: string
|
||||
lastModified?: string
|
||||
networks?: ExplorerNetwork[]
|
||||
}
|
||||
|
||||
export interface MissionControlLiquidityPool {
|
||||
address: string
|
||||
dex?: string
|
||||
token0?: {
|
||||
symbol?: string
|
||||
}
|
||||
token1?: {
|
||||
symbol?: string
|
||||
}
|
||||
tvl?: number
|
||||
}
|
||||
|
||||
export interface MissionControlLiquidityPoolsResponse {
|
||||
pools?: MissionControlLiquidityPool[]
|
||||
}
|
||||
|
||||
const tokenAggregationBase = `${getExplorerApiBase()}/token-aggregation/api/v1`
|
||||
const missionControlBase = `${getExplorerApiBase()}/explorer-api/v1/mission-control`
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export const routesApi = {
|
||||
getNetworks: async (): Promise<NetworksResponse> =>
|
||||
fetchJson<NetworksResponse>(`${tokenAggregationBase}/networks`),
|
||||
|
||||
getRouteMatrix: async (): Promise<RouteMatrixResponse> =>
|
||||
fetchJson<RouteMatrixResponse>(`${tokenAggregationBase}/routes/matrix?includeNonLive=true`),
|
||||
|
||||
getTokenPools: async (tokenAddress: string): Promise<MissionControlLiquidityPoolsResponse> =>
|
||||
fetchJson<MissionControlLiquidityPoolsResponse>(
|
||||
`${missionControlBase}/liquidity/token/${tokenAddress}/pools`
|
||||
),
|
||||
}
|
||||
35
frontend/src/services/api/stats.test.ts
Normal file
35
frontend/src/services/api/stats.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeExplorerStats } from './stats'
|
||||
|
||||
describe('normalizeExplorerStats', () => {
|
||||
it('normalizes the local explorer stats shape', () => {
|
||||
expect(
|
||||
normalizeExplorerStats({
|
||||
total_blocks: 12,
|
||||
total_transactions: 34,
|
||||
total_addresses: 56,
|
||||
latest_block: 78,
|
||||
})
|
||||
).toEqual({
|
||||
total_blocks: 12,
|
||||
total_transactions: 34,
|
||||
total_addresses: 56,
|
||||
latest_block: 78,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes the public Blockscout stats shape with string counts and no latest block', () => {
|
||||
expect(
|
||||
normalizeExplorerStats({
|
||||
total_blocks: '2784760',
|
||||
total_transactions: '15788',
|
||||
total_addresses: '376',
|
||||
})
|
||||
).toEqual({
|
||||
total_blocks: 2784760,
|
||||
total_transactions: 15788,
|
||||
total_addresses: 376,
|
||||
latest_block: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
46
frontend/src/services/api/stats.ts
Normal file
46
frontend/src/services/api/stats.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { getExplorerApiBase } from './blockscout'
|
||||
|
||||
export interface ExplorerStats {
|
||||
total_blocks: number
|
||||
total_transactions: number
|
||||
total_addresses: number
|
||||
latest_block: number | null
|
||||
}
|
||||
|
||||
interface RawExplorerStats {
|
||||
total_blocks?: number | string | null
|
||||
total_transactions?: number | string | null
|
||||
total_addresses?: number | string | null
|
||||
latest_block?: number | string | null
|
||||
}
|
||||
|
||||
function toNumber(value: number | string | null | undefined): number {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string' && value.trim() !== '') return Number(value)
|
||||
return 0
|
||||
}
|
||||
|
||||
export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
|
||||
const latestBlockValue = raw.latest_block
|
||||
|
||||
return {
|
||||
total_blocks: toNumber(raw.total_blocks),
|
||||
total_transactions: toNumber(raw.total_transactions),
|
||||
total_addresses: toNumber(raw.total_addresses),
|
||||
latest_block:
|
||||
latestBlockValue == null || latestBlockValue === ''
|
||||
? null
|
||||
: toNumber(latestBlockValue),
|
||||
}
|
||||
}
|
||||
|
||||
export const statsApi = {
|
||||
get: async (): Promise<ExplorerStats> => {
|
||||
const response = await fetch(`${getExplorerApiBase()}/api/v2/stats`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const json = (await response.json()) as RawExplorerStats
|
||||
return normalizeExplorerStats(json)
|
||||
},
|
||||
}
|
||||
76
frontend/src/utils/dashboard.test.ts
Normal file
76
frontend/src/utils/dashboard.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Block } from '@/services/api/blocks'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
import { loadDashboardData } from './dashboard'
|
||||
|
||||
const sampleStats: ExplorerStats = {
|
||||
total_blocks: 12,
|
||||
total_transactions: 34,
|
||||
total_addresses: 56,
|
||||
latest_block: 78,
|
||||
}
|
||||
|
||||
const sampleBlocks: Block[] = [
|
||||
{
|
||||
chain_id: 138,
|
||||
number: 123,
|
||||
hash: '0xabc',
|
||||
timestamp: '2026-04-04T00:00:00.000Z',
|
||||
miner: '0xdef',
|
||||
transaction_count: 4,
|
||||
gas_used: 21000,
|
||||
gas_limit: 30000000,
|
||||
},
|
||||
]
|
||||
|
||||
describe('loadDashboardData', () => {
|
||||
it('returns both stats and recent blocks when both loaders succeed', async () => {
|
||||
const result = await loadDashboardData({
|
||||
loadStats: async () => sampleStats,
|
||||
loadRecentBlocks: async () => sampleBlocks,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: sampleStats,
|
||||
recentBlocks: sampleBlocks,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves recent blocks when stats loading fails', async () => {
|
||||
const onError = vi.fn()
|
||||
|
||||
const result = await loadDashboardData({
|
||||
loadStats: async () => {
|
||||
throw new Error('stats unavailable')
|
||||
},
|
||||
loadRecentBlocks: async () => sampleBlocks,
|
||||
onError,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: null,
|
||||
recentBlocks: sampleBlocks,
|
||||
})
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(onError).toHaveBeenCalledWith('stats', expect.any(Error))
|
||||
})
|
||||
|
||||
it('preserves stats when recent blocks loading fails', async () => {
|
||||
const onError = vi.fn()
|
||||
|
||||
const result = await loadDashboardData({
|
||||
loadStats: async () => sampleStats,
|
||||
loadRecentBlocks: async () => {
|
||||
throw new Error('blocks unavailable')
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: sampleStats,
|
||||
recentBlocks: [],
|
||||
})
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(onError).toHaveBeenCalledWith('blocks', expect.any(Error))
|
||||
})
|
||||
})
|
||||
37
frontend/src/utils/dashboard.ts
Normal file
37
frontend/src/utils/dashboard.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Block } from '@/services/api/blocks'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
|
||||
export interface DashboardData {
|
||||
stats: ExplorerStats | null
|
||||
recentBlocks: Block[]
|
||||
}
|
||||
|
||||
export interface DashboardLoaders {
|
||||
loadStats: () => Promise<ExplorerStats>
|
||||
loadRecentBlocks: () => Promise<Block[]>
|
||||
onError?: (scope: 'stats' | 'blocks', error: unknown) => void
|
||||
}
|
||||
|
||||
export async function loadDashboardData({
|
||||
loadStats,
|
||||
loadRecentBlocks,
|
||||
onError,
|
||||
}: DashboardLoaders): Promise<DashboardData> {
|
||||
const [statsResult, recentBlocksResult] = await Promise.allSettled([
|
||||
loadStats(),
|
||||
loadRecentBlocks(),
|
||||
])
|
||||
|
||||
if (statsResult.status === 'rejected') {
|
||||
onError?.('stats', statsResult.reason)
|
||||
}
|
||||
|
||||
if (recentBlocksResult.status === 'rejected') {
|
||||
onError?.('blocks', recentBlocksResult.reason)
|
||||
}
|
||||
|
||||
return {
|
||||
stats: statsResult.status === 'fulfilled' ? statsResult.value : null,
|
||||
recentBlocks: recentBlocksResult.status === 'fulfilled' ? recentBlocksResult.value : [],
|
||||
}
|
||||
}
|
||||
15
frontend/src/utils/format.test.ts
Normal file
15
frontend/src/utils/format.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatWeiAsEth } from './format'
|
||||
|
||||
describe('formatWeiAsEth', () => {
|
||||
it('formats zero and whole ETH values without losing precision', () => {
|
||||
expect(formatWeiAsEth('0')).toBe('0 ETH')
|
||||
expect(formatWeiAsEth('1000000000000000000')).toBe('1 ETH')
|
||||
expect(formatWeiAsEth('123450000000000000000')).toBe('123.45 ETH')
|
||||
})
|
||||
|
||||
it('truncates fractional ETH safely using bigint math', () => {
|
||||
expect(formatWeiAsEth('123456789123456789')).toBe('0.1234 ETH')
|
||||
expect(formatWeiAsEth('9007199254740993')).toBe('0.009 ETH')
|
||||
})
|
||||
})
|
||||
30
frontend/src/utils/format.ts
Normal file
30
frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
const WEI_DECIMALS = 18n
|
||||
|
||||
export function formatWeiAsEth(value: string, fractionDigits = 4): string {
|
||||
try {
|
||||
const normalizedDigits = Math.max(0, Math.min(18, fractionDigits))
|
||||
const wei = BigInt(value)
|
||||
const divisor = 10n ** WEI_DECIMALS
|
||||
const whole = wei / divisor
|
||||
const fraction = wei % divisor
|
||||
|
||||
if (normalizedDigits === 0) {
|
||||
return `${whole.toString()} ETH`
|
||||
}
|
||||
|
||||
const scale = 10n ** (WEI_DECIMALS - BigInt(normalizedDigits))
|
||||
const truncatedFraction = fraction / scale
|
||||
const paddedFraction = truncatedFraction
|
||||
.toString()
|
||||
.padStart(normalizedDigits, '0')
|
||||
.replace(/0+$/, '')
|
||||
|
||||
if (!paddedFraction) {
|
||||
return `${whole.toString()} ETH`
|
||||
}
|
||||
|
||||
return `${whole.toString()}.${paddedFraction} ETH`
|
||||
} catch {
|
||||
return '0 ETH'
|
||||
}
|
||||
}
|
||||
38
frontend/src/utils/search.test.ts
Normal file
38
frontend/src/utils/search.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { inferDirectSearchTarget } from './search'
|
||||
|
||||
describe('inferDirectSearchTarget', () => {
|
||||
it('detects addresses and normalizes the prefix', () => {
|
||||
expect(
|
||||
inferDirectSearchTarget('0X1234567890123456789012345678901234567890'),
|
||||
).toEqual({
|
||||
kind: 'address',
|
||||
href: '/addresses/0x1234567890123456789012345678901234567890',
|
||||
label: 'Open address',
|
||||
})
|
||||
})
|
||||
|
||||
it('detects transaction hashes', () => {
|
||||
expect(
|
||||
inferDirectSearchTarget(
|
||||
'0x1234567890123456789012345678901234567890123456789012345678901234',
|
||||
),
|
||||
).toEqual({
|
||||
kind: 'transaction',
|
||||
href: '/transactions/0x1234567890123456789012345678901234567890123456789012345678901234',
|
||||
label: 'Open transaction',
|
||||
})
|
||||
})
|
||||
|
||||
it('detects block numbers', () => {
|
||||
expect(inferDirectSearchTarget(' 12345 ')).toEqual({
|
||||
kind: 'block',
|
||||
href: '/blocks/12345',
|
||||
label: 'Open block',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for generic text', () => {
|
||||
expect(inferDirectSearchTarget('cUSDT')).toBeNull()
|
||||
})
|
||||
})
|
||||
41
frontend/src/utils/search.ts
Normal file
41
frontend/src/utils/search.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type DirectSearchTarget =
|
||||
| { kind: 'address'; href: string; label: string }
|
||||
| { kind: 'transaction'; href: string; label: string }
|
||||
| { kind: 'block'; href: string; label: string }
|
||||
|
||||
const addressPattern = /^0x[a-f0-9]{40}$/i
|
||||
const transactionHashPattern = /^0x[a-f0-9]{64}$/i
|
||||
const blockNumberPattern = /^\d+$/
|
||||
|
||||
export function inferDirectSearchTarget(query: string): DirectSearchTarget | null {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (addressPattern.test(trimmed)) {
|
||||
return {
|
||||
kind: 'address',
|
||||
href: `/addresses/0x${trimmed.slice(2)}`,
|
||||
label: 'Open address',
|
||||
}
|
||||
}
|
||||
|
||||
if (transactionHashPattern.test(trimmed)) {
|
||||
return {
|
||||
kind: 'transaction',
|
||||
href: `/transactions/0x${trimmed.slice(2)}`,
|
||||
label: 'Open transaction',
|
||||
}
|
||||
}
|
||||
|
||||
if (blockNumberPattern.test(trimmed)) {
|
||||
return {
|
||||
kind: 'block',
|
||||
href: `/blocks/${trimmed}`,
|
||||
label: 'Open block',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
42
frontend/src/utils/watchlist.test.ts
Normal file
42
frontend/src/utils/watchlist.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isWatchlistEntry,
|
||||
normalizeWatchlistAddress,
|
||||
parseStoredWatchlist,
|
||||
sanitizeWatchlistEntries,
|
||||
} from './watchlist'
|
||||
|
||||
describe('watchlist utils', () => {
|
||||
it('normalizes only valid addresses', () => {
|
||||
expect(normalizeWatchlistAddress(' 0x1234567890123456789012345678901234567890 ')).toBe(
|
||||
'0x1234567890123456789012345678901234567890',
|
||||
)
|
||||
expect(normalizeWatchlistAddress('not-an-address')).toBe('')
|
||||
})
|
||||
|
||||
it('filters invalid entries and deduplicates case-insensitively', () => {
|
||||
expect(
|
||||
sanitizeWatchlistEntries([
|
||||
'0x1234567890123456789012345678901234567890',
|
||||
'0x1234567890123456789012345678901234567890',
|
||||
'0x1234567890123456789012345678901234567890'.toUpperCase(),
|
||||
'bad',
|
||||
42,
|
||||
]),
|
||||
).toEqual(['0x1234567890123456789012345678901234567890'])
|
||||
})
|
||||
|
||||
it('parses stored JSON safely', () => {
|
||||
expect(parseStoredWatchlist('["0x1234567890123456789012345678901234567890"]')).toEqual([
|
||||
'0x1234567890123456789012345678901234567890',
|
||||
])
|
||||
expect(parseStoredWatchlist('not json')).toEqual([])
|
||||
})
|
||||
|
||||
it('matches saved addresses case-insensitively', () => {
|
||||
const entries = ['0x1234567890123456789012345678901234567890']
|
||||
expect(
|
||||
isWatchlistEntry(entries, '0x1234567890123456789012345678901234567890'.toUpperCase()),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
73
frontend/src/utils/watchlist.ts
Normal file
73
frontend/src/utils/watchlist.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export const WATCHLIST_STORAGE_KEY = 'explorerWatchlist'
|
||||
|
||||
const addressPattern = /^0x[a-f0-9]{40}$/i
|
||||
|
||||
export function normalizeWatchlistAddress(value: string) {
|
||||
const trimmed = value.trim()
|
||||
return addressPattern.test(trimmed) ? `0x${trimmed.slice(2)}` : ''
|
||||
}
|
||||
|
||||
export function sanitizeWatchlistEntries(input: unknown) {
|
||||
if (!Array.isArray(input)) {
|
||||
return [] as string[]
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const next: string[] = []
|
||||
|
||||
for (const entry of input) {
|
||||
if (typeof entry !== 'string') {
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = normalizeWatchlistAddress(entry)
|
||||
if (!normalized) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = normalized.toLowerCase()
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(key)
|
||||
next.push(normalized)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function parseStoredWatchlist(raw: string | null) {
|
||||
if (!raw) {
|
||||
return [] as string[]
|
||||
}
|
||||
|
||||
try {
|
||||
return sanitizeWatchlistEntries(JSON.parse(raw))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function readWatchlistFromStorage(storage: Pick<Storage, 'getItem'>) {
|
||||
return parseStoredWatchlist(storage.getItem(WATCHLIST_STORAGE_KEY))
|
||||
}
|
||||
|
||||
export function writeWatchlistToStorage(
|
||||
storage: Pick<Storage, 'setItem'>,
|
||||
entries: string[],
|
||||
) {
|
||||
storage.setItem(
|
||||
WATCHLIST_STORAGE_KEY,
|
||||
JSON.stringify(sanitizeWatchlistEntries(entries)),
|
||||
)
|
||||
}
|
||||
|
||||
export function isWatchlistEntry(entries: string[], address: string) {
|
||||
const normalized = normalizeWatchlistAddress(address)
|
||||
if (!normalized) {
|
||||
return false
|
||||
}
|
||||
|
||||
return entries.some((entry) => entry.toLowerCase() === normalized.toLowerCase())
|
||||
}
|
||||
Reference in New Issue
Block a user