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,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>
|
||||
|
||||
Reference in New Issue
Block a user