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,7 +1,7 @@
|
||||
# Explorer API base URL (used for blocks, transactions, addresses, and /api/config/token-list).
|
||||
# Production at https://explorer.d-bis.org: leave empty or set to https://explorer.d-bis.org (same origin).
|
||||
# Local dev: http://localhost:8080 (or your API port).
|
||||
NEXT_PUBLIC_API_URL=https://explorer.d-bis.org
|
||||
# Production behind the nginx proxy: leave empty to use same-origin automatically.
|
||||
# Local dev against a standalone backend: http://localhost:8080 (or your API port).
|
||||
NEXT_PUBLIC_API_URL=
|
||||
|
||||
# Chain ID for the explorer (default: Chain 138 - DeFi Oracle Meta Mainnet).
|
||||
NEXT_PUBLIC_CHAIN_ID=138
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
NEXT_PUBLIC_API_URL=https://explorer.d-bis.org
|
||||
NEXT_PUBLIC_API_URL=
|
||||
NEXT_PUBLIC_CHAIN_ID=138
|
||||
|
||||
31
frontend/libs/frontend-api-client/api-base.test.ts
Normal file
31
frontend/libs/frontend-api-client/api-base.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveExplorerApiBase } from './api-base'
|
||||
|
||||
describe('resolveExplorerApiBase', () => {
|
||||
it('prefers an explicit env value when present', () => {
|
||||
expect(
|
||||
resolveExplorerApiBase({
|
||||
envValue: 'https://explorer.d-bis.org/',
|
||||
browserOrigin: 'http://127.0.0.1:3000',
|
||||
})
|
||||
).toBe('https://explorer.d-bis.org')
|
||||
})
|
||||
|
||||
it('falls back to same-origin in the browser when env is empty', () => {
|
||||
expect(
|
||||
resolveExplorerApiBase({
|
||||
envValue: '',
|
||||
browserOrigin: 'http://127.0.0.1:3000/',
|
||||
})
|
||||
).toBe('http://127.0.0.1:3000')
|
||||
})
|
||||
|
||||
it('falls back to the local backend on the server when no other base is available', () => {
|
||||
expect(
|
||||
resolveExplorerApiBase({
|
||||
envValue: '',
|
||||
browserOrigin: '',
|
||||
})
|
||||
).toBe('http://localhost:8080')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { resolveExplorerApiBase } from './api-base'
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T
|
||||
@@ -21,9 +22,9 @@ export interface ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiClient(baseURL: string = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080', getApiKey?: () => string | null) {
|
||||
export function createApiClient(baseURL?: string, getApiKey?: () => string | null) {
|
||||
const client = axios.create({
|
||||
baseURL,
|
||||
baseURL: baseURL || resolveExplorerApiBase(),
|
||||
timeout: 30000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
@@ -25,24 +25,51 @@ export function Address({
|
||||
: address
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(address)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
try {
|
||||
await navigator.clipboard.writeText(address)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
setCopied(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('flex items-center gap-2', className)}>
|
||||
<span className="font-mono text-sm">{displayAddress}</span>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex min-w-0 items-start gap-2',
|
||||
truncate ? 'flex-nowrap' : 'flex-wrap',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'min-w-0 font-mono text-sm leading-6 text-gray-900 dark:text-gray-100',
|
||||
truncate ? 'truncate' : 'break-all'
|
||||
)}
|
||||
>
|
||||
{displayAddress}
|
||||
</span>
|
||||
{showCopy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
className="shrink-0 rounded-md p-1 text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
title="Copy address"
|
||||
aria-label="Copy address"
|
||||
>
|
||||
{copied ? '✓' : '📋'}
|
||||
{copied ? (
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden>
|
||||
<path fillRule="evenodd" d="M16.704 5.29a1 1 0 0 1 .006 1.414l-7.25 7.313a1 1 0 0 1-1.42 0L4.79 10.766a1 1 0 1 1 1.414-1.414l2.546 2.546 6.544-6.602a1 1 0 0 1 1.41-.006Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden>
|
||||
<path d="M6 2.75A2.25 2.25 0 0 0 3.75 5v8A2.25 2.25 0 0 0 6 15.25h1.25V14H6A1 1 0 0 1 5 13V5a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v1.25h1.25V5A2.25 2.25 0 0 0 11 2.75H6Z" />
|
||||
<path d="M9 6.75A2.25 2.25 0 0 0 6.75 9v6A2.25 2.25 0 0 0 9 17.25h5A2.25 2.25 0 0 0 16.25 15V9A2.25 2.25 0 0 0 14 6.75H9Zm0 1.25h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export function Button({
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
'font-medium rounded-lg transition-colors',
|
||||
'font-medium rounded-lg transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
'bg-primary-600 text-white hover:bg-primary-700': variant === 'primary',
|
||||
'bg-gray-200 text-gray-900 hover:bg-gray-300': variant === 'secondary',
|
||||
@@ -34,4 +34,3 @@ export function Button({
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ export function Card({ children, className, title }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-white dark:bg-gray-800 rounded-lg shadow-md p-6',
|
||||
'rounded-xl bg-white p-4 shadow-md dark:bg-gray-800 sm:p-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-white sm:mb-4 sm:text-xl">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
@@ -24,4 +24,3 @@ export function Card({ children, className, title }: CardProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,48 +11,91 @@ interface TableProps<T> {
|
||||
columns: Column<T>[]
|
||||
data: T[]
|
||||
className?: string
|
||||
emptyMessage?: 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>) {
|
||||
export function Table<T>({
|
||||
columns,
|
||||
data,
|
||||
className,
|
||||
emptyMessage = 'No data available right now.',
|
||||
keyExtractor,
|
||||
}: TableProps<T>) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-xl border border-dashed border-gray-300 bg-white px-4 py-6 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<div className={clsx('space-y-3', className)}>
|
||||
<div className="grid gap-3 md:hidden">
|
||||
{data.map((row, rowIndex) => (
|
||||
<div
|
||||
key={keyExtractor ? keyExtractor(row) : rowIndex}
|
||||
className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<dl className="space-y-3">
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
<div key={colIndex} className="space-y-1">
|
||||
<dt className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{column.header}
|
||||
</dt>
|
||||
<dd className={clsx('min-w-0 text-sm text-gray-900 dark:text-gray-100', column.className)}>
|
||||
{column.accessor(row)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<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-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100',
|
||||
'px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 lg:px-6',
|
||||
column.className
|
||||
)}
|
||||
>
|
||||
{column.accessor(row)}
|
||||
</td>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
{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-4 py-4 align-top text-sm text-gray-900 dark:text-gray-100 lg:px-6',
|
||||
column.className
|
||||
)}
|
||||
>
|
||||
{column.accessor(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ const nextConfig = {
|
||||
output: 'standalone',
|
||||
// If you see a workspace lockfile warning: align on one package manager (npm or pnpm) in frontend, or ignore for dev/build.
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
|
||||
NEXT_PUBLIC_CHAIN_ID: process.env.NEXT_PUBLIC_CHAIN_ID || '138',
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? '',
|
||||
NEXT_PUBLIC_CHAIN_ID: process.env.NEXT_PUBLIC_CHAIN_ID ?? '138',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"build": "next build",
|
||||
"build:check": "npm run lint && npm run type-check && npm run build",
|
||||
"smoke:routes": "node ./scripts/smoke-routes.mjs",
|
||||
"start": "PORT=${PORT:-3000} node .next/standalone/server.js",
|
||||
"start": "PORT=${PORT:-3000} node ./scripts/start-standalone.mjs",
|
||||
"start:next": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit -p tsconfig.check.json",
|
||||
|
||||
696
frontend/public/chain138-command-center.html
Normal file
696
frontend/public/chain138-command-center.html
Normal file
@@ -0,0 +1,696 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Chain 138 — Visual Command Center</title>
|
||||
<!-- Mermaid: local copy (vendor via explorer-monorepo/scripts/vendor-mermaid-for-command-center.sh). CDN fallback: jsdelivr mermaid@10 -->
|
||||
<script src="/thirdparty/mermaid.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0f14;
|
||||
--panel: #0f172a;
|
||||
--header: #111827;
|
||||
--border: #1f2937;
|
||||
--text: #e6edf3;
|
||||
--muted: #94a3b8;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
header {
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--header);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
header p {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted);
|
||||
max-width: 52rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(17, 24, 39, 0.85);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.tab {
|
||||
padding: 0.5rem 0.85rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
}
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--border);
|
||||
}
|
||||
.tab.active {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
.toolbar a.back {
|
||||
margin-left: auto;
|
||||
font-size: 0.8125rem;
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
.toolbar a.back:hover { text-decoration: underline; }
|
||||
.content {
|
||||
display: none;
|
||||
padding: 1.25rem;
|
||||
max-width: 120rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.content.active { display: block; }
|
||||
.panel-desc {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1rem;
|
||||
max-width: 56rem;
|
||||
}
|
||||
.mermaid-wrap {
|
||||
background: var(--panel);
|
||||
padding: 1.25rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.mermaid-wrap h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.mermaid-wrap + .mermaid-wrap { margin-top: 0.5rem; }
|
||||
footer {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
footer code { color: #a5b4fc; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>Chain 138 — deployment and liquidity topology</h1>
|
||||
<p>Operator-style view of the architecture in <code>docs/02-architecture/SMOM_DBIS_138_FULL_DEPLOYMENT_FLOW_MAP.md</code>. Diagrams are informational only; contract addresses live in explorer config and repo references. The live Mission Control visual surfaces remain in the main explorer SPA. Deep links: <code>?tab=mission-control</code> or numeric <code>?tab=0</code>–<code>8</code> (slug per tab).</p>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="tabs" role="tablist" aria-label="Topology panels">
|
||||
<button type="button" id="tab-0" class="tab active" role="tab" aria-selected="true" aria-controls="panel-0" data-tab="0" tabindex="0">Master map</button>
|
||||
<button type="button" id="tab-1" class="tab" role="tab" aria-selected="false" aria-controls="panel-1" data-tab="1" tabindex="-1">Network</button>
|
||||
<button type="button" id="tab-2" class="tab" role="tab" aria-selected="false" aria-controls="panel-2" data-tab="2" tabindex="-1">Stack</button>
|
||||
<button type="button" id="tab-3" class="tab" role="tab" aria-selected="false" aria-controls="panel-3" data-tab="3" tabindex="-1">Flows</button>
|
||||
<button type="button" id="tab-4" class="tab" role="tab" aria-selected="false" aria-controls="panel-4" data-tab="4" tabindex="-1">Cross-chain</button>
|
||||
<button type="button" id="tab-5" class="tab" role="tab" aria-selected="false" aria-controls="panel-5" data-tab="5" tabindex="-1">Public cW</button>
|
||||
<button type="button" id="tab-6" class="tab" role="tab" aria-selected="false" aria-controls="panel-6" data-tab="6" tabindex="-1">Off-chain</button>
|
||||
<button type="button" id="tab-7" class="tab" role="tab" aria-selected="false" aria-controls="panel-7" data-tab="7" tabindex="-1">Integrations</button>
|
||||
<button type="button" id="tab-8" class="tab" role="tab" aria-selected="false" aria-controls="panel-8" data-tab="8" tabindex="-1">Mission Control</button>
|
||||
</div>
|
||||
<a class="back" href="/more">Back to More</a>
|
||||
</div>
|
||||
|
||||
<!-- 0 Master -->
|
||||
<div class="content active" id="panel-0" role="tabpanel" aria-labelledby="tab-0">
|
||||
<p class="panel-desc">Hub, leaf endings, CCIP destinations, Alltra, the dedicated Avalanche cW corridor, the public cW mesh, and pending programs. Mainnet cW mint corridors and the optional TRUU rail are summarized under the Ethereum anchor.</p>
|
||||
<div class="mermaid-wrap"><div class="mermaid" id="g-master">
|
||||
flowchart TB
|
||||
subgraph LEAF_INGRESS["Leaves — access to 138"]
|
||||
WU[Wallets · MetaMask Snaps · Ledger · Chainlist · SDKs · ethers.js]
|
||||
OPS[Operators · Foundry scripts · relay · systemd · deploy hooks]
|
||||
RPCPUB[Public RPC FQDNs · thirdweb mirrors]
|
||||
FB[Fireblocks Web3 RPC]
|
||||
end
|
||||
|
||||
subgraph LEAF_EDGE["Leaves — services that index or front 138"]
|
||||
EXP[Explorer · Blockscout · token-aggregation]
|
||||
INFO[info.defi-oracle.io]
|
||||
DAPP[dapp.d-bis.org bridge UI]
|
||||
DBIS[dbis-api Core hosts]
|
||||
X402[x402 payment API]
|
||||
MCP[MCP PMM controller]
|
||||
end
|
||||
|
||||
subgraph HUB["CHAIN 138 — origin hub"]
|
||||
C138["Besu EVM · tokens core · DODO PMM V2/V3 · RouterV2 · UniV3 / Balancer / Curve / 1inch pilots · CCIP bridges + router · AlltraAdapter · BridgeVault · ISO channels · mirror reserve vault settlement · Lockbox · Truth / Tron / Solana adapters"]
|
||||
end
|
||||
|
||||
subgraph CCIP_ETH["Ethereum 1 — CCIP anchor"]
|
||||
ETH1["WETH9 / WETH10 bridges · CCIPRelayRouter · RelayBridge · Logger · optional trustless stack"]
|
||||
LEAF_ETH["Leaf — Mainnet native DEX venues · Li.Fi touchpoints on other chains · first-wave cW DODO pools · optional TRUU PMM rail"]
|
||||
end
|
||||
|
||||
subgraph CCIP_L2["Other live CCIP EVM destinations"]
|
||||
L2CLU["OP 10 · Base 8453 · Arb 42161 · Polygon 137 · BSC 56 · Avax 43114 · Gnosis 100 · Celo 42220 · Cronos 25"]
|
||||
LEAF_L2["Leaf — per-chain native DEX · cW token transport · partial edge pools"]
|
||||
end
|
||||
|
||||
subgraph ALLTRA["ALL Mainnet 651940"]
|
||||
A651["AlltraAdapter peer · AUSDT · WETH · WALL · HYDX · DEX env placeholders"]
|
||||
LEAF_651["Leaf — ALL native venues when configured"]
|
||||
end
|
||||
|
||||
subgraph SPECIAL["Dedicated corridor from 138"]
|
||||
AVAXCW["138 cUSDT to Avax cWUSDT mint path"]
|
||||
LEAF_AVAX["Leaf — recipient on 43114"]
|
||||
end
|
||||
|
||||
subgraph CW_MESH["Public cW GRU mesh"]
|
||||
CW["Cross-public-EVM token matrix · pool design · Mainnet DODO concentration"]
|
||||
end
|
||||
|
||||
subgraph PENDING["Pending separate scaffold"]
|
||||
WEMIX[Wemix 1111 CCIP pending]
|
||||
XDC[XDC Zero parallel program]
|
||||
SCAFF[Etherlink Tezos OP L2 design]
|
||||
PNON[Truth pointer · Tron adapter · Solana partial]
|
||||
end
|
||||
|
||||
WU --> RPCPUB
|
||||
RPCPUB --> C138
|
||||
WU --> C138
|
||||
OPS --> C138
|
||||
EXP --> C138
|
||||
INFO --> C138
|
||||
DAPP --> C138
|
||||
DBIS --> C138
|
||||
X402 --> C138
|
||||
MCP --> C138
|
||||
FB --> C138
|
||||
|
||||
C138 <--> ETH1
|
||||
C138 <--> L2CLU
|
||||
C138 <--> A651
|
||||
C138 --> AVAXCW
|
||||
AVAXCW --> LEAF_AVAX
|
||||
|
||||
ETH1 <--> L2CLU
|
||||
ETH1 --> LEAF_ETH
|
||||
L2CLU --> LEAF_L2
|
||||
A651 --> LEAF_651
|
||||
|
||||
CW -.->|pool and peg design| LEAF_ETH
|
||||
CW -.->|token mesh| L2CLU
|
||||
|
||||
C138 -.-> WEMIX
|
||||
C138 -.-> XDC
|
||||
C138 -.-> SCAFF
|
||||
C138 -.-> PNON
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 1 Network -->
|
||||
<div class="content" id="panel-1" role="tabpanel" aria-labelledby="tab-1" hidden>
|
||||
<p class="panel-desc">Chain 138 to the public EVM mesh, Alltra, pending or scaffold targets, Avalanche cW minting, and the separate Mainnet cW mint corridor that sits alongside the standard WETH-class CCIP rail.</p>
|
||||
<div class="mermaid-wrap"><div class="mermaid">
|
||||
flowchart TB
|
||||
subgraph C138["Chain 138 — primary"]
|
||||
CORE[Core registry vault oracle ISO router]
|
||||
PMM[DODO PMM V2 DVM + pools]
|
||||
R2[EnhancedSwapRouterV2]
|
||||
D3[D3MM pilot]
|
||||
CCIPB[CCIP WETH9 WETH10 bridges]
|
||||
ALLA[AlltraAdapter]
|
||||
ADP[Truth Tron Solana adapters partial]
|
||||
end
|
||||
|
||||
subgraph PUB["Public EVM mesh (cW*)"]
|
||||
E1[Ethereum 1]
|
||||
E10[Optimism 10]
|
||||
E25[Cronos 25]
|
||||
E56[BSC 56]
|
||||
E100[Gnosis 100]
|
||||
E137[Polygon 137]
|
||||
E42161[Arbitrum 42161]
|
||||
E43114[Avalanche 43114]
|
||||
E8453[Base 8453]
|
||||
E42220[Celo 42220]
|
||||
end
|
||||
|
||||
subgraph PEND["Pending or separate"]
|
||||
WEMIX[Wemix 1111 CCIP pending]
|
||||
XDC[XDC Zero parallel program]
|
||||
SCAFF[Etherlink Tezos OP L2 scaffold design]
|
||||
end
|
||||
|
||||
subgraph A651["ALL Mainnet 651940"]
|
||||
ALLTOK[AUSDT USDC WETH WALL HYDX]
|
||||
end
|
||||
|
||||
C138 -->|CCIP WETH| PUB
|
||||
C138 -->|CCIP WETH| E1
|
||||
C138 -->|mainnet cW mint corridor| E1
|
||||
C138 -->|AlltraAdapter| A651
|
||||
PUB -->|CCIP return| C138
|
||||
E1 -->|CCIP return| C138
|
||||
C138 -.->|operator completion| WEMIX
|
||||
C138 -.->|not CCIP matrix row| XDC
|
||||
C138 -.->|future gated| SCAFF
|
||||
|
||||
C138 -->|avax cw corridor| E43114
|
||||
</div></div>
|
||||
<p class="panel-desc">Topology note: Mainnet now represents two Ethereum-facing patterns in production, the standard WETH-class CCIP rail and the dedicated <code>cUSDC/cUSDT -> cWUSDC/cWUSDT</code> mint corridor.</p>
|
||||
</div>
|
||||
|
||||
<!-- 2 Stack -->
|
||||
<div class="content" id="panel-2" role="tabpanel" aria-labelledby="tab-2" hidden>
|
||||
<p class="panel-desc">On-chain layers: tokens, core, liquidity, cross-domain, reserve and settlement.</p>
|
||||
<div class="mermaid-wrap"><div class="mermaid">
|
||||
flowchart TB
|
||||
subgraph L1["Tokens and compliance"]
|
||||
CT[cUSDT · cUSDC · cEUR* · cXAU* · mirrors · USDT · USDC]
|
||||
GEN[WETH WETH10 LINK]
|
||||
end
|
||||
|
||||
subgraph L2["Core infrastructure"]
|
||||
REG[Compliance TokenFactory TokenRegistry BridgeVault]
|
||||
POL[PolicyManager DebtRegistry FeeCollector]
|
||||
ISO[ISO20022Router]
|
||||
end
|
||||
|
||||
subgraph L3["Liquidity and execution"]
|
||||
DVM[DVMFactory VendingMachine DODOPMMIntegration]
|
||||
PRV[DODOPMMProvider PrivatePoolRegistry]
|
||||
R2[EnhancedSwapRouterV2]
|
||||
VEN[Uniswap v3 lane Balancer Curve 1inch pilots]
|
||||
D3[D3Oracle D3Vault D3Proxy D3MMFactory]
|
||||
end
|
||||
|
||||
subgraph L4["Cross-domain"]
|
||||
CCIP[CCIP Router CCIPWETH9 CCIPWETH10]
|
||||
ALL[AlltraAdapter]
|
||||
LBX[Lockbox138]
|
||||
CH[PaymentChannel Mirror AddressMapper]
|
||||
end
|
||||
|
||||
subgraph L5["Reserve vault settlement"]
|
||||
RS[ReserveSystem OraclePriceFeed]
|
||||
VF[VaultFactory Ledger Liquidation XAUOracle]
|
||||
MSR[MerchantSettlementRegistry WithdrawalEscrow]
|
||||
end
|
||||
|
||||
L1 --> L2
|
||||
L2 --> L3
|
||||
L3 --> R2
|
||||
R2 --> VEN
|
||||
L2 --> L4
|
||||
L2 --> L5
|
||||
DVM --> PRV
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 3 Flows -->
|
||||
<div class="content" id="panel-3" role="tabpanel" aria-labelledby="tab-3" hidden>
|
||||
<p class="panel-desc">Same-chain 138: PMM pools, RouterV2 venues, D3 pilot.</p>
|
||||
<div class="mermaid-wrap"><div class="mermaid">
|
||||
flowchart LR
|
||||
subgraph inputs["Typical inputs"]
|
||||
U1[cUSDT]
|
||||
U2[cUSDC]
|
||||
U3[USDT mirror]
|
||||
U4[USDC mirror]
|
||||
U5[cEURT]
|
||||
U6[cXAUC]
|
||||
end
|
||||
|
||||
subgraph path_pmm["DODO PMM"]
|
||||
INT[DODOPMMIntegration]
|
||||
POOL[Stable pools XAU public pools Private XAU registry]
|
||||
end
|
||||
|
||||
subgraph path_r2["Router v2"]
|
||||
R2[EnhancedSwapRouterV2]
|
||||
UV3[Uniswap v3 WETH stable]
|
||||
PILOT[Balancer Curve 1inch]
|
||||
end
|
||||
|
||||
subgraph path_d3["Pilot"]
|
||||
D3[D3MM WETH10 pilot pool]
|
||||
end
|
||||
|
||||
inputs --> INT
|
||||
INT --> POOL
|
||||
inputs --> R2
|
||||
R2 --> UV3
|
||||
R2 --> PILOT
|
||||
GEN2[WETH WETH10] --> R2
|
||||
GEN2 --> D3
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 4 Cross-chain -->
|
||||
<div class="content" id="panel-4" role="tabpanel" aria-labelledby="tab-4" hidden>
|
||||
<p class="panel-desc">CCIP transport, Alltra round-trip, the dedicated c-to-cW mint corridors, and the orchestrated swap-bridge-swap target.</p>
|
||||
<div class="mermaid-wrap">
|
||||
<h3>CCIP — WETH primary transport</h3>
|
||||
<div class="mermaid">
|
||||
sequenceDiagram
|
||||
participant U as User or bot
|
||||
participant C138 as Chain 138
|
||||
participant BR as CCIPWETH9 or WETH10 bridge
|
||||
participant R as CCIP Router
|
||||
participant D as Destination EVM
|
||||
|
||||
U->>C138: Fund WETH bridge fee LINK
|
||||
U->>BR: Initiate cross-chain WETH transfer
|
||||
BR->>R: CCIP message
|
||||
R->>D: Deliver WETH class asset
|
||||
Note over D: Native DEX or cW pools where deployed
|
||||
D->>R: Optional return leg
|
||||
R->>C138: Inbound to receiver bridge
|
||||
</div>
|
||||
</div>
|
||||
<div class="mermaid-wrap">
|
||||
<h3>Alltra — 138 to ALL Mainnet</h3>
|
||||
<div class="mermaid">
|
||||
flowchart LR
|
||||
A[Chain 138] -->|AlltraAdapter| B[ALL 651940]
|
||||
B -->|AlltraAdapter| A
|
||||
</div>
|
||||
</div>
|
||||
<div class="mermaid-wrap">
|
||||
<h3>Special corridors — c* to cW* mint</h3>
|
||||
<div class="mermaid">
|
||||
flowchart LR
|
||||
S1[cUSDT on 138] -->|avax cw relay mint| T1[cWUSDT on Avalanche]
|
||||
S2[cUSDC on 138] -->|mainnet relay mint| T2[cWUSDC on Mainnet]
|
||||
S3[cUSDT on 138] -->|mainnet relay mint| T3[cWUSDT on Mainnet]
|
||||
</div>
|
||||
</div>
|
||||
<div class="mermaid-wrap">
|
||||
<h3>Orchestrated swap-bridge-swap (design target)</h3>
|
||||
<div class="mermaid">
|
||||
flowchart LR
|
||||
Q[QuoteService POST api bridge quote] --> S1[Source leg e.g. 138 PMM]
|
||||
S1 --> BR[Bridge CCIP Alltra or special]
|
||||
BR --> S2[Destination leg DEX or cW pool]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5 Public cW -->
|
||||
<div class="content" id="panel-5" role="tabpanel" aria-labelledby="tab-5" hidden>
|
||||
<p class="panel-desc">Ethereum Mainnet first-wave cW DODO mesh, plus the separate optional TRUU PMM rail. See PMM_DEX_ROUTING_STATUS and cross-chain-pmm-lps deployment-status for live detail.</p>
|
||||
<div class="mermaid-wrap"><div class="mermaid">
|
||||
flowchart TB
|
||||
subgraph ETH["Ethereum Mainnet"]
|
||||
CW[cWUSDT cWUSDC cWEURC cWGBPC cWAUDC cWCADC cWJPYC cWCHFC]
|
||||
HUB[USDC USDT]
|
||||
DODO[DODO PMM Wave 1 pools]
|
||||
end
|
||||
|
||||
CW <--> DODO
|
||||
HUB <--> DODO
|
||||
</div></div>
|
||||
<p class="panel-desc">TRUU note: the optional Mainnet Truth rail is a separate volatile PMM lane and is not part of the default cW stable mesh.</p>
|
||||
<div class="mermaid-wrap">
|
||||
<h3>Mainnet TRUU PMM (volatile, optional)</h3>
|
||||
<div class="mermaid">
|
||||
flowchart LR
|
||||
subgraph TRUUmesh["Mainnet TRUU rail optional"]
|
||||
CWu[cWUSDT or cWUSDC]
|
||||
TRUU[TRUU ERC-20]
|
||||
PMM[DODO PMM integration]
|
||||
end
|
||||
|
||||
CWu <--> PMM
|
||||
TRUU <--> PMM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6 Off-chain -->
|
||||
<div class="content" id="panel-6" role="tabpanel" aria-labelledby="tab-6" hidden>
|
||||
<p class="panel-desc">Wallets, edge FQDNs, APIs, operators feeding Chain 138 RPC, plus the explorer-hosted Mission Control visual surfaces.</p>
|
||||
<div class="mermaid-wrap"><div class="mermaid">
|
||||
flowchart TB
|
||||
subgraph users["Wallets and tools"]
|
||||
MM[MetaMask custom network Snaps]
|
||||
MCP[MCP PMM controller allowlist 138]
|
||||
end
|
||||
|
||||
subgraph edge["Public edge"]
|
||||
EXP[explorer.d-bis.org Blockscout token-aggregation]
|
||||
MC[Mission Control visual panels]
|
||||
INFO[info.defi-oracle.io]
|
||||
DAPP[dapp.d-bis.org bridge UI]
|
||||
RPC[rpc-http-pub.d-bis.org public RPC]
|
||||
end
|
||||
|
||||
subgraph api["APIs"]
|
||||
TA[token-aggregation v1 v2 quote pools bridge routes]
|
||||
DBIS[dbis-api Core runtime]
|
||||
X402[x402-api readiness surface]
|
||||
end
|
||||
|
||||
subgraph ops["Operator"]
|
||||
REL[CCIP relay systemd]
|
||||
SCR[smom-dbis-138 forge scripts]
|
||||
end
|
||||
|
||||
users --> edge
|
||||
EXP --> MC
|
||||
edge --> api
|
||||
MC --> api
|
||||
api --> C138[Chain 138 RPC]
|
||||
ops --> C138
|
||||
</div></div>
|
||||
<p class="panel-desc">Mission Control note: the live visual display lives in the main explorer SPA, especially the bridge-monitoring and operator surfaces. This command center stays focused on the static architecture view.</p>
|
||||
</div>
|
||||
|
||||
<!-- 7 Integrations -->
|
||||
<div class="content" id="panel-7" role="tabpanel" aria-labelledby="tab-7" hidden>
|
||||
<p class="panel-desc">Contract families vs wallet/client integrations not spelled out in every zoom diagram. Wormhole remains docs/MCP scope, not canonical 138 addresses.</p>
|
||||
<div class="mermaid-wrap"><div class="mermaid">
|
||||
flowchart LR
|
||||
subgraph chain138_tech["Chain 138 contract families"]
|
||||
A[Besu EVM]
|
||||
B[ERC-20 core registries]
|
||||
C[DODO V2 V3]
|
||||
D[UniV3 Bal Curve 1inch pilots]
|
||||
E[CCIP bridges router]
|
||||
F[Alltra Vault ISO channels]
|
||||
end
|
||||
|
||||
subgraph public_integrations["Wallet and client integrations"]
|
||||
L[Ledger]
|
||||
CL[Chainlist]
|
||||
TW[thirdweb RPC]
|
||||
ETH[ethers.js]
|
||||
MM[MetaMask Snaps]
|
||||
end
|
||||
|
||||
chain138_tech --> public_integrations
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 8 Mission Control -->
|
||||
<div class="content" id="panel-8" role="tabpanel" aria-labelledby="tab-8" hidden>
|
||||
<p class="panel-desc">Mission Control is the live explorer surface for SSE health, labeled bridge traces, cached liquidity proxy results, and operator-facing API references. The interactive controls live in the main explorer SPA; this tab is the architecture companion with direct entry points.</p>
|
||||
<div class="mermaid-wrap">
|
||||
<h3>Mission Control visual flow</h3>
|
||||
<div class="mermaid">
|
||||
flowchart LR
|
||||
UI[Explorer SPA Mission Control panels]
|
||||
SSE[SSE stream]
|
||||
TRACE[Bridge trace]
|
||||
LIQ[Liquidity proxy]
|
||||
T4[Track 4 script API]
|
||||
API[Explorer Go API]
|
||||
UP[Blockscout and token-aggregation upstreams]
|
||||
|
||||
UI --> SSE
|
||||
UI --> TRACE
|
||||
UI --> LIQ
|
||||
UI -.->|operator-only| T4
|
||||
SSE --> API
|
||||
TRACE --> API
|
||||
LIQ --> API
|
||||
T4 --> API
|
||||
TRACE --> UP
|
||||
LIQ --> UP
|
||||
</div>
|
||||
</div>
|
||||
<div class="mermaid-wrap">
|
||||
<h3>Live entry points</h3>
|
||||
<p class="panel-desc">Use the main explorer UI for the visual Mission Control experience, then open the raw APIs when you need direct payloads or verification.</p>
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:0.75rem;">
|
||||
<a href="/operator" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--panel);"><div style="font-weight:700; margin-bottom:0.3rem;">Operator hub</div><div style="color:var(--muted); line-height:1.5;">Explorer SPA surface with Mission Control and operator-facing API references.</div></a>
|
||||
<a href="/bridge" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--panel);"><div style="font-weight:700; margin-bottom:0.3rem;">Bridge monitoring</div><div style="color:var(--muted); line-height:1.5;">Includes the visible Mission Control bridge-trace card and SSE stream entry point.</div></a>
|
||||
<a href="/explorer-api/v1/mission-control/stream" target="_blank" rel="noopener noreferrer" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--panel);"><div style="font-weight:700; margin-bottom:0.3rem;">SSE stream</div><div style="color:var(--muted); line-height:1.5;"><code>GET /explorer-api/v1/mission-control/stream</code></div></a>
|
||||
<a href="/explorer-api/v1/mission-control/bridge/trace?tx=0x2f31d4f9a97be754b800f4af1a9eedf3b107d353bfa1a19e81417497a76c05c2" target="_blank" rel="noopener noreferrer" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--panel);"><div style="font-weight:700; margin-bottom:0.3rem;">Bridge trace example</div><div style="color:var(--muted); line-height:1.5;"><code>GET /explorer-api/v1/mission-control/bridge/trace</code></div></a>
|
||||
<a href="/explorer-api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools" target="_blank" rel="noopener noreferrer" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--panel);"><div style="font-weight:700; margin-bottom:0.3rem;">Liquidity example</div><div style="color:var(--muted); line-height:1.5;"><code>GET /explorer-api/v1/mission-control/liquidity/token/{address}/pools</code></div></a>
|
||||
<div style="border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--panel);"><div style="font-weight:700; margin-bottom:0.3rem;">Track 4 script API</div><div style="color:var(--muted); line-height:1.5;"><code>POST /explorer-api/v1/track4/operator/run-script</code><br>Requires wallet auth, IP allowlisting, and backend allowlist config.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Source: <code>proxmox/docs/02-architecture/SMOM_DBIS_138_FULL_DEPLOYMENT_FLOW_MAP.md</code> — addresses: <code>config/smart-contracts-master.json</code> and CONTRACT_ADDRESSES_REFERENCE.
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'dark',
|
||||
securityLevel: 'loose',
|
||||
flowchart: { curve: 'basis', padding: 12 },
|
||||
sequence: { actorMargin: 24, boxMargin: 8 }
|
||||
});
|
||||
|
||||
var TAB_SLUGS = ['master', 'network', 'stack', 'flows', 'cross-chain', 'public-cw', 'off-chain', 'integrations', 'mission-control'];
|
||||
var TAB_BY_NAME = {};
|
||||
TAB_SLUGS.forEach(function (s, i) { TAB_BY_NAME[s] = i; });
|
||||
|
||||
var tabs = document.querySelectorAll('.tab');
|
||||
var panels = document.querySelectorAll('.content[role="tabpanel"]');
|
||||
var tablist = document.querySelector('[role="tablist"]');
|
||||
var done = {};
|
||||
|
||||
function parseInitialTab() {
|
||||
var q = new URLSearchParams(window.location.search).get('tab');
|
||||
if (q == null || q === '') return 0;
|
||||
var n = parseInt(q, 10);
|
||||
if (!isNaN(n) && n >= 0 && n < tabs.length) return n;
|
||||
var key = String(q).toLowerCase().trim().replace(/\s+/g, '-');
|
||||
if (Object.prototype.hasOwnProperty.call(TAB_BY_NAME, key)) return TAB_BY_NAME[key];
|
||||
return 0;
|
||||
}
|
||||
|
||||
function syncUrl(index) {
|
||||
var slug = TAB_SLUGS[index] != null ? TAB_SLUGS[index] : String(index);
|
||||
try {
|
||||
var u = new URL(window.location.href);
|
||||
u.searchParams.set('tab', slug);
|
||||
history.replaceState(null, '', u.pathname + u.search + u.hash);
|
||||
} catch (e) { /* file:// or restricted */ }
|
||||
}
|
||||
|
||||
function setActive(index) {
|
||||
if (index < 0) index = 0;
|
||||
if (index >= tabs.length) index = tabs.length - 1;
|
||||
tabs.forEach(function (t, i) {
|
||||
var on = i === index;
|
||||
t.classList.toggle('active', on);
|
||||
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
t.setAttribute('tabindex', on ? '0' : '-1');
|
||||
});
|
||||
panels.forEach(function (p, i) {
|
||||
var on = i === index;
|
||||
p.classList.toggle('active', on);
|
||||
if (on) p.removeAttribute('hidden');
|
||||
else p.setAttribute('hidden', '');
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPanel(index) {
|
||||
var panel = document.getElementById('panel-' + index);
|
||||
if (!panel || done[index]) return;
|
||||
done[index] = true;
|
||||
var nodes = panel.querySelectorAll('.mermaid');
|
||||
if (nodes.length) {
|
||||
try {
|
||||
await mermaid.run({ nodes: nodes });
|
||||
} catch (e) {
|
||||
console.error('Mermaid render failed for panel', index, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showTab(index, opts) {
|
||||
opts = opts || {};
|
||||
setActive(index);
|
||||
await renderPanel(index);
|
||||
if (!opts.skipUrl) syncUrl(index);
|
||||
}
|
||||
|
||||
tabs.forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
var i = parseInt(tab.getAttribute('data-tab'), 10);
|
||||
showTab(i);
|
||||
});
|
||||
});
|
||||
|
||||
if (tablist) {
|
||||
tablist.addEventListener('keydown', function (e) {
|
||||
var cur = -1;
|
||||
tabs.forEach(function (t, idx) {
|
||||
if (t.getAttribute('aria-selected') === 'true') cur = idx;
|
||||
});
|
||||
if (cur < 0) return;
|
||||
var next = cur;
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
next = (cur + 1) % tabs.length;
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
next = (cur - 1 + tabs.length) % tabs.length;
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
next = 0;
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
next = tabs.length - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
showTab(next).then(function () {
|
||||
tabs[next].focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
var initial = parseInitialTab();
|
||||
await showTab(initial, { skipUrl: true });
|
||||
try {
|
||||
var u = new URL(window.location.href);
|
||||
if (u.searchParams.has('tab')) syncUrl(initial);
|
||||
} catch (e2) { /* ignore */ }
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"publishedAt": "2026-04-04T00:00:00Z",
|
||||
"source": "operator-verify-scripts",
|
||||
"summary": "Copy to mission-control-verify.json on the explorer host and set MISSION_CONTROL_VERIFY_JSON on the Go service.",
|
||||
"checks": [
|
||||
{ "name": "example", "ok": true, "detail": "Replace with real verify output." }
|
||||
]
|
||||
}
|
||||
377
frontend/public/config/topology-graph.json
Normal file
377
frontend/public/config/topology-graph.json
Normal file
@@ -0,0 +1,377 @@
|
||||
{
|
||||
"generatedAt": "2026-04-04T21:38:48Z",
|
||||
"description": "Auto-generated from config/smart-contracts-master.json (subset)",
|
||||
"liquiditySample": null,
|
||||
"elements": [
|
||||
{
|
||||
"data": {
|
||||
"id": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
|
||||
"label": "WETH9 (0xC02aaA39…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f",
|
||||
"label": "WETH10 (0xf4BB2e28…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506",
|
||||
"label": "Oracle_Aggregator (0x99b3511a…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x99b3511a2d315a497c8112c1fdd8d508d4b1e506"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6",
|
||||
"label": "Oracle_Proxy (0x3304b747…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817",
|
||||
"label": "CCIP_Router (0x42DAb7b8…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e",
|
||||
"label": "CCIP_Router_Direct_Legacy (0x8078A096…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x8078a09637e47fa5ed34f626046ea2094a5cde5e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x105f8a15b819948a89153505762444ee9f324684",
|
||||
"label": "CCIP_Sender (0x105F8A15…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x105f8a15b819948a89153505762444ee9f324684"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xcacfd227a040002e49e2e01626363071324f820a",
|
||||
"label": "CCIPWETH9_Bridge (0xcacfd227…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xcacfd227a040002e49e2e01626363071324f820a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x971cd9d156f193df8051e48043c476e53ecd4693",
|
||||
"label": "CCIPWETH9_Bridge_Direct_Legacy (0x971cD9D1…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x971cd9d156f193df8051e48043c476e53ecd4693"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xe0e93247376aa097db308b92e6ba36ba015535d0",
|
||||
"label": "CCIPWETH10_Bridge (0xe0E93247…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xe0e93247376aa097db308b92e6ba36ba015535d0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xb7721dd53a8c629d9f1ba31a5819afe250002b03",
|
||||
"label": "LINK (0xb7721dD5…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xb7721dd53a8c629d9f1ba31a5819afe250002b03"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x93e66202a11b1772e55407b32b44e5cd8eda7f22",
|
||||
"label": "cUSDT (0x93E66202…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x93e66202a11b1772e55407b32b44e5cd8eda7f22"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xf22258f57794cc8e06237084b353ab30fffa640b",
|
||||
"label": "cUSDC (0xf22258f5…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xf22258f57794cc8e06237084b353ab30fffa640b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x9fbfab33882efe0038daa608185718b772ee5660",
|
||||
"label": "cUSDT_V2 (0x9FBfab33…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x9fbfab33882efe0038daa608185718b772ee5660"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d",
|
||||
"label": "cUSDC_V2 (0x219522c6…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x91efe92229dbf7c5b38d422621300956b55870fa",
|
||||
"label": "TokenRegistry (0x91Efe922…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x91efe92229dbf7c5b38d422621300956b55870fa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xebfb5c60de5f7c4baae180ca328d3bb39e1a5133",
|
||||
"label": "TokenFactory (0xEBFb5C60…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xebfb5c60de5f7c4baae180ca328d3bb39e1a5133"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1",
|
||||
"label": "ComplianceRegistry (0xbc54fe2b…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x31884f84555210ffb36a19d2471b8ebc7372d0a8",
|
||||
"label": "BridgeVault (0x31884f84…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x31884f84555210ffb36a19d2471b8ebc7372d0a8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xf78246eb94c6cb14018e507e60661314e5f4c53f",
|
||||
"label": "FeeCollector (0xF78246eB…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xf78246eb94c6cb14018e507e60661314e5f4c53f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x95bc4a997c0670d5dac64d55cdf3769b53b63c28",
|
||||
"label": "DebtRegistry (0x95BC4A99…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x95bc4a997c0670d5dac64d55cdf3769b53b63c28"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x0c4fd27018130a00762a802f91a72d6a64a60f14",
|
||||
"label": "PolicyManager (0x0C4FD270…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x0c4fd27018130a00762a802f91a72d6a64a60f14"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x0059e237973179146237ab49f1322e8197c22b21",
|
||||
"label": "TokenImplementation (0x0059e237…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x0059e237973179146237ab49f1322e8197c22b21"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xd3ad6831aacb5386b8a25bb8d8176a6c8a026f04",
|
||||
"label": "PriceFeed_Keeper (0xD3AD6831…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xd3ad6831aacb5386b8a25bb8d8176a6c8a026f04"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x8918ee0819fd687f4eb3e8b9b7d0ef7557493cfa",
|
||||
"label": "OraclePriceFeed (0x8918eE08…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x8918ee0819fd687f4eb3e8b9b7d0ef7557493cfa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x3e8725b8de386fef3efe5678c92ea6adb41992b2",
|
||||
"label": "WETH_MockPriceFeed (0x3e8725b8…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x3e8725b8de386fef3efe5678c92ea6adb41992b2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x16d9a2cb94a0b92721d93db4a6cd8023d3338800",
|
||||
"label": "MerchantSettlementRegistry (0x16D9A2cB…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x16d9a2cb94a0b92721d93db4a6cd8023d3338800"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xe77cb26ea300e2f5304b461b0ec94c8ad6a7e46d",
|
||||
"label": "WithdrawalEscrow (0xe77cb26e…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xe77cb26ea300e2f5304b461b0ec94c8ad6a7e46d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xaee4b7fbe82e1f8295951584cbc772b8bbd68575",
|
||||
"label": "UniversalAssetRegistry (0xAEE4b7fB…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xaee4b7fbe82e1f8295951584cbc772b8bbd68575"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xa6891d5229f2181a34d4ff1b515c3aa37dd90e0e",
|
||||
"label": "GovernanceController (0xA6891D52…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xa6891d5229f2181a34d4ff1b515c3aa37dd90e0e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xcd42e8ed79dc50599535d1de48d3dafa0be156f8",
|
||||
"label": "UniversalCCIPBridge (0xCd42e8eD…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xcd42e8ed79dc50599535d1de48d3dafa0be156f8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xbe9e0b2d4cf6a3b2994d6f2f0904d2b165eb8ffc",
|
||||
"label": "UniversalCCIPFlashBridgeAdapter (0xBe9e0B2d…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xbe9e0b2d4cf6a3b2994d6f2f0904d2b165eb8ffc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xd084b68cb4b1ef2cba09cf99fb1b6552fd9b4859",
|
||||
"label": "CrossChainFlashRepayReceiver (0xD084b68c…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xd084b68cb4b1ef2cba09cf99fb1b6552fd9b4859"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x89f7a1fcbbe104bee96da4b4b6b7d3af85f7e661",
|
||||
"label": "CrossChainFlashVaultCreditReceiver (0x89F7a1fc…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x89f7a1fcbbe104bee96da4b4b6b7d3af85f7e661"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x89ab428c437f23bab9781ff8db8d3848e27eed6c",
|
||||
"label": "BridgeOrchestrator (0x89aB428c…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x89ab428c437f23bab9781ff8db8d3848e27eed6c"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce",
|
||||
"label": "EnhancedSwapRouterV2 (0xF1c93F54…)",
|
||||
"href": "https://explorer.d-bis.org/address/0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7",
|
||||
"label": "IntentBridgeCoordinatorV2 (0x7D0022B7…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x88495b3dccea93b0633390fde71992683121fa62",
|
||||
"label": "DodoRouteExecutorAdapter (0x88495B3d…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x88495b3dccea93b0633390fde71992683121fa62"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x9cb97add29c52e3b81989bca2e33d46074b530ef",
|
||||
"label": "DodoV3RouteExecutorAdapter (0x9Cb97adD…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x9cb97add29c52e3b81989bca2e33d46074b530ef"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "0x960d6db4e78705f82995690548556fb2266308ea",
|
||||
"label": "UniswapV3RouteExecutorAdapter (0x960D6db4…)",
|
||||
"href": "https://explorer.d-bis.org/address/0x960d6db4e78705f82995690548556fb2266308ea"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": "stack_rpc",
|
||||
"label": "Chain 138 RPC (logical)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"source": "stack_rpc",
|
||||
"target": "0x105f8a15b819948a89153505762444ee9f324684",
|
||||
"label": "settlement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"source": "stack_rpc",
|
||||
"target": "0x31884f84555210ffb36a19d2471b8ebc7372d0a8",
|
||||
"label": "settlement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"source": "stack_rpc",
|
||||
"target": "0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817",
|
||||
"label": "settlement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"source": "stack_rpc",
|
||||
"target": "0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7",
|
||||
"label": "settlement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"source": "stack_rpc",
|
||||
"target": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e",
|
||||
"label": "settlement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"source": "stack_rpc",
|
||||
"target": "0x89ab428c437f23bab9781ff8db8d3848e27eed6c",
|
||||
"label": "settlement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"source": "stack_rpc",
|
||||
"target": "0x971cd9d156f193df8051e48043c476e53ecd4693",
|
||||
"label": "settlement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"source": "stack_rpc",
|
||||
"target": "0xbe9e0b2d4cf6a3b2994d6f2f0904d2b165eb8ffc",
|
||||
"label": "settlement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"source": "stack_rpc",
|
||||
"target": "0xcacfd227a040002e49e2e01626363071324f820a",
|
||||
"label": "settlement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"source": "stack_rpc",
|
||||
"target": "0xcd42e8ed79dc50599535d1de48d3dafa0be156f8",
|
||||
"label": "settlement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"source": "stack_rpc",
|
||||
"target": "0xe0e93247376aa097db308b92e6ba36ba015535d0",
|
||||
"label": "settlement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"source": "stack_rpc",
|
||||
"target": "0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce",
|
||||
"label": "settlement"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -919,6 +919,149 @@
|
||||
.gas-network-subtle { color: var(--text-light); font-size: 0.82rem; white-space: nowrap; }
|
||||
.btn-copy { background: none; border: none; cursor: pointer; padding: 0.25rem; margin-left: 0.35rem; color: var(--text-light); vertical-align: middle; }
|
||||
.btn-copy:hover { color: var(--primary); }
|
||||
.tx-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.tx-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: var(--muted-surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.tx-chip-label {
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.tx-inspector-stack {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.tx-inspector-entry {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--muted-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tx-inspector-entry summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tx-inspector-entry summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.tx-inspector-entry[open] summary {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tx-inspector-summary-value {
|
||||
color: var(--text-light);
|
||||
font-size: 0.82rem;
|
||||
text-align: right;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tx-inspector-entry-body {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
}
|
||||
.tx-inspector-note {
|
||||
color: var(--text-light);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tx-inspector-line {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(110px, 140px) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
.tx-inspector-label {
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.72rem;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
.tx-inspector-content {
|
||||
min-width: 0;
|
||||
}
|
||||
.tx-inspector-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.tx-inspector-mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.tx-inspector-pre {
|
||||
margin: 0;
|
||||
padding: 0.85rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.tx-inspector-topic-list {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.tx-inspector-topic-row {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.7rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.tx-inspector-topic-index {
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.tx-empty {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
body.dark-theme .tx-inspector-pre {
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
}
|
||||
body.dark-theme .tx-inspector-topic-row {
|
||||
background: rgba(15, 23, 42, 0.44);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.tx-inspector-line {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.tx-inspector-entry summary {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.tx-inspector-summary-value {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
.site-footer {
|
||||
margin-top: 2.5rem;
|
||||
padding: 2rem 0 2.5rem;
|
||||
@@ -1041,6 +1184,7 @@
|
||||
<li role="none"><a href="/routes" role="menuitem" onclick="event.preventDefault(); showRoutes(); updatePath('/routes'); closeNavMenu();" aria-label="View route decision tree"><i class="fas fa-diagram-project" aria-hidden="true"></i> <span>Routes</span></a></li>
|
||||
<li role="none"><a href="/tokens" role="menuitem" onclick="event.preventDefault(); if(typeof showTokensList==='function')showTokensList();else focusSearchWithHint('token'); updatePath('/tokens'); closeNavMenu();" aria-label="View token list"><i class="fas fa-tag" aria-hidden="true"></i> <span data-i18n="tokens">Tokens</span></a></li>
|
||||
<li role="none"><a href="/pools" role="menuitem" onclick="event.preventDefault(); showPools(); updatePath('/pools'); closeNavMenu();" aria-label="View pools"><i class="fas fa-water" aria-hidden="true"></i> <span data-i18n="pools">Pools</span> <span id="poolsMissingQuoteBadge" class="badge badge-warning" style="display:none; margin-left:0.35rem; vertical-align:middle;">0</span></a></li>
|
||||
<li role="none"><a href="/system" role="menuitem" onclick="event.preventDefault(); showSystemTopology(); closeNavMenu();" aria-label="System topology"><i class="fas fa-sitemap" aria-hidden="true"></i> <span>System</span></a></li>
|
||||
<li role="none"><a href="/watchlist" role="menuitem" onclick="event.preventDefault(); showWatchlist(); updatePath('/watchlist'); closeNavMenu();" aria-label="Watchlist"><i class="fas fa-star" aria-hidden="true"></i> <span data-i18n="watchlist">Watchlist</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -1556,6 +1700,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="systemView" class="detail-view">
|
||||
<div class="breadcrumb" id="systemBreadcrumb"><a href="/">Home</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">System</span></div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<button class="btn btn-secondary" onclick="showHome()" aria-label="Go back"><i class="fas fa-arrow-left" aria-hidden="true"></i> Back</button>
|
||||
<h2 class="card-title"><i class="fas fa-sitemap" aria-hidden="true"></i> System topology</h2>
|
||||
<div style="display:flex; gap:0.5rem; margin-left:auto; flex-wrap:wrap;">
|
||||
<a class="btn btn-secondary" href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer">Command center</a>
|
||||
<button type="button" class="btn btn-primary" onclick="if(window._showSystemTopology) window._showSystemTopology();" aria-label="Reload topology"><i class="fas fa-sync-alt" aria-hidden="true"></i> Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="systemTopologyContent">
|
||||
<div class="loading"><i class="fas fa-spinner" aria-hidden="true"></i> Loading graph…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="site-footer">
|
||||
@@ -1594,6 +1755,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/explorer-spa.js?v=29"></script>
|
||||
<script src="/explorer-spa.js?v=34"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
17
frontend/public/thirdparty/README.md
vendored
Normal file
17
frontend/public/thirdparty/README.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Third-party bundles (optional)
|
||||
|
||||
## Mermaid (Visual Command Center)
|
||||
|
||||
`chain138-command-center.html` loads Mermaid from jsDelivr by default. If your explorer host blocks external script origins (CSP) or you need a fully offline doc path:
|
||||
|
||||
1. From repo root:
|
||||
```bash
|
||||
bash explorer-monorepo/scripts/vendor-mermaid-for-command-center.sh
|
||||
```
|
||||
2. Edit `chain138-command-center.html` and change the Mermaid `<script src="...">` line to:
|
||||
```html
|
||||
<script src="/thirdparty/mermaid.min.js"></script>
|
||||
```
|
||||
3. Deploy with `deploy-frontend-to-vmid5000.sh` — it copies `thirdparty/mermaid.min.js` when the file exists.
|
||||
|
||||
The minified file is gitignored (~3.3 MB); do not commit it.
|
||||
BIN
frontend/public/token-icons/cUSDC.png
Normal file
BIN
frontend/public/token-icons/cUSDC.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
frontend/public/token-icons/cUSDT.png
Normal file
BIN
frontend/public/token-icons/cUSDT.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
BIN
frontend/public/token-icons/cXAUC.png
Normal file
BIN
frontend/public/token-icons/cXAUC.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
BIN
frontend/public/token-icons/cXAUT.png
Normal file
BIN
frontend/public/token-icons/cXAUT.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -1,96 +1,116 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/\/$/, '');
|
||||
const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/\/$/, '')
|
||||
const addressUnderTest = process.env.SMOKE_ADDRESS || '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506'
|
||||
|
||||
const checks = [
|
||||
{ path: '/', homeVisible: true, expectTexts: ['Gas & Network', 'Latest Blocks', 'Latest Transactions'] },
|
||||
{ path: '/blocks', activeView: 'blocksView', expectTexts: ['All Blocks'] },
|
||||
{ path: '/transactions', activeView: 'transactionsView', expectTexts: ['All Transactions'] },
|
||||
{ path: '/addresses', activeView: 'addressesView', expectTexts: ['All Addresses'] },
|
||||
{ path: '/tokens', activeView: 'tokensView', expectTexts: ['Tokens'] },
|
||||
{ path: '/pools', activeView: 'poolsView', expectTexts: ['Pools', 'Canonical PMM routes'] },
|
||||
{ path: '/routes', activeView: 'routesView', expectTexts: ['Routes', 'Live Route Decision Tree'] },
|
||||
{ path: '/watchlist', activeView: 'watchlistView', expectTexts: ['Watchlist'] },
|
||||
{ path: '/bridge', activeView: 'bridgeView', expectTexts: ['Bridge Monitoring'] },
|
||||
{ path: '/weth', activeView: 'wethView', expectTexts: ['WETH', 'Wrap ETH to WETH9'] },
|
||||
{ path: '/liquidity', activeView: 'liquidityView', expectTexts: ['Liquidity Access', 'Public Explorer Access Points'] },
|
||||
{ path: '/more', activeView: 'moreView', expectTexts: ['More', 'Tools & Services'] },
|
||||
{ path: '/analytics', activeView: 'analyticsView', expectTexts: ['Analytics Dashboard', 'Live Network Analytics'] },
|
||||
{ path: '/operator', activeView: 'operatorView', expectTexts: ['Operator Panel', 'Operator Access Hub'] },
|
||||
{ path: '/block/1', activeView: 'blockDetailView', expectTexts: ['Block Details'] },
|
||||
{
|
||||
path: '/tx/0x0000000000000000000000000000000000000000000000000000000000000000',
|
||||
activeView: 'transactionDetailView',
|
||||
expectTexts: ['Transaction Details', 'Transaction not found'],
|
||||
},
|
||||
{
|
||||
path: '/address/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
|
||||
activeView: 'addressDetailView',
|
||||
expectTexts: ['Address Details', 'Address'],
|
||||
},
|
||||
];
|
||||
{ path: '/', expectTexts: ['SolaceScanScout', 'Recent Blocks', 'Open wallet tools'] },
|
||||
{ path: '/blocks', expectTexts: ['Blocks'] },
|
||||
{ path: '/transactions', expectTexts: ['Transactions'] },
|
||||
{ path: '/addresses', expectTexts: ['Addresses', 'Open An Address'] },
|
||||
{ path: '/watchlist', expectTexts: ['Watchlist', 'Saved Addresses'] },
|
||||
{ path: '/pools', expectTexts: ['Pools', 'Pool operation shortcuts'] },
|
||||
{ path: '/liquidity', expectTexts: ['Chain 138 Liquidity Access', 'Explorer Access Points'] },
|
||||
{ path: '/wallet', expectTexts: ['Wallet & MetaMask', 'Install Open Snap'] },
|
||||
{ path: '/tokens', expectTexts: ['Tokens', 'Find A Token'] },
|
||||
{ path: '/search', expectTexts: ['Search'], placeholder: 'Search by address, transaction hash, block number...' },
|
||||
{ path: `/addresses/${addressUnderTest}`, expectTexts: [], anyOfTexts: ['Back to addresses', 'Address not found'] },
|
||||
]
|
||||
|
||||
function hasExpectedText(text, snippets) {
|
||||
return snippets.some((snippet) => text.includes(snippet));
|
||||
async function bodyText(page) {
|
||||
return (await page.textContent('body')) || ''
|
||||
}
|
||||
|
||||
async function hasShell(page) {
|
||||
const homeLink = await page.getByRole('link', { name: /Go to explorer home/i }).isVisible().catch(() => false)
|
||||
const supportText = await page.getByText(/Support:/i).isVisible().catch(() => false)
|
||||
return homeLink && supportText
|
||||
}
|
||||
|
||||
async function waitForBodyText(page, snippets, timeoutMs = 15000) {
|
||||
if (!snippets || snippets.length === 0) {
|
||||
return bodyText(page)
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let lastText = ''
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
lastText = await bodyText(page)
|
||||
if (snippets.every((snippet) => lastText.includes(snippet))) {
|
||||
return lastText
|
||||
}
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
|
||||
return lastText
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
let failures = 0;
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage()
|
||||
let failures = 0
|
||||
|
||||
for (const check of checks) {
|
||||
const url = `${baseUrl}${check.path}`;
|
||||
const url = `${baseUrl}${check.path}`
|
||||
|
||||
try {
|
||||
const response = await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 })
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
if (!response || !response.ok()) {
|
||||
console.error(`FAIL ${check.path}: HTTP ${response ? response.status() : 'no-response'}`);
|
||||
failures += 1;
|
||||
continue;
|
||||
console.error(`FAIL ${check.path}: HTTP ${response ? response.status() : 'no-response'}`)
|
||||
failures += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const bodyText = await page.textContent('body');
|
||||
if (check.homeVisible) {
|
||||
const homeVisible = await page.$eval('#homeView', (el) => getComputedStyle(el).display !== 'none').catch(() => false);
|
||||
if (!homeVisible) {
|
||||
console.error(`FAIL ${check.path}: home view not visible`);
|
||||
failures += 1;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const activeView = await page.$eval('.detail-view.active', (el) => el.id).catch(() => null);
|
||||
if (activeView !== check.activeView) {
|
||||
console.error(`FAIL ${check.path}: expected active view ${check.activeView}, got ${activeView}`);
|
||||
failures += 1;
|
||||
continue;
|
||||
const text = await waitForBodyText(page, check.expectTexts)
|
||||
const missing = check.expectTexts.filter((snippet) => !text.includes(snippet))
|
||||
if (missing.length > 0) {
|
||||
console.error(`FAIL ${check.path}: missing text ${missing.join(' | ')}`)
|
||||
failures += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (check.anyOfTexts && !check.anyOfTexts.some((snippet) => text.includes(snippet))) {
|
||||
console.error(`FAIL ${check.path}: expected one of ${check.anyOfTexts.join(' | ')}`)
|
||||
failures += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (check.placeholder) {
|
||||
const placeholderVisible = await page.getByPlaceholder(check.placeholder).isVisible().catch(() => false)
|
||||
if (!placeholderVisible) {
|
||||
console.error(`FAIL ${check.path}: missing placeholder ${check.placeholder}`)
|
||||
failures += 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasExpectedText(bodyText || '', check.expectTexts)) {
|
||||
console.error(`FAIL ${check.path}: expected one of ${check.expectTexts.join(' | ')}`);
|
||||
failures += 1;
|
||||
continue;
|
||||
if (!(await hasShell(page))) {
|
||||
console.error(`FAIL ${check.path}: shared explorer shell not visible`)
|
||||
failures += 1
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(`OK ${check.path}`);
|
||||
console.log(`OK ${check.path}`)
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${check.path}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
failures += 1;
|
||||
console.error(`FAIL ${check.path}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
failures += 1
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
await browser.close()
|
||||
|
||||
if (failures > 0) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`All ${checks.length} route checks passed for ${baseUrl}`);
|
||||
console.log(`All ${checks.length} route checks passed for ${baseUrl}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error))
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
||||
47
frontend/scripts/start-standalone.mjs
Normal file
47
frontend/scripts/start-standalone.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { cp, mkdir } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const projectRoot = process.cwd()
|
||||
const standaloneRoot = path.join(projectRoot, '.next', 'standalone')
|
||||
const standaloneNextRoot = path.join(standaloneRoot, '.next')
|
||||
const standaloneServer = path.join(standaloneRoot, 'server.js')
|
||||
|
||||
async function copyIfPresent(sourcePath, destinationPath) {
|
||||
if (!existsSync(sourcePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(destinationPath), { recursive: true })
|
||||
await cp(sourcePath, destinationPath, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!existsSync(standaloneServer)) {
|
||||
console.error('Standalone server build is missing. Run `npm run build` first.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await copyIfPresent(path.join(projectRoot, '.next', 'static'), path.join(standaloneNextRoot, 'static'))
|
||||
await copyIfPresent(path.join(projectRoot, 'public'), path.join(standaloneRoot, 'public'))
|
||||
|
||||
const child = spawn(process.execPath, [standaloneServer], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
})
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,9 +1,13 @@
|
||||
import './globals.css'
|
||||
import type { ReactNode } from 'react'
|
||||
import ExplorerChrome from '@/components/common/ExplorerChrome'
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<ExplorerChrome>{children}</ExplorerChrome>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,261 +1,5 @@
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives/Card'
|
||||
|
||||
const publicApiBase = '/token-aggregation/api/v1'
|
||||
|
||||
const livePools = [
|
||||
{
|
||||
pair: 'cUSDT / cUSDC',
|
||||
poolAddress: '0xff8d3b8fDF7B112759F076B69f4271D4209C0849',
|
||||
reserves: '10,000,000 / 10,000,000',
|
||||
},
|
||||
{
|
||||
pair: 'cUSDT / USDT',
|
||||
poolAddress: '0x6fc60DEDc92a2047062294488539992710b99D71',
|
||||
reserves: '10,000,000 / 10,000,000',
|
||||
},
|
||||
{
|
||||
pair: 'cUSDC / USDC',
|
||||
poolAddress: '0x0309178ae30302D83c76d6Dd402a684eF3160eec',
|
||||
reserves: '10,000,000 / 10,000,000',
|
||||
},
|
||||
{
|
||||
pair: 'cUSDT / cXAUC',
|
||||
poolAddress: '0x1AA55E2001E5651349AfF5A63FD7A7Ae44f0F1b0',
|
||||
reserves: '2,666,965 / 519.477000',
|
||||
},
|
||||
{
|
||||
pair: 'cUSDC / cXAUC',
|
||||
poolAddress: '0xEA9Ac6357CaCB42a83b9082B870610363B177cBa',
|
||||
reserves: '1,000,000 / 194.782554',
|
||||
},
|
||||
{
|
||||
pair: 'cEURT / cXAUC',
|
||||
poolAddress: '0xbA99bc1eAAC164569d5AcA96C806934DDaF970Cf',
|
||||
reserves: '1,000,000 / 225.577676',
|
||||
},
|
||||
]
|
||||
|
||||
const publicEndpoints = [
|
||||
{
|
||||
name: 'Canonical route matrix',
|
||||
method: 'GET',
|
||||
href: `${publicApiBase}/routes/matrix`,
|
||||
notes: 'All live and optional non-live route inventory with counts and filters.',
|
||||
},
|
||||
{
|
||||
name: 'Live ingestion export',
|
||||
method: 'GET',
|
||||
href: `${publicApiBase}/routes/ingestion?family=LiFi`,
|
||||
notes: 'Flat live-route export for adapter ingestion and route discovery.',
|
||||
},
|
||||
{
|
||||
name: 'Partner payload templates',
|
||||
method: 'GET',
|
||||
href: `${publicApiBase}/routes/partner-payloads?partner=0x&amount=1000000&includeUnsupported=true`,
|
||||
notes: 'Builds exact 1inch, 0x, and LiFi request templates from live routes.',
|
||||
},
|
||||
{
|
||||
name: 'Resolve supported partner payloads',
|
||||
method: 'POST',
|
||||
href: `${publicApiBase}/routes/partner-payloads/resolve`,
|
||||
notes: 'Accepts partner, amount, and addresses and returns supported payloads by default.',
|
||||
},
|
||||
{
|
||||
name: 'Dispatch supported partner payload',
|
||||
method: 'POST',
|
||||
href: `${publicApiBase}/routes/partner-payloads/dispatch`,
|
||||
notes: 'Resolves then dispatches a single supported partner payload when the chain is supported.',
|
||||
},
|
||||
{
|
||||
name: 'Internal Chain 138 execution plan',
|
||||
method: 'POST',
|
||||
href: `${publicApiBase}/routes/internal-execution-plan`,
|
||||
notes: 'Returns the internal DODO PMM fallback plan when external partner support is unavailable.',
|
||||
},
|
||||
]
|
||||
|
||||
const routeHighlights = [
|
||||
'Direct live routes: cUSDT <-> cUSDC, cUSDT <-> USDT, cUSDC <-> USDC, cUSDT <-> cXAUC, cUSDC <-> cXAUC, cEURT <-> cXAUC.',
|
||||
'Multi-hop public routes exist through cXAUC for cEURT <-> cUSDT, cEURT <-> cUSDC, and an alternate cUSDT <-> cUSDC path.',
|
||||
'Mainnet bridge discovery is live for cUSDT -> USDT and cUSDC -> USDC through the configured UniversalCCIPBridge lane.',
|
||||
'External partner templates are available for 1inch, 0x, and LiFi, but Chain 138 remains unsupported on those public partner networks today.',
|
||||
'When partner support is unavailable, the explorer can surface the internal DODO PMM execution plan instead of a dead end.',
|
||||
]
|
||||
|
||||
const requestExamples = [
|
||||
{
|
||||
title: 'Inspect the full route matrix',
|
||||
code: `GET ${publicApiBase}/routes/matrix?includeNonLive=true`,
|
||||
},
|
||||
{
|
||||
title: 'Filter live same-chain swap routes on Chain 138',
|
||||
code: `GET ${publicApiBase}/routes/ingestion?fromChainId=138&routeType=swap`,
|
||||
},
|
||||
{
|
||||
title: 'Generate partner templates for review',
|
||||
code: `GET ${publicApiBase}/routes/partner-payloads?partner=LiFi&amount=1000000&includeUnsupported=true`,
|
||||
},
|
||||
{
|
||||
title: 'Resolve a dispatch candidate',
|
||||
code: `POST ${publicApiBase}/routes/partner-payloads/resolve`,
|
||||
},
|
||||
{
|
||||
title: 'Build the internal fallback plan',
|
||||
code: `POST ${publicApiBase}/routes/internal-execution-plan`,
|
||||
},
|
||||
]
|
||||
import LiquidityOperationsPage from '@/components/explorer/LiquidityOperationsPage'
|
||||
|
||||
export default function LiquidityPage() {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8 max-w-4xl">
|
||||
<div className="mb-3 inline-flex rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">
|
||||
Chain 138 Liquidity Access
|
||||
</div>
|
||||
<h1 className="mb-3 text-4xl font-bold text-gray-900 dark:text-white">
|
||||
Public liquidity, route discovery, and execution access points
|
||||
</h1>
|
||||
<p className="text-lg leading-8 text-gray-600 dark:text-gray-400">
|
||||
This explorer page pulls together the live public DODO PMM liquidity on Chain 138 and the
|
||||
token-aggregation endpoints that DEX aggregators, integrators, and operators can use for
|
||||
route discovery, payload generation, and internal fallback execution planning.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Live public pools</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">6</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Verified public DODO PMM pools on Chain 138.
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Public access path</div>
|
||||
<div className="mt-2 text-lg font-bold text-gray-900 dark:text-white">/token-aggregation/api/v1</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Explorer-hosted proxy path for route, quote, and reporting APIs.
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Partner status</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">Fallback Ready</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Mainnet stable bridge routing is live; 1inch, 0x, and LiFi templates remain available for partner integrations, with internal fallback for unsupported Chain 138 execution.
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<Card title="Live Pool Snapshot">
|
||||
<div className="space-y-4">
|
||||
{livePools.map((pool) => (
|
||||
<div
|
||||
key={pool.poolAddress}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{pool.pair}</div>
|
||||
<div className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">
|
||||
Pool: {pool.poolAddress}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reserves: {pool.reserves}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="What Integrators Need To Know">
|
||||
<div className="space-y-3 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{routeHighlights.map((item) => (
|
||||
<p key={item}>{item}</p>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Explorer Access Points">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{publicEndpoints.map((endpoint) => (
|
||||
<a
|
||||
key={endpoint.href}
|
||||
href={endpoint.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 transition hover:border-primary-400 hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{endpoint.name}</div>
|
||||
<span className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
{endpoint.method}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 break-all rounded-xl bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||||
{endpoint.href}
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-400">{endpoint.notes}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Quick Request Examples">
|
||||
<div className="space-y-4">
|
||||
{requestExamples.map((example) => (
|
||||
<div key={example.title} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{example.title}</div>
|
||||
<code className="block break-all text-xs leading-6 text-gray-700 dark:text-gray-300">
|
||||
{example.code}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Related Explorer Tools">
|
||||
<div className="space-y-4 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
Use the wallet page for network onboarding and the explorer token list URL, then use this
|
||||
page for route and execution discovery.
|
||||
</p>
|
||||
<p>
|
||||
The route APIs complement the existing route decision tree and market-data APIs already
|
||||
proxied through the explorer.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="rounded-full bg-primary-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-primary-700"
|
||||
>
|
||||
Open wallet tools
|
||||
</Link>
|
||||
<a
|
||||
href={`${publicApiBase}/routes/tree?chainId=138&amountIn=1000000`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Route tree API
|
||||
</a>
|
||||
<a
|
||||
href="/docs.html"
|
||||
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Explorer docs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
return <LiquidityOperationsPage />
|
||||
}
|
||||
|
||||
@@ -3,97 +3,180 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives/Card'
|
||||
import Link from 'next/link'
|
||||
import { blocksApi } from '@/services/api/blocks'
|
||||
import { blocksApi, type Block } from '@/services/api/blocks'
|
||||
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
import {
|
||||
missionControlApi,
|
||||
type MissionControlRelaySummary,
|
||||
} from '@/services/api/missionControl'
|
||||
import { loadDashboardData } from '@/utils/dashboard'
|
||||
|
||||
interface NetworkStats {
|
||||
current_block: number
|
||||
tps: number
|
||||
gps: number
|
||||
avg_gas_price: number
|
||||
pending_transactions: number
|
||||
}
|
||||
type HomeStats = ExplorerStats
|
||||
|
||||
export default function Home() {
|
||||
const [stats, setStats] = useState<NetworkStats | null>(null)
|
||||
const [recentBlocks, setRecentBlocks] = useState<any[]>([])
|
||||
const [stats, setStats] = useState<HomeStats | null>(null)
|
||||
const [recentBlocks, setRecentBlocks] = useState<Block[]>([])
|
||||
const [relaySummary, setRelaySummary] = useState<MissionControlRelaySummary | null>(null)
|
||||
const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>('connecting')
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const latestBlock = stats?.latest_block ?? recentBlocks[0]?.number ?? null
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
// This would call analytics API
|
||||
// For now, placeholder
|
||||
setStats({
|
||||
current_block: 0,
|
||||
tps: 0,
|
||||
gps: 0,
|
||||
avg_gas_price: 0,
|
||||
pending_transactions: 0,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error)
|
||||
}
|
||||
}, [])
|
||||
const loadDashboard = useCallback(async () => {
|
||||
const dashboardData = await loadDashboardData({
|
||||
loadStats: () => statsApi.get(),
|
||||
loadRecentBlocks: async () => {
|
||||
const response = await blocksApi.list({
|
||||
chain_id: chainId,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
onError: (scope, error) => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(`Failed to load dashboard ${scope}:`, error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const loadRecentBlocks = useCallback(async () => {
|
||||
try {
|
||||
const response = await blocksApi.list({
|
||||
chain_id: chainId,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
})
|
||||
setRecentBlocks(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent blocks:', error)
|
||||
}
|
||||
setStats(dashboardData.stats)
|
||||
setRecentBlocks(dashboardData.recentBlocks)
|
||||
}, [chainId])
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
loadRecentBlocks()
|
||||
}, [loadStats, loadRecentBlocks])
|
||||
loadDashboard()
|
||||
}, [loadDashboard])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadSnapshot = async () => {
|
||||
try {
|
||||
const summary = await missionControlApi.getRelaySummary()
|
||||
if (!cancelled) {
|
||||
setRelaySummary(summary)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to load mission control relay summary:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSnapshot()
|
||||
|
||||
const unsubscribe = missionControlApi.subscribeRelaySummary(
|
||||
(summary) => {
|
||||
if (!cancelled) {
|
||||
setRelaySummary(summary)
|
||||
setRelayFeedState('live')
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
if (!cancelled) {
|
||||
setRelayFeedState('fallback')
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Mission control live stream update issue:', error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const relayToneClasses =
|
||||
relaySummary?.tone === 'danger'
|
||||
? 'border-red-200 bg-red-50 text-red-900 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100'
|
||||
: relaySummary?.tone === 'warning'
|
||||
? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100'
|
||||
: 'border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-100'
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-2">SolaceScanScout</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">The Defi Oracle Meta Explorer</p>
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="mb-2 text-3xl font-bold sm:text-4xl">SolaceScanScout</h1>
|
||||
<p className="text-base text-gray-600 dark:text-gray-400 sm:text-lg">The Defi Oracle Meta Explorer</p>
|
||||
</div>
|
||||
|
||||
{relaySummary && (
|
||||
<Card className={`mb-6 border shadow-sm ${relayToneClasses}`}>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold uppercase tracking-wide opacity-80">Mission Control</div>
|
||||
<div className="mt-1 text-sm sm:text-base">{relaySummary.text}</div>
|
||||
<div className="mt-2 text-xs font-medium uppercase tracking-wide opacity-75">
|
||||
Feed: {relayFeedState === 'live' ? 'Live SSE' : relayFeedState === 'fallback' ? 'Snapshot fallback' : 'Connecting'}
|
||||
</div>
|
||||
{relaySummary.items.length > 1 && (
|
||||
<div className="mt-3 space-y-1 text-sm opacity-90">
|
||||
{relaySummary.items.map((item) => (
|
||||
<div key={item.key}>{item.text}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link href="/explorer-api/v1/mission-control/stream" className="text-sm font-semibold underline-offset-4 hover:underline">
|
||||
Open live stream
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="mb-8 grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Current Block</div>
|
||||
<div className="text-2xl font-bold">{stats.current_block.toLocaleString()}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Latest Block</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">
|
||||
{latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">TPS</div>
|
||||
<div className="text-2xl font-bold">{stats.tps.toFixed(2)}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Total Blocks</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{stats.total_blocks.toLocaleString()}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Gas Price</div>
|
||||
<div className="text-2xl font-bold">{stats.avg_gas_price.toLocaleString()} Gwei</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Total Transactions</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{stats.total_transactions.toLocaleString()}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Pending Tx</div>
|
||||
<div className="text-2xl font-bold">{stats.pending_transactions}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Total Addresses</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{stats.total_addresses.toLocaleString()}</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!stats && (
|
||||
<Card className="mb-8">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Live network stats are temporarily unavailable. Recent blocks and explorer tools are still available below.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Recent Blocks">
|
||||
<div className="space-y-2">
|
||||
{recentBlocks.map((block) => (
|
||||
<div key={block.number} className="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<Link href={`/blocks/${block.number}`} className="text-primary-600 hover:underline">
|
||||
Block #{block.number}
|
||||
</Link>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{block.transaction_count} transactions
|
||||
{recentBlocks.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Recent blocks are unavailable right now.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentBlocks.map((block) => (
|
||||
<div key={block.number} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Link href={`/blocks/${block.number}`} className="text-primary-600 hover:underline">
|
||||
Block #{block.number}
|
||||
</Link>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
|
||||
{block.transaction_count} transactions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Link href="/blocks" className="text-primary-600 hover:underline">
|
||||
View all blocks →
|
||||
</Link>
|
||||
@@ -107,8 +190,8 @@ export default function Home() {
|
||||
partner payload endpoints exposed through the explorer.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/liquidity" className="text-primary-600 hover:underline">
|
||||
Open liquidity access →
|
||||
<Link href="/routes" className="text-primary-600 hover:underline">
|
||||
Open routes and liquidity →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -123,6 +206,28 @@ export default function Home() {
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Bridge & Relay Monitoring">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Open the public bridge monitoring surface for relay status, mission-control links, bridge trace tooling,
|
||||
and the visual command center entry points.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/bridge" className="text-primary-600 hover:underline">
|
||||
Open bridge monitoring →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="More Explorer Tools">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Surface the restored WETH utilities, analytics shortcuts, operator links, system topology views, and
|
||||
other public tools that were previously hidden in the legacy explorer shell.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/more" className="text-primary-600 hover:underline">
|
||||
Open operations hub →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -3,9 +3,9 @@ import Link from 'next/link'
|
||||
|
||||
export default function WalletPage() {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Wallet & MetaMask</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-4 text-2xl font-bold sm:text-3xl">Wallet & MetaMask</h1>
|
||||
<p className="mb-6 text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base">
|
||||
Connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets. Use the token list URL so tokens and oracles are discoverable.
|
||||
</p>
|
||||
<AddToMetaMask />
|
||||
|
||||
@@ -1,48 +1 @@
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface AddressProps {
|
||||
address: string
|
||||
chainId?: number
|
||||
showCopy?: boolean
|
||||
showENS?: boolean
|
||||
truncate?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Address({
|
||||
address,
|
||||
chainId,
|
||||
showCopy = true,
|
||||
showENS = false,
|
||||
truncate = false,
|
||||
className,
|
||||
}: AddressProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const displayAddress = truncate
|
||||
? `${address.slice(0, 6)}...${address.slice(-4)}`
|
||||
: address
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(address)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('flex items-center gap-2', className)}>
|
||||
<span className="font-mono text-sm">{displayAddress}</span>
|
||||
{showCopy && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
title="Copy address"
|
||||
>
|
||||
{copied ? '✓' : '📋'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Address } from '@/libs/frontend-ui-primitives/Address'
|
||||
|
||||
@@ -1,37 +1 @@
|
||||
import { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
'font-medium rounded-lg transition-colors',
|
||||
{
|
||||
'bg-primary-600 text-white hover:bg-primary-700': variant === 'primary',
|
||||
'bg-gray-200 text-gray-900 hover:bg-gray-300': variant === 'secondary',
|
||||
'bg-red-600 text-white hover:bg-red-700': variant === 'danger',
|
||||
'px-3 py-1.5 text-sm': size === 'sm',
|
||||
'px-4 py-2 text-base': size === 'md',
|
||||
'px-6 py-3 text-lg': size === 'lg',
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button } from '@/libs/frontend-ui-primitives/Button'
|
||||
|
||||
@@ -1,27 +1 @@
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function Card({ children, className, title }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-white dark:bg-gray-800 rounded-lg shadow-md p-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card } from '@/libs/frontend-ui-primitives/Card'
|
||||
|
||||
34
frontend/src/components/common/DetailRow.tsx
Normal file
34
frontend/src/components/common/DetailRow.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
valueClassName?: string
|
||||
}
|
||||
|
||||
export function DetailRow({
|
||||
label,
|
||||
children,
|
||||
className,
|
||||
labelClassName,
|
||||
valueClassName,
|
||||
}: DetailRowProps) {
|
||||
return (
|
||||
<div className={clsx('flex flex-col gap-1.5 sm:flex-row sm:items-start sm:gap-4', className)}>
|
||||
<dt
|
||||
className={clsx(
|
||||
'text-sm font-semibold text-gray-700 dark:text-gray-300 sm:w-36 sm:shrink-0',
|
||||
labelClassName,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</dt>
|
||||
<dd className={clsx('min-w-0 text-sm text-gray-900 dark:text-gray-100', valueClassName)}>
|
||||
{children}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
frontend/src/components/common/ExplorerChrome.tsx
Normal file
13
frontend/src/components/common/ExplorerChrome.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Navbar from './Navbar'
|
||||
import Footer from './Footer'
|
||||
|
||||
export default function ExplorerChrome({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<Navbar />
|
||||
<div className="flex-1">{children}</div>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,10 +8,10 @@ export default function Footer() {
|
||||
|
||||
return (
|
||||
<footer className="mt-auto border-t border-gray-200 dark:border-gray-700 bg-white/90 dark:bg-gray-900/90 backdrop-blur">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid gap-6 md:grid-cols-[1.5fr_1fr_1fr]">
|
||||
<div className="space-y-3">
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="grid gap-4 sm:gap-6 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white sm:text-lg">
|
||||
SolaceScanScout
|
||||
</div>
|
||||
<p className="max-w-xl text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
@@ -24,13 +24,16 @@ export default function Footer() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
|
||||
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Resources
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><a className={footerLinkClass} href="/docs.html">Documentation</a></li>
|
||||
<li><Link className={footerLinkClass} href="/bridge">Bridge Monitoring</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/liquidity">Liquidity Access</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/more">More Tools</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/addresses">Addresses</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/watchlist">Watchlist</Link></li>
|
||||
<li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li>
|
||||
@@ -39,7 +42,7 @@ export default function Footer() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
|
||||
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Contact
|
||||
</div>
|
||||
@@ -56,6 +59,12 @@ export default function Footer() {
|
||||
explorer.d-bis.org/snap/
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Command center:{' '}
|
||||
<a className={footerLinkClass} href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer">
|
||||
Chain 138 visual map
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-gray-500 dark:text-gray-500">
|
||||
Questions about the explorer, chain metadata, route discovery, or liquidity access
|
||||
can be sent to the support mailbox above.
|
||||
|
||||
@@ -86,26 +86,42 @@ export default function Navbar() {
|
||||
const [exploreOpen, setExploreOpen] = useState(false)
|
||||
const [toolsOpen, setToolsOpen] = useState(false)
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setMobileMenuOpen((open) => {
|
||||
const nextOpen = !open
|
||||
if (!nextOpen) {
|
||||
setExploreOpen(false)
|
||||
setToolsOpen(false)
|
||||
}
|
||||
return nextOpen
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-4 md:gap-8">
|
||||
<div className="flex h-14 items-center justify-between sm:h-16">
|
||||
<div className="flex min-w-0 items-center gap-3 md:gap-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="group inline-flex flex-col rounded-xl px-3 py-2 text-xl font-bold text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-gray-700/70"
|
||||
className="group inline-flex max-w-[calc(100vw-5rem)] flex-col rounded-xl px-2 py-1.5 text-base font-bold text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-gray-700/70 sm:max-w-none sm:px-3 sm:py-2 sm:text-xl"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label="Go to explorer home"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600 text-white shadow-sm transition-transform group-hover:-translate-y-0.5 dark:bg-primary-500">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary-600 text-white shadow-sm transition-transform group-hover:-translate-y-0.5 dark:bg-primary-500 sm:h-8 sm:w-8">
|
||||
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M12 2.5 3.5 6.5v11l8.5 4 8.5-4v-11L12 2.5Zm0 2.24 6.44 3.03L12 10.8 5.56 7.77 12 4.74Zm-7 4.63L11 13.1v6.07L5 16.4V9.37Zm9 9.8v-6.07l6-2.92v6.03l-6 2.96Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>SolaceScanScout</span>
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="sm:hidden">SolaceScan</span>
|
||||
<span className="hidden sm:inline">SolaceScanScout</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 hidden text-xs font-normal text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200 sm:block">
|
||||
The Defi Oracle Meta Explorer
|
||||
</span>
|
||||
<span className="mt-0.5 text-xs font-normal text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200">The Defi Oracle Meta Explorer</span>
|
||||
</Link>
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
<NavDropdown
|
||||
@@ -117,6 +133,12 @@ export default function Navbar() {
|
||||
<DropdownItem href="/transactions" icon={<span className="text-gray-400">⇄</span>}>Transactions</DropdownItem>
|
||||
<DropdownItem href="/addresses" icon={<span className="text-gray-400">⌗</span>}>Addresses</DropdownItem>
|
||||
</NavDropdown>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className={`hidden md:inline-flex items-center px-3 py-2 rounded-md ${pathname.startsWith('/wallet') ? navLinkActive : navLink}`}
|
||||
>
|
||||
Wallet
|
||||
</Link>
|
||||
<NavDropdown
|
||||
label="Tools"
|
||||
icon={<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>}
|
||||
@@ -127,6 +149,10 @@ export default function Navbar() {
|
||||
<DropdownItem href="/watchlist">Watchlist</DropdownItem>
|
||||
<DropdownItem href="/wallet">Wallet</DropdownItem>
|
||||
<DropdownItem href="/liquidity">Liquidity</DropdownItem>
|
||||
<DropdownItem href="/bridge">Bridge</DropdownItem>
|
||||
<DropdownItem href="/routes">Routes</DropdownItem>
|
||||
<DropdownItem href="/more">More</DropdownItem>
|
||||
<DropdownItem href="/chain138-command-center.html" external>Command Center</DropdownItem>
|
||||
</NavDropdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,7 +160,7 @@ export default function Navbar() {
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
onClick={() => setMobileMenuOpen((o) => !o)}
|
||||
onClick={toggleMobileMenu}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
@@ -147,7 +173,7 @@ export default function Navbar() {
|
||||
</div>
|
||||
</div>
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden py-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="border-t border-gray-200 py-2 pb-3 dark:border-gray-700 md:hidden">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link href="/" className={`px-3 py-2.5 rounded-md ${pathname === '/' ? navLinkActive : navLink}`} onClick={() => setMobileMenuOpen(false)}>Home</Link>
|
||||
<div className="relative">
|
||||
@@ -176,6 +202,10 @@ export default function Navbar() {
|
||||
<li><Link href="/watchlist" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Watchlist</Link></li>
|
||||
<li><Link href="/wallet" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Wallet</Link></li>
|
||||
<li><Link href="/liquidity" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Liquidity</Link></li>
|
||||
<li><Link href="/bridge" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Bridge</Link></li>
|
||||
<li><Link href="/routes" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Routes</Link></li>
|
||||
<li><Link href="/more" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>More</Link></li>
|
||||
<li><a href="/chain138-command-center.html" className={`block px-3 py-2 rounded-md ${navLink}`} target="_blank" rel="noopener noreferrer" onClick={() => setMobileMenuOpen(false)}>Command Center</a></li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,58 +1 @@
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface Column<T> {
|
||||
header: string
|
||||
accessor: (row: T) => ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface TableProps<T> {
|
||||
columns: Column<T>[]
|
||||
data: T[]
|
||||
className?: string
|
||||
/** Stable key for each row (e.g. row => row.id or row => row.hash). Falls back to index if not provided. */
|
||||
keyExtractor?: (row: T) => string | number
|
||||
}
|
||||
|
||||
export function Table<T>({ columns, data, className, keyExtractor }: TableProps<T>) {
|
||||
return (
|
||||
<div className={clsx('overflow-x-auto', className)}>
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={index}
|
||||
className={clsx(
|
||||
'px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider',
|
||||
column.className
|
||||
)}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{data.map((row, rowIndex) => (
|
||||
<tr key={keyExtractor ? keyExtractor(row) : rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
className={clsx(
|
||||
'px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100',
|
||||
column.className
|
||||
)}
|
||||
>
|
||||
{column.accessor(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Table } from '@/libs/frontend-ui-primitives/Table'
|
||||
|
||||
177
frontend/src/components/explorer/AnalyticsOperationsPage.tsx
Normal file
177
frontend/src/components/explorer/AnalyticsOperationsPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import { blocksApi, type Block } from '@/services/api/blocks'
|
||||
import {
|
||||
missionControlApi,
|
||||
type MissionControlBridgeStatusResponse,
|
||||
type MissionControlChainStatus,
|
||||
} from '@/services/api/missionControl'
|
||||
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
import { transactionsApi, type Transaction } from '@/services/api/transactions'
|
||||
import OperationsPageShell, {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
function getChainStatus(bridgeStatus: MissionControlBridgeStatusResponse | null): MissionControlChainStatus | null {
|
||||
const chains = bridgeStatus?.data?.chains
|
||||
if (!chains) return null
|
||||
const [firstChain] = Object.values(chains)
|
||||
return firstChain || null
|
||||
}
|
||||
|
||||
export default function AnalyticsOperationsPage() {
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(null)
|
||||
const [blocks, setBlocks] = useState<Block[]>([])
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.analytics
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [statsResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
|
||||
statsApi.get(),
|
||||
blocksApi.list({ chain_id: 138, page: 1, page_size: 5 }),
|
||||
transactionsApi.list(138, 1, 5),
|
||||
missionControlApi.getBridgeStatus(),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (statsResult.status === 'fulfilled') setStats(statsResult.value)
|
||||
if (blocksResult.status === 'fulfilled') setBlocks(blocksResult.value.data)
|
||||
if (transactionsResult.status === 'fulfilled') setTransactions(transactionsResult.value.data)
|
||||
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
|
||||
|
||||
const failedCount = [statsResult, blocksResult, transactionsResult, bridgeResult].filter(
|
||||
(result) => result.status === 'rejected'
|
||||
).length
|
||||
|
||||
if (failedCount === 4) {
|
||||
setLoadingError('Analytics data is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(error instanceof Error ? error.message : 'Analytics data is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const chainStatus = useMemo(() => getChainStatus(bridgeStatus), [bridgeStatus])
|
||||
|
||||
return (
|
||||
<OperationsPageShell page={page}>
|
||||
{loadingError ? (
|
||||
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
||||
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Total Blocks"
|
||||
value={formatNumber(stats?.total_blocks)}
|
||||
description="Current block count from the public Blockscout stats endpoint."
|
||||
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Transactions"
|
||||
value={formatNumber(stats?.total_transactions)}
|
||||
description="Total transactions currently indexed by the public explorer."
|
||||
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Addresses"
|
||||
value={formatNumber(stats?.total_addresses)}
|
||||
description="Known addresses from the public stats surface."
|
||||
/>
|
||||
<MetricCard
|
||||
title="Chain Head"
|
||||
value={chainStatus?.head_age_sec != null ? `${Math.round(chainStatus.head_age_sec)}s` : 'Unknown'}
|
||||
description={
|
||||
chainStatus?.latency_ms != null
|
||||
? `RPC latency ${Math.round(chainStatus.latency_ms)}ms on Chain 138.`
|
||||
: 'Latest public RPC head age from mission control.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Recent Blocks">
|
||||
<div className="space-y-4">
|
||||
{blocks.map((block) => (
|
||||
<div
|
||||
key={block.hash}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Block {formatNumber(block.number)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{truncateMiddle(block.hash)} · miner {truncateMiddle(block.miner)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatNumber(block.transaction_count)} tx · {relativeAge(block.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{blocks.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No recent block data available.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Recent Transactions">
|
||||
<div className="space-y-4">
|
||||
{transactions.map((transaction) => (
|
||||
<div
|
||||
key={transaction.hash}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{truncateMiddle(transaction.hash, 12, 10)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Block {formatNumber(transaction.block_number)} · from {truncateMiddle(transaction.from_address)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge
|
||||
status={transaction.status === 1 ? 'success' : 'failed'}
|
||||
tone={transaction.status === 1 ? 'normal' : 'danger'}
|
||||
/>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{relativeAge(transaction.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{transactions.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No recent transaction data available.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</OperationsPageShell>
|
||||
)
|
||||
}
|
||||
326
frontend/src/components/explorer/BridgeMonitoringPage.tsx
Normal file
326
frontend/src/components/explorer/BridgeMonitoringPage.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import {
|
||||
getMissionControlRelayLabel,
|
||||
getMissionControlRelays,
|
||||
missionControlApi,
|
||||
type MissionControlBridgeStatusResponse,
|
||||
type MissionControlRelayPayload,
|
||||
type MissionControlRelaySnapshot,
|
||||
} from '@/services/api/missionControl'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
|
||||
type FeedState = 'connecting' | 'live' | 'fallback'
|
||||
|
||||
interface RelayLaneCard {
|
||||
key: string
|
||||
label: string
|
||||
status: string
|
||||
profile: string
|
||||
sourceChain: string
|
||||
destinationChain: string
|
||||
queueSize: number
|
||||
processed: number
|
||||
failed: number
|
||||
lastPolled: string
|
||||
bridgeAddress: string
|
||||
}
|
||||
|
||||
const relayOrder = ['mainnet_cw', 'mainnet_weth', 'bsc', 'avax', 'avax_cw', 'avax_to_138']
|
||||
|
||||
function relativeAge(isoString?: string): string {
|
||||
if (!isoString) return 'Unknown'
|
||||
const parsed = Date.parse(isoString)
|
||||
if (!Number.isFinite(parsed)) return 'Unknown'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
function shortAddress(value?: string): string {
|
||||
if (!value) return 'Unspecified'
|
||||
if (value.length <= 14) return value
|
||||
return `${value.slice(0, 6)}...${value.slice(-4)}`
|
||||
}
|
||||
|
||||
function resolveSnapshot(relay?: MissionControlRelayPayload): MissionControlRelaySnapshot | null {
|
||||
return relay?.url_probe?.body || relay?.file_snapshot || null
|
||||
}
|
||||
|
||||
function laneToneClasses(status: string): string {
|
||||
const normalized = status.toLowerCase()
|
||||
if (['degraded', 'stale', 'stopped', 'down', 'snapshot-error'].includes(normalized)) {
|
||||
return 'border-red-200 bg-red-50/80 dark:border-red-900/60 dark:bg-red-950/20'
|
||||
}
|
||||
if (['paused', 'starting'].includes(normalized)) {
|
||||
return 'border-amber-200 bg-amber-50/80 dark:border-amber-900/60 dark:bg-amber-950/20'
|
||||
}
|
||||
return 'border-emerald-200 bg-emerald-50/80 dark:border-emerald-900/60 dark:bg-emerald-950/20'
|
||||
}
|
||||
|
||||
function statusPillClasses(status: string): string {
|
||||
const normalized = status.toLowerCase()
|
||||
if (['degraded', 'stale', 'stopped', 'down', 'snapshot-error'].includes(normalized)) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-100'
|
||||
}
|
||||
if (['paused', 'starting'].includes(normalized)) {
|
||||
return 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-100'
|
||||
}
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-100'
|
||||
}
|
||||
|
||||
function ActionLink({
|
||||
href,
|
||||
label,
|
||||
external,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
external?: boolean
|
||||
}) {
|
||||
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
|
||||
const text = `${label} ->`
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BridgeMonitoringPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [feedState, setFeedState] = useState<FeedState>('connecting')
|
||||
const page = explorerFeaturePages.bridge
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadSnapshot = async () => {
|
||||
try {
|
||||
const snapshot = await missionControlApi.getBridgeStatus()
|
||||
if (!cancelled) {
|
||||
setBridgeStatus(snapshot)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to load bridge monitoring snapshot:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSnapshot()
|
||||
|
||||
const unsubscribe = missionControlApi.subscribeBridgeStatus(
|
||||
(status) => {
|
||||
if (!cancelled) {
|
||||
setBridgeStatus(status)
|
||||
setFeedState('live')
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
if (!cancelled) {
|
||||
setFeedState('fallback')
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Bridge monitoring live stream issue:', error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const relayLanes = useMemo((): RelayLaneCard[] => {
|
||||
const relays = getMissionControlRelays(bridgeStatus)
|
||||
if (!relays) return []
|
||||
|
||||
const orderIndex = new Map(relayOrder.map((key, index) => [key, index]))
|
||||
|
||||
return Object.entries(relays)
|
||||
.map(([key, relay]) => {
|
||||
const snapshot = resolveSnapshot(relay)
|
||||
const status = String(snapshot?.status || (relay.file_snapshot_error ? 'snapshot-error' : 'configured')).toLowerCase()
|
||||
return {
|
||||
key,
|
||||
label: getMissionControlRelayLabel(key),
|
||||
status,
|
||||
profile: snapshot?.service?.profile || key,
|
||||
sourceChain: snapshot?.source?.chain_name || 'Unknown',
|
||||
destinationChain: snapshot?.destination?.chain_name || 'Unknown',
|
||||
queueSize: snapshot?.queue?.size ?? 0,
|
||||
processed: snapshot?.queue?.processed ?? 0,
|
||||
failed: snapshot?.queue?.failed ?? 0,
|
||||
lastPolled: relativeAge(snapshot?.last_source_poll?.at),
|
||||
bridgeAddress:
|
||||
snapshot?.destination?.relay_bridge_default ||
|
||||
snapshot?.destination?.relay_bridge ||
|
||||
snapshot?.source?.bridge_filter ||
|
||||
'',
|
||||
}
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftIndex = orderIndex.get(left.key) ?? Number.MAX_SAFE_INTEGER
|
||||
const rightIndex = orderIndex.get(right.key) ?? Number.MAX_SAFE_INTEGER
|
||||
return leftIndex - rightIndex || left.label.localeCompare(right.label)
|
||||
})
|
||||
}, [bridgeStatus])
|
||||
|
||||
const chainStatus = bridgeStatus?.data?.chains?.['138']
|
||||
const overallStatus = bridgeStatus?.data?.status || 'unknown'
|
||||
const checkedAt = relativeAge(bridgeStatus?.data?.checked_at)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 max-w-4xl sm:mb-8">
|
||||
<div className="mb-3 inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
{page.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{page.note ? (
|
||||
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
|
||||
{page.note}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 grid gap-4 lg:grid-cols-3">
|
||||
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
|
||||
Relay Fleet
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{overallStatus}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{relayLanes.length} managed lanes visible
|
||||
</div>
|
||||
<div className="mt-2 text-xs font-medium uppercase tracking-wide text-sky-800/80 dark:text-sky-100/80">
|
||||
Feed: {feedState === 'live' ? 'Live SSE' : feedState === 'fallback' ? 'Snapshot fallback' : 'Connecting'}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
Chain 138 RPC
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{chainStatus?.status || 'unknown'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Head age: {chainStatus?.head_age_sec != null ? `${chainStatus.head_age_sec.toFixed(1)}s` : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Latency: {chainStatus?.latency_ms != null ? `${chainStatus.latency_ms}ms` : 'Unknown'}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
Last Check
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{checkedAt}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Public status JSON and live stream are both active.
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ActionLink href="/explorer-api/v1/track1/bridge/status" label="Open status JSON" external />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{relayLanes.map((lane) => (
|
||||
<Card key={lane.key} className={`border ${laneToneClasses(lane.status)}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{lane.label}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{`${lane.sourceChain} -> ${lane.destinationChain}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide ${statusPillClasses(lane.status)}`}>
|
||||
{lane.status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Profile</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{lane.profile}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Queue</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{lane.queueSize}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Processed</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{lane.processed}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Failed</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{lane.failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Last polled: {lane.lastPolled}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Bridge: {shortAddress(lane.bridgeAddress)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{action.title}
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink
|
||||
href={action.href}
|
||||
label={action.label}
|
||||
external={'external' in action ? action.external : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
frontend/src/components/explorer/FeatureLandingPage.tsx
Normal file
66
frontend/src/components/explorer/FeatureLandingPage.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import type { ExplorerFeatureAction, ExplorerFeaturePage } from '@/data/explorerOperations'
|
||||
|
||||
function ActionLink({ action }: { action: ExplorerFeatureAction }) {
|
||||
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
|
||||
const label = `${action.label} ->`
|
||||
|
||||
if (action.external) {
|
||||
return (
|
||||
<a href={action.href} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={action.href} className={className}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FeatureLandingPage({ page }: { page: ExplorerFeaturePage }) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 max-w-4xl sm:mb-8">
|
||||
<div className="mb-3 inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
{page.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{page.note ? (
|
||||
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
|
||||
{page.note}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{action.title}
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink action={action} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
419
frontend/src/components/explorer/LiquidityOperationsPage.tsx
Normal file
419
frontend/src/components/explorer/LiquidityOperationsPage.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { configApi, type TokenListResponse } from '@/services/api/config'
|
||||
import {
|
||||
aggregateLiquidityPools,
|
||||
featuredLiquiditySymbols,
|
||||
getLivePlannerProviders,
|
||||
getRouteBackedPoolAddresses,
|
||||
getTopLiquidityRoutes,
|
||||
selectFeaturedLiquidityTokens,
|
||||
type AggregatedLiquidityPool,
|
||||
} from '@/services/api/liquidity'
|
||||
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
|
||||
import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import {
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
const tokenAggregationV1Base = '/token-aggregation/api/v1'
|
||||
const tokenAggregationV2Base = '/token-aggregation/api/v2'
|
||||
|
||||
interface TokenPoolRecord {
|
||||
symbol: string
|
||||
pools: MissionControlLiquidityPool[]
|
||||
}
|
||||
|
||||
function routePairLabel(routeId: string, routeLabel: string, tokenIn?: string, tokenOut?: string): string {
|
||||
return [tokenIn, tokenOut].filter(Boolean).join(' / ') || routeLabel || routeId
|
||||
}
|
||||
|
||||
export default function LiquidityOperationsPage() {
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
|
||||
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>([])
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult] =
|
||||
await Promise.allSettled([
|
||||
configApi.getTokenList(),
|
||||
routesApi.getRouteMatrix(),
|
||||
plannerApi.getCapabilities(),
|
||||
plannerApi.getInternalExecutionPlan(),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value)
|
||||
if (routeMatrixResult.status === 'fulfilled') setRouteMatrix(routeMatrixResult.value)
|
||||
if (plannerCapabilitiesResult.status === 'fulfilled') setPlannerCapabilities(plannerCapabilitiesResult.value)
|
||||
if (planResult.status === 'fulfilled') setInternalPlan(planResult.value)
|
||||
|
||||
if (tokenListResult.status === 'fulfilled') {
|
||||
const featuredTokens = selectFeaturedLiquidityTokens(tokenListResult.value.tokens || [])
|
||||
const poolResults = await Promise.allSettled(
|
||||
featuredTokens.map(async (token) => ({
|
||||
symbol: token.symbol,
|
||||
pools: (await routesApi.getTokenPools(token.address)).pools || [],
|
||||
}))
|
||||
)
|
||||
|
||||
if (!cancelled) {
|
||||
setTokenPoolRecords(
|
||||
poolResults
|
||||
.filter((result): result is PromiseFulfilledResult<TokenPoolRecord> => result.status === 'fulfilled')
|
||||
.map((result) => result.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const failedCount = [
|
||||
tokenListResult,
|
||||
routeMatrixResult,
|
||||
plannerCapabilitiesResult,
|
||||
planResult,
|
||||
].filter((result) => result.status === 'rejected').length
|
||||
|
||||
if (failedCount === 4) {
|
||||
setLoadingError('Live liquidity data is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(
|
||||
error instanceof Error ? error.message : 'Live liquidity data is temporarily unavailable from the public explorer APIs.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const featuredTokens = useMemo(
|
||||
() => selectFeaturedLiquidityTokens(tokenList?.tokens || []),
|
||||
[tokenList?.tokens]
|
||||
)
|
||||
const aggregatedPools = useMemo(
|
||||
() => aggregateLiquidityPools(tokenPoolRecords),
|
||||
[tokenPoolRecords]
|
||||
)
|
||||
const livePlannerProviders = useMemo(
|
||||
() => getLivePlannerProviders(plannerCapabilities),
|
||||
[plannerCapabilities]
|
||||
)
|
||||
const routeBackedPoolAddresses = useMemo(
|
||||
() => getRouteBackedPoolAddresses(routeMatrix),
|
||||
[routeMatrix]
|
||||
)
|
||||
const highlightedRoutes = useMemo(
|
||||
() => getTopLiquidityRoutes(routeMatrix, 6),
|
||||
[routeMatrix]
|
||||
)
|
||||
const dexCount = useMemo(
|
||||
() => new Set(aggregatedPools.map((pool) => pool.dex).filter(Boolean)).size,
|
||||
[aggregatedPools]
|
||||
)
|
||||
|
||||
const insightLines = useMemo(
|
||||
() => [
|
||||
`${formatNumber(routeMatrix?.counts?.liveSwapRoutes)} live swap routes and ${formatNumber(routeMatrix?.counts?.liveBridgeRoutes)} bridge routes are currently published in the public route matrix.`,
|
||||
`${formatNumber(aggregatedPools.length)} unique pools were discovered across ${formatNumber(featuredTokens.length)} featured Chain 138 liquidity tokens.`,
|
||||
`${formatNumber(livePlannerProviders.length)} planner providers are live, and the current internal fallback decision is ${internalPlan?.plannerResponse?.decision || 'unknown'}.`,
|
||||
`${formatNumber(routeBackedPoolAddresses.length)} unique pool addresses are referenced directly by the current live route legs.`,
|
||||
],
|
||||
[routeMatrix, aggregatedPools.length, featuredTokens.length, livePlannerProviders.length, internalPlan?.plannerResponse?.decision, routeBackedPoolAddresses.length]
|
||||
)
|
||||
|
||||
const endpointCards = [
|
||||
{
|
||||
name: 'Canonical route matrix',
|
||||
method: 'GET',
|
||||
href: `${tokenAggregationV1Base}/routes/matrix?includeNonLive=true`,
|
||||
notes: 'All live and non-live route inventory with counts and pool-backed legs.',
|
||||
},
|
||||
{
|
||||
name: 'Planner capabilities',
|
||||
method: 'GET',
|
||||
href: `${tokenAggregationV2Base}/providers/capabilities?chainId=138`,
|
||||
notes: 'Live provider inventory, published pair coverage, and execution modes.',
|
||||
},
|
||||
{
|
||||
name: 'Internal execution plan',
|
||||
method: 'POST',
|
||||
href: `${tokenAggregationV2Base}/routes/internal-execution-plan`,
|
||||
notes: 'Returns the direct-pool fallback posture that the operator surfaces already verify.',
|
||||
},
|
||||
{
|
||||
name: 'Mission-control token pools',
|
||||
method: 'GET',
|
||||
href: `/explorer-api/v1/mission-control/liquidity/token/${featuredTokens[0]?.address || '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'}/pools`,
|
||||
notes: 'Cached public pool inventory for a specific Chain 138 token.',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-8 max-w-4xl">
|
||||
<div className="mb-3 inline-flex rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">
|
||||
Chain 138 Liquidity Access
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
Public liquidity, route discovery, and execution access points
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
This page now reads the live explorer APIs instead of hardcoded pool snapshots. It pulls the
|
||||
public route matrix, planner capabilities, and mission-control token pool inventory together
|
||||
so integrators can inspect what Chain 138 is actually serving right now.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loadingError ? (
|
||||
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
||||
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
|
||||
Live Pools
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(aggregatedPools.length)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Dedupe of mission-control pool inventory across featured Chain 138 tokens.
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
|
||||
Route Coverage
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(routeMatrix?.counts?.filteredLiveRoutes)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{formatNumber(routeMatrix?.counts?.liveSwapRoutes)} swaps and {formatNumber(routeMatrix?.counts?.liveBridgeRoutes)} bridges.
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Planner providers</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(livePlannerProviders.length)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatNumber(dexCount)} DEX families in the current discovered pools.
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Fallback posture</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{internalPlan?.plannerResponse?.decision || 'unknown'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Execution contract {truncateMiddle(internalPlan?.execution?.contractAddress)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<Card title="Live Pool Snapshot">
|
||||
<div className="space-y-4">
|
||||
{aggregatedPools.slice(0, 8).map((pool) => (
|
||||
<div
|
||||
key={pool.address}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')}
|
||||
</div>
|
||||
<div className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">
|
||||
Pool: {pool.address}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{pool.dex || 'Unknown DEX'} · TVL {formatCurrency(pool.tvl)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Seen from {pool.sourceSymbols.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{aggregatedPools.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No live pool inventory is available right now.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="What Integrators Need To Know">
|
||||
<div className="space-y-3 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{insightLines.map((item) => (
|
||||
<p key={item}>{item}</p>
|
||||
))}
|
||||
<p>
|
||||
Featured symbols in this view: {featuredLiquiditySymbols.join(', ')}.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Top Route-Backed Liquidity Paths">
|
||||
<div className="space-y-4">
|
||||
{highlightedRoutes.map((route) => (
|
||||
<div
|
||||
key={route.routeId}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{routePairLabel(route.routeId, route.label || '', route.tokenInSymbol, route.tokenOutSymbol)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatNumber((route.legs || []).length)} legs · {formatNumber((route.legs || []).filter((leg) => leg.poolAddress).length)} pool-backed
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{route.aggregatorFamilies?.join(', ') || 'No provider families listed'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{(route.legs || [])
|
||||
.map((leg) => leg.poolAddress)
|
||||
.filter(Boolean)
|
||||
.map((address) => truncateMiddle(address))
|
||||
.join(' · ') || 'No pool addresses published'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Featured Token Coverage">
|
||||
<div className="space-y-4">
|
||||
{featuredTokens.map((token) => {
|
||||
const matchingRecord = tokenPoolRecords.find((record) => record.symbol === token.symbol)
|
||||
return (
|
||||
<div
|
||||
key={token.address}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{token.symbol}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{token.name || 'Unnamed token'} · {formatNumber(matchingRecord?.pools.length || 0)} mission-control pools
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{truncateMiddle(token.address, 10, 8)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Explorer Access Points">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{endpointCards.map((endpoint) => (
|
||||
<a
|
||||
key={endpoint.href}
|
||||
href={endpoint.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 transition hover:border-primary-400 hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{endpoint.name}</div>
|
||||
<span className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
{endpoint.method}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 break-all rounded-xl bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||||
{endpoint.href}
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-400">{endpoint.notes}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Quick Request Examples">
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
`GET ${tokenAggregationV1Base}/routes/matrix?includeNonLive=true`,
|
||||
`GET ${tokenAggregationV2Base}/providers/capabilities?chainId=138`,
|
||||
`POST ${tokenAggregationV2Base}/routes/internal-execution-plan`,
|
||||
`GET /explorer-api/v1/mission-control/liquidity/token/${featuredTokens[0]?.address || '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'}/pools`,
|
||||
].map((example) => (
|
||||
<div key={example} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<code className="block break-all text-xs leading-6 text-gray-700 dark:text-gray-300">
|
||||
{example}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Related Explorer Tools">
|
||||
<div className="space-y-4 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
Use the wallet page for network onboarding, the pools page for a tighter live inventory
|
||||
view, and this page for the broader route and execution surfaces.
|
||||
</p>
|
||||
<p>
|
||||
The live route matrix was updated {relativeAge(routeMatrix?.updated)}, and the current
|
||||
route-backed pool set references {formatNumber(routeBackedPoolAddresses.length)} unique
|
||||
pool addresses.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/pools"
|
||||
className="rounded-full bg-primary-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-primary-700"
|
||||
>
|
||||
Open pools page
|
||||
</Link>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Open wallet tools
|
||||
</Link>
|
||||
<a
|
||||
href="/docs.html"
|
||||
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Explorer docs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
301
frontend/src/components/explorer/MoreOperationsPage.tsx
Normal file
301
frontend/src/components/explorer/MoreOperationsPage.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config'
|
||||
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
|
||||
function relativeAge(isoString?: string): string {
|
||||
if (!isoString) return 'Unknown'
|
||||
const parsed = Date.parse(isoString)
|
||||
if (!Number.isFinite(parsed)) return 'Unknown'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
function ActionLink({
|
||||
href,
|
||||
label,
|
||||
external,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
external?: boolean
|
||||
}) {
|
||||
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
|
||||
const text = `${label} ->`
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MoreOperationsPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(null)
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
|
||||
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(null)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.more
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [bridgeResult, routesResult, networksResult, tokenListResult, capabilitiesResult] =
|
||||
await Promise.allSettled([
|
||||
missionControlApi.getBridgeStatus(),
|
||||
routesApi.getRouteMatrix(),
|
||||
configApi.getNetworks(),
|
||||
configApi.getTokenList(),
|
||||
configApi.getCapabilities(),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
|
||||
if (routesResult.status === 'fulfilled') setRouteMatrix(routesResult.value)
|
||||
if (networksResult.status === 'fulfilled') setNetworksConfig(networksResult.value)
|
||||
if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value)
|
||||
if (capabilitiesResult.status === 'fulfilled') setCapabilities(capabilitiesResult.value)
|
||||
|
||||
const failedCount = [
|
||||
bridgeResult,
|
||||
routesResult,
|
||||
networksResult,
|
||||
tokenListResult,
|
||||
capabilitiesResult,
|
||||
].filter((result) => result.status === 'rejected').length
|
||||
|
||||
if (failedCount === 5) {
|
||||
setLoadingError('Public explorer operations data is temporarily unavailable.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(
|
||||
error instanceof Error ? error.message : 'Public explorer operations data is temporarily unavailable.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const relayCount = useMemo(() => {
|
||||
const relays = getMissionControlRelays(bridgeStatus)
|
||||
return relays ? Object.keys(relays).length : 0
|
||||
}, [bridgeStatus])
|
||||
|
||||
const totalQueue = useMemo(() => {
|
||||
const relays = getMissionControlRelays(bridgeStatus)
|
||||
if (!relays) return 0
|
||||
return Object.values(relays).reduce((sum, relay) => {
|
||||
const queueSize = relay.url_probe?.body?.queue?.size ?? relay.file_snapshot?.queue?.size ?? 0
|
||||
return sum + queueSize
|
||||
}, 0)
|
||||
}, [bridgeStatus])
|
||||
|
||||
const tokenChainCoverage = useMemo(() => {
|
||||
return new Set((tokenList?.tokens || []).map((token) => token.chainId).filter(Boolean)).size
|
||||
}, [tokenList])
|
||||
|
||||
const topSymbols = useMemo(() => {
|
||||
return Array.from(
|
||||
new Set((tokenList?.tokens || []).map((token) => token.symbol).filter(Boolean) as string[])
|
||||
).slice(0, 8)
|
||||
}, [tokenList])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 max-w-4xl sm:mb-8">
|
||||
<div className="mb-3 inline-flex rounded-full border border-violet-200 bg-violet-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-violet-700">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
{page.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{page.note ? (
|
||||
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
|
||||
{page.note}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{loadingError ? (
|
||||
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
||||
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
|
||||
Bridge Fleet
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{bridgeStatus?.data?.status || 'unknown'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{relayCount} managed lanes · queue {totalQueue}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
|
||||
Route Coverage
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{routeMatrix?.counts?.filteredLiveRoutes ?? 0}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{routeMatrix?.counts?.liveSwapRoutes ?? 0} swaps · {routeMatrix?.counts?.liveBridgeRoutes ?? 0} bridges
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
Wallet Surface
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{networksConfig?.chains?.length ?? 0}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Chains · {(tokenList?.tokens || []).length} tokens across {tokenChainCoverage} networks
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
RPC Capabilities
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{capabilities?.http?.supportedMethods?.length ?? 0}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
HTTP methods · {capabilities?.tracing?.supportedMethods?.length ?? 0} tracing methods
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Operations Snapshot">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Bridge checked</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{relativeAge(bridgeStatus?.data?.checked_at)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Public mission-control snapshot freshness.
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Route matrix updated</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{relativeAge(routeMatrix?.updated)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Token-aggregation route inventory timestamp.
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Default chain</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{networksConfig?.defaultChainId ?? 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Wallet onboarding points at Chain 138 by default.
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Wallet support</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{capabilities?.walletSupport?.walletAddEthereumChain && capabilities?.walletSupport?.walletWatchAsset
|
||||
? 'Ready'
|
||||
: 'Partial'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
`wallet_addEthereumChain` and `wallet_watchAsset` compatibility.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Public Config Highlights">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Featured symbols</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{topSymbols.map((symbol) => (
|
||||
<span
|
||||
key={symbol}
|
||||
className="rounded-full bg-primary-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||
>
|
||||
{symbol}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Tracing posture</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Supported: {(capabilities?.tracing?.supportedMethods || []).join(', ') || 'None'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Unsupported: {(capabilities?.tracing?.unsupportedMethods || []).join(', ') || 'None'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{action.title}
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink
|
||||
href={action.href}
|
||||
label={action.label}
|
||||
external={'external' in action ? action.external : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
frontend/src/components/explorer/OperationsPageShell.tsx
Normal file
148
frontend/src/components/explorer/OperationsPageShell.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import type { ExplorerFeatureAction, ExplorerFeaturePage } from '@/data/explorerOperations'
|
||||
|
||||
export type StatusTone = 'normal' | 'warning' | 'danger'
|
||||
|
||||
function ActionLink({ action }: { action: ExplorerFeatureAction }) {
|
||||
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
|
||||
const label = `${action.label} ->`
|
||||
|
||||
if (action.external) {
|
||||
return (
|
||||
<a href={action.href} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={action.href} className={className}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function relativeAge(isoString?: string): string {
|
||||
if (!isoString) return 'Unknown'
|
||||
const parsed = Date.parse(isoString)
|
||||
if (!Number.isFinite(parsed)) return 'Unknown'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
export function formatNumber(value?: number | null): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '0'
|
||||
return new Intl.NumberFormat('en-US').format(value)
|
||||
}
|
||||
|
||||
export function formatCurrency(value?: number | null): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 'Unknown'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export function truncateMiddle(value?: string, start = 8, end = 6): string {
|
||||
if (!value) return 'Unknown'
|
||||
if (value.length <= start + end + 3) return value
|
||||
return `${value.slice(0, start)}...${value.slice(-end)}`
|
||||
}
|
||||
|
||||
export function StatusBadge({
|
||||
status,
|
||||
tone = 'normal',
|
||||
}: {
|
||||
status: string
|
||||
tone?: StatusTone
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'danger'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-300'
|
||||
: tone === 'warning'
|
||||
? 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300'
|
||||
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
|
||||
|
||||
return (
|
||||
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold uppercase tracking-wide ${toneClass}`}>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function MetricCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
value: string
|
||||
description: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{value}</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">{description}</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OperationsPageShell({
|
||||
page,
|
||||
children,
|
||||
}: {
|
||||
page: ExplorerFeaturePage
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 max-w-4xl sm:mb-8">
|
||||
<div className="mb-3 inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
{page.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{page.note ? (
|
||||
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">{page.note}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{action.title}</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink action={action} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
193
frontend/src/components/explorer/OperatorOperationsPage.tsx
Normal file
193
frontend/src/components/explorer/OperatorOperationsPage.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import {
|
||||
getMissionControlRelays,
|
||||
getMissionControlRelayLabel,
|
||||
missionControlApi,
|
||||
type MissionControlBridgeStatusResponse,
|
||||
} from '@/services/api/missionControl'
|
||||
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
|
||||
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import OperationsPageShell, {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
|
||||
const normalized = String(status || 'unknown').toLowerCase()
|
||||
if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger'
|
||||
if (['paused', 'starting', 'unknown'].includes(normalized)) return 'warning'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
export default function OperatorOperationsPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.operator
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [bridgeResult, routesResult, capabilitiesResult, planResult] = await Promise.allSettled([
|
||||
missionControlApi.getBridgeStatus(),
|
||||
routesApi.getRouteMatrix(),
|
||||
plannerApi.getCapabilities(),
|
||||
plannerApi.getInternalExecutionPlan(),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
|
||||
if (routesResult.status === 'fulfilled') setRouteMatrix(routesResult.value)
|
||||
if (capabilitiesResult.status === 'fulfilled') setPlannerCapabilities(capabilitiesResult.value)
|
||||
if (planResult.status === 'fulfilled') setInternalPlan(planResult.value)
|
||||
|
||||
const failedCount = [bridgeResult, routesResult, capabilitiesResult, planResult].filter(
|
||||
(result) => result.status === 'rejected'
|
||||
).length
|
||||
|
||||
if (failedCount === 4) {
|
||||
setLoadingError('Operator telemetry is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(error instanceof Error ? error.message : 'Operator telemetry is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const relays = useMemo(() => getMissionControlRelays(bridgeStatus), [bridgeStatus])
|
||||
const relayEntries = useMemo(() => Object.entries(relays || {}), [relays])
|
||||
const totalQueue = useMemo(
|
||||
() =>
|
||||
relayEntries.reduce((sum, [, relay]) => {
|
||||
const queueSize = relay.url_probe?.body?.queue?.size ?? relay.file_snapshot?.queue?.size ?? 0
|
||||
return sum + queueSize
|
||||
}, 0),
|
||||
[relayEntries]
|
||||
)
|
||||
const providers = plannerCapabilities?.providers || []
|
||||
const liveProviders = providers.filter((provider) => provider.live)
|
||||
|
||||
return (
|
||||
<OperationsPageShell page={page}>
|
||||
{loadingError ? (
|
||||
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
||||
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Relay Fleet"
|
||||
value={bridgeStatus?.data?.status || 'unknown'}
|
||||
description={`${relayEntries.length} managed lanes · queue ${formatNumber(totalQueue)}`}
|
||||
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Live Routes"
|
||||
value={formatNumber(routeMatrix?.counts?.filteredLiveRoutes)}
|
||||
description={`${formatNumber(routeMatrix?.counts?.blockedOrPlannedRoutes)} planned or blocked routes remain in the matrix.`}
|
||||
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Planner Providers"
|
||||
value={formatNumber(liveProviders.length)}
|
||||
description={`${formatNumber(providers.length)} published providers in planner v2 capabilities.`}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Fallback Decision"
|
||||
value={internalPlan?.plannerResponse?.decision || 'unknown'}
|
||||
description={
|
||||
internalPlan?.execution?.contractAddress
|
||||
? `Execution contract ${truncateMiddle(internalPlan.execution.contractAddress)}`
|
||||
: 'Latest internal execution plan posture.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<Card title="Managed Relay Lanes">
|
||||
<div className="space-y-4">
|
||||
{relayEntries.map(([key, relay]) => {
|
||||
const snapshot = relay.url_probe?.body || relay.file_snapshot
|
||||
const status = snapshot?.status || 'unknown'
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{getMissionControlRelayLabel(key)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{snapshot?.destination?.chain_name || 'Unknown destination'} · queue {formatNumber(snapshot?.queue?.size ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={status} tone={relayTone(status)} />
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Last source poll {relativeAge(snapshot?.last_source_poll?.at)} · processed {formatNumber(snapshot?.queue?.processed ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{relayEntries.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No relay lane data available.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Execution Readiness">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Internal execution plan</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<StatusBadge
|
||||
status={internalPlan?.plannerResponse?.decision || 'unknown'}
|
||||
tone={internalPlan?.plannerResponse?.decision === 'direct-pool' ? 'normal' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Contract {truncateMiddle(internalPlan?.execution?.contractAddress)} · {formatNumber(internalPlan?.plannerResponse?.steps?.length)} planner steps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Live providers</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{liveProviders.map((provider) => (
|
||||
<span
|
||||
key={provider.provider}
|
||||
className="rounded-full bg-primary-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||
>
|
||||
{provider.provider}
|
||||
</span>
|
||||
))}
|
||||
{liveProviders.length === 0 ? (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">No live providers reported.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</OperationsPageShell>
|
||||
)
|
||||
}
|
||||
237
frontend/src/components/explorer/PoolsOperationsPage.tsx
Normal file
237
frontend/src/components/explorer/PoolsOperationsPage.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { configApi, type TokenListResponse } from '@/services/api/config'
|
||||
import {
|
||||
aggregateLiquidityPools,
|
||||
getRouteBackedPoolAddresses,
|
||||
selectFeaturedLiquidityTokens,
|
||||
} from '@/services/api/liquidity'
|
||||
import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import { formatCurrency, formatNumber, truncateMiddle } from './OperationsPageShell'
|
||||
|
||||
interface TokenPoolRecord {
|
||||
symbol: string
|
||||
pools: MissionControlLiquidityPool[]
|
||||
}
|
||||
|
||||
export default function PoolsOperationsPage() {
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>([])
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [tokenListResult, routeMatrixResult] = await Promise.allSettled([
|
||||
configApi.getTokenList(),
|
||||
routesApi.getRouteMatrix(),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value)
|
||||
if (routeMatrixResult.status === 'fulfilled') setRouteMatrix(routeMatrixResult.value)
|
||||
|
||||
if (tokenListResult.status === 'fulfilled') {
|
||||
const featuredTokens = selectFeaturedLiquidityTokens(tokenListResult.value.tokens || [])
|
||||
const poolResults = await Promise.allSettled(
|
||||
featuredTokens.map(async (token) => ({
|
||||
symbol: token.symbol,
|
||||
pools: (await routesApi.getTokenPools(token.address)).pools || [],
|
||||
}))
|
||||
)
|
||||
|
||||
if (!cancelled) {
|
||||
setTokenPoolRecords(
|
||||
poolResults
|
||||
.filter((result): result is PromiseFulfilledResult<TokenPoolRecord> => result.status === 'fulfilled')
|
||||
.map((result) => result.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenListResult.status === 'rejected' && routeMatrixResult.status === 'rejected') {
|
||||
setLoadingError('Live pool inventory is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(
|
||||
error instanceof Error ? error.message : 'Live pool inventory is temporarily unavailable from the public explorer APIs.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const featuredTokens = useMemo(
|
||||
() => selectFeaturedLiquidityTokens(tokenList?.tokens || []),
|
||||
[tokenList?.tokens]
|
||||
)
|
||||
const aggregatedPools = useMemo(
|
||||
() => aggregateLiquidityPools(tokenPoolRecords),
|
||||
[tokenPoolRecords]
|
||||
)
|
||||
const routeBackedPoolAddresses = useMemo(
|
||||
() => getRouteBackedPoolAddresses(routeMatrix),
|
||||
[routeMatrix]
|
||||
)
|
||||
const topPools = aggregatedPools.slice(0, 9)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 max-w-4xl sm:mb-8">
|
||||
<div className="mb-3 inline-flex rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-primary-700">
|
||||
Live Pool Inventory
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
Pools
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
This page now summarizes the live pool inventory discovered through mission-control token
|
||||
pool endpoints and cross-checks it against the current route matrix.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loadingError ? (
|
||||
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
||||
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-3">
|
||||
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
|
||||
Unique pools
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(aggregatedPools.length)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Discovered across {formatNumber(featuredTokens.length)} featured Chain 138 tokens.
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
|
||||
Route-backed pools
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(routeBackedPoolAddresses.length)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Unique pool addresses referenced by the live route matrix.
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Featured coverage</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{formatNumber(tokenPoolRecords.filter((record) => record.pools.length > 0).length)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Featured tokens currently returning at least one live pool.
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Live Pool Cards">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{topPools.map((pool) => (
|
||||
<div
|
||||
key={pool.address}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{pool.dex || 'Unknown DEX'} · TVL {formatCurrency(pool.tvl)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{truncateMiddle(pool.address, 10, 8)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Seen from {pool.sourceSymbols.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{topPools.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No live pools available right now.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Featured Token Pool Counts">
|
||||
<div className="space-y-4">
|
||||
{featuredTokens.map((token) => {
|
||||
const record = tokenPoolRecords.find((entry) => entry.symbol === token.symbol)
|
||||
return (
|
||||
<div
|
||||
key={token.address}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{token.symbol}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{token.name || 'Unnamed token'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{formatNumber(record?.pools.length || 0)} pools
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Liquidity Shortcuts">
|
||||
<div className="space-y-4 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
The broader liquidity page now shows live route, planner, and pool access together.
|
||||
</p>
|
||||
<p>
|
||||
The current route matrix publishes {formatNumber(routeMatrix?.counts?.liveSwapRoutes)} live
|
||||
swap routes and {formatNumber(routeMatrix?.counts?.liveBridgeRoutes)} bridge routes.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/liquidity"
|
||||
className="rounded-full bg-primary-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-primary-700"
|
||||
>
|
||||
Open liquidity access
|
||||
</Link>
|
||||
<Link
|
||||
href="/routes"
|
||||
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Open routes page
|
||||
</Link>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Open wallet tools
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
401
frontend/src/components/explorer/RoutesMonitoringPage.tsx
Normal file
401
frontend/src/components/explorer/RoutesMonitoringPage.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import {
|
||||
routesApi,
|
||||
type ExplorerNetwork,
|
||||
type MissionControlLiquidityPool,
|
||||
type RouteMatrixRoute,
|
||||
type RouteMatrixResponse,
|
||||
} from '@/services/api/routes'
|
||||
|
||||
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
|
||||
|
||||
function relativeAge(isoString?: string): string {
|
||||
if (!isoString) return 'Unknown'
|
||||
const parsed = Date.parse(isoString)
|
||||
if (!Number.isFinite(parsed)) return 'Unknown'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
function compactAddress(value?: string): string {
|
||||
if (!value) return 'Unspecified'
|
||||
if (value.length <= 14) return value
|
||||
return `${value.slice(0, 6)}...${value.slice(-4)}`
|
||||
}
|
||||
|
||||
function formatUsd(value?: number): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 'Unknown'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function protocolList(route: RouteMatrixRoute): string {
|
||||
const protocols = Array.from(
|
||||
new Set((route.legs || []).map((leg) => leg.protocol || leg.executor || '').filter(Boolean))
|
||||
)
|
||||
return protocols.length > 0 ? protocols.join(', ') : 'Unspecified'
|
||||
}
|
||||
|
||||
function routeAssetPair(route: RouteMatrixRoute): string {
|
||||
if (route.routeType === 'bridge') {
|
||||
return route.assetSymbol || 'Bridge asset'
|
||||
}
|
||||
return [route.tokenInSymbol, route.tokenOutSymbol].filter(Boolean).join(' -> ') || 'Swap route'
|
||||
}
|
||||
|
||||
function ActionLink({
|
||||
href,
|
||||
label,
|
||||
external,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
external?: boolean
|
||||
}) {
|
||||
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
|
||||
const text = `${label} ->`
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoutesMonitoringPage() {
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [networks, setNetworks] = useState<ExplorerNetwork[]>([])
|
||||
const [pools, setPools] = useState<MissionControlLiquidityPool[]>([])
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.routes
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [matrixResult, networksResult, poolsResult] = await Promise.allSettled([
|
||||
routesApi.getRouteMatrix(),
|
||||
routesApi.getNetworks(),
|
||||
routesApi.getTokenPools(canonicalLiquidityToken),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (matrixResult.status === 'fulfilled') {
|
||||
setRouteMatrix(matrixResult.value)
|
||||
}
|
||||
if (networksResult.status === 'fulfilled') {
|
||||
setNetworks(networksResult.value.networks || [])
|
||||
}
|
||||
if (poolsResult.status === 'fulfilled') {
|
||||
setPools(poolsResult.value.pools || [])
|
||||
}
|
||||
|
||||
if (
|
||||
matrixResult.status === 'rejected' &&
|
||||
networksResult.status === 'rejected' &&
|
||||
poolsResult.status === 'rejected'
|
||||
) {
|
||||
setLoadingError('Live route inventory is temporarily unavailable.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(
|
||||
error instanceof Error ? error.message : 'Live route inventory is temporarily unavailable.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const liveRoutes = useMemo(() => routeMatrix?.liveRoutes || [], [routeMatrix?.liveRoutes])
|
||||
const plannedRoutes = useMemo(
|
||||
() => routeMatrix?.blockedOrPlannedRoutes || [],
|
||||
[routeMatrix?.blockedOrPlannedRoutes]
|
||||
)
|
||||
|
||||
const familyCount = useMemo(() => {
|
||||
return new Set(liveRoutes.flatMap((route) => route.aggregatorFamilies || [])).size
|
||||
}, [liveRoutes])
|
||||
|
||||
const topRoutes = useMemo(() => {
|
||||
const ordered = [...liveRoutes].sort((left, right) => {
|
||||
if ((left.routeType || '') !== (right.routeType || '')) {
|
||||
return left.routeType === 'bridge' ? -1 : 1
|
||||
}
|
||||
return (left.label || '').localeCompare(right.label || '')
|
||||
})
|
||||
return ordered.slice(0, 8)
|
||||
}, [liveRoutes])
|
||||
|
||||
const highlightedNetworks = useMemo(() => {
|
||||
return networks
|
||||
.filter((network) => [138, 1, 651940, 56, 43114].includes(network.chainIdDecimal || 0))
|
||||
.sort((left, right) => (left.chainIdDecimal || 0) - (right.chainIdDecimal || 0))
|
||||
}, [networks])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 max-w-4xl sm:mb-8">
|
||||
<div className="mb-3 inline-flex rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
{page.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{page.note ? (
|
||||
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
|
||||
{page.note}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{loadingError ? (
|
||||
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
||||
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
|
||||
Live Swap Routes
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{routeMatrix?.counts?.liveSwapRoutes ?? liveRoutes.filter((route) => route.routeType === 'swap').length}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Planner-visible same-chain routes on Chain 138.
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
|
||||
Live Bridge Routes
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{routeMatrix?.counts?.liveBridgeRoutes ?? liveRoutes.filter((route) => route.routeType === 'bridge').length}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Bridge routes exposed through the current route matrix.
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
Network Catalog
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{networks.length}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Published networks available through the explorer config surface.
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
||||
cUSDT Pool View
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{pools.length}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Live mission-control pools for the canonical cUSDT token.
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card title="Route Matrix Summary">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Generated</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{relativeAge(routeMatrix?.generatedAt)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Matrix version {routeMatrix?.version || 'unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Updated Source</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{relativeAge(routeMatrix?.updated)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{familyCount} partner families surfaced in live routes.
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Filtered Live Routes</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{routeMatrix?.counts?.filteredLiveRoutes ?? liveRoutes.length}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Includes swap and bridge lanes currently in the public matrix.
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Planned / Blocked</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{routeMatrix?.counts?.blockedOrPlannedRoutes ?? plannedRoutes.length}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Remaining lanes still waiting on pools, funding, or routing support.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Highlighted Networks">
|
||||
<div className="space-y-3">
|
||||
{highlightedNetworks.map((network) => (
|
||||
<div
|
||||
key={`${network.chainIdDecimal}-${network.chainName}`}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{network.chainName || 'Unknown chain'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Chain ID {network.chainIdDecimal ?? 'Unknown'} · {network.shortName || 'n/a'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card title="Live Route Snapshot">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{topRoutes.map((route) => (
|
||||
<div
|
||||
key={route.routeId}
|
||||
className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{route.label || route.routeId}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{routeAssetPair(route)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
{route.routeType || 'route'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{`${route.fromChainId} -> ${route.toChainId} · ${route.hopCount ?? 0} hop${
|
||||
(route.hopCount ?? 0) === 1 ? '' : 's'
|
||||
}`}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Protocols: {protocolList(route)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="cUSDT Mission-Control Pools">
|
||||
<div className="space-y-4">
|
||||
{pools.map((pool) => (
|
||||
<div
|
||||
key={pool.address}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{pool.dex || 'Unknown DEX'} · TVL {formatUsd(pool.tvl)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Pool {compactAddress(pool.address)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Planned Route Backlog">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{plannedRoutes.slice(0, 6).map((route) => (
|
||||
<div
|
||||
key={route.routeId}
|
||||
className="rounded-2xl border border-amber-200 bg-amber-50/70 p-4 dark:border-amber-900/50 dark:bg-amber-950/20"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{route.routeId}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{(route.tokenInSymbols || []).join(' / ') || routeAssetPair(route)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{route.reason || 'Pending additional deployment or routing work.'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{action.title}
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink
|
||||
href={action.href}
|
||||
label={action.label}
|
||||
external={'external' in action ? action.external : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
189
frontend/src/components/explorer/SystemOperationsPage.tsx
Normal file
189
frontend/src/components/explorer/SystemOperationsPage.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config'
|
||||
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
import OperationsPageShell, {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
export default function SystemOperationsPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(null)
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
|
||||
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(null)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.system
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [bridgeResult, networksResult, tokenListResult, capabilitiesResult, routesResult, statsResult] =
|
||||
await Promise.allSettled([
|
||||
missionControlApi.getBridgeStatus(),
|
||||
configApi.getNetworks(),
|
||||
configApi.getTokenList(),
|
||||
configApi.getCapabilities(),
|
||||
routesApi.getRouteMatrix(),
|
||||
statsApi.get(),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
|
||||
if (networksResult.status === 'fulfilled') setNetworksConfig(networksResult.value)
|
||||
if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value)
|
||||
if (capabilitiesResult.status === 'fulfilled') setCapabilities(capabilitiesResult.value)
|
||||
if (routesResult.status === 'fulfilled') setRouteMatrix(routesResult.value)
|
||||
if (statsResult.status === 'fulfilled') setStats(statsResult.value)
|
||||
|
||||
const failedCount = [
|
||||
bridgeResult,
|
||||
networksResult,
|
||||
tokenListResult,
|
||||
capabilitiesResult,
|
||||
routesResult,
|
||||
statsResult,
|
||||
].filter((result) => result.status === 'rejected').length
|
||||
|
||||
if (failedCount === 6) {
|
||||
setLoadingError('System inventory data is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(error instanceof Error ? error.message : 'System inventory data is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const relays = useMemo(() => getMissionControlRelays(bridgeStatus), [bridgeStatus])
|
||||
const chainStatus = bridgeStatus?.data?.chains?.['138']
|
||||
const chainCoverage = useMemo(
|
||||
() => new Set((tokenList?.tokens || []).map((token) => token.chainId).filter(Boolean)).size,
|
||||
[tokenList]
|
||||
)
|
||||
|
||||
return (
|
||||
<OperationsPageShell page={page}>
|
||||
{loadingError ? (
|
||||
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
||||
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Published Networks"
|
||||
value={formatNumber(networksConfig?.chains?.length)}
|
||||
description={`Default chain ${networksConfig?.defaultChainId ?? 'unknown'} in wallet onboarding.`}
|
||||
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Relay Lanes"
|
||||
value={formatNumber(relays ? Object.keys(relays).length : 0)}
|
||||
description={`${bridgeStatus?.data?.status || 'unknown'} public bridge posture across managed lanes.`}
|
||||
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Token Coverage"
|
||||
value={formatNumber((tokenList?.tokens || []).length)}
|
||||
description={`${formatNumber(chainCoverage)} chain catalogs served through the public token list.`}
|
||||
/>
|
||||
<MetricCard
|
||||
title="RPC Methods"
|
||||
value={formatNumber(capabilities?.http?.supportedMethods?.length)}
|
||||
description={`${formatNumber(capabilities?.tracing?.supportedMethods?.length)} tracing methods published.`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Topology Snapshot">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Chain 138 RPC</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<StatusBadge status={chainStatus?.status || 'unknown'} tone={chainStatus?.status === 'operational' ? 'normal' : 'warning'} />
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Head age {chainStatus?.head_age_sec != null ? `${Math.round(chainStatus.head_age_sec)}s` : 'Unknown'} · latency {chainStatus?.latency_ms != null ? `${Math.round(chainStatus.latency_ms)}ms` : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Route matrix</div>
|
||||
<div className="mt-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{formatNumber(routeMatrix?.counts?.filteredLiveRoutes)} live routes
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Updated {relativeAge(routeMatrix?.updated)} · {formatNumber(routeMatrix?.counts?.blockedOrPlannedRoutes)} planned or blocked
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Explorer index</div>
|
||||
<div className="mt-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{formatNumber(stats?.total_blocks)} blocks
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatNumber(stats?.total_transactions)} transactions · {formatNumber(stats?.total_addresses)} addresses
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Wallet compatibility</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<StatusBadge
|
||||
status={
|
||||
capabilities?.walletSupport?.walletAddEthereumChain && capabilities?.walletSupport?.walletWatchAsset
|
||||
? 'ready'
|
||||
: 'partial'
|
||||
}
|
||||
tone={
|
||||
capabilities?.walletSupport?.walletAddEthereumChain && capabilities?.walletSupport?.walletWatchAsset
|
||||
? 'normal'
|
||||
: 'warning'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Public capabilities JSON is wired for chain-add and token-add flows.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Network Inventory">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{(networksConfig?.chains || []).map((chain) => (
|
||||
<div
|
||||
key={`${chain.chainIdDecimal}-${chain.shortName}`}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{chain.chainName || chain.shortName || `Chain ${chain.chainIdDecimal}`}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Chain ID {chain.chainIdDecimal ?? 'Unknown'} · short name {chain.shortName || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(networksConfig?.chains || []).length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No network inventory available.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</OperationsPageShell>
|
||||
)
|
||||
}
|
||||
188
frontend/src/components/explorer/WethOperationsPage.tsx
Normal file
188
frontend/src/components/explorer/WethOperationsPage.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import {
|
||||
getMissionControlRelays,
|
||||
missionControlApi,
|
||||
type MissionControlBridgeStatusResponse,
|
||||
type MissionControlRelayPayload,
|
||||
} from '@/services/api/missionControl'
|
||||
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
|
||||
import OperationsPageShell, {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
|
||||
const normalized = String(status || 'unknown').toLowerCase()
|
||||
if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger'
|
||||
if (['paused', 'starting', 'unknown'].includes(normalized)) return 'warning'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function relaySnapshot(relay: MissionControlRelayPayload | undefined) {
|
||||
return relay?.url_probe?.body || relay?.file_snapshot
|
||||
}
|
||||
|
||||
export default function WethOperationsPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.weth
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [bridgeResult, capabilitiesResult, planResult] = await Promise.allSettled([
|
||||
missionControlApi.getBridgeStatus(),
|
||||
plannerApi.getCapabilities(),
|
||||
plannerApi.getInternalExecutionPlan(),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
|
||||
if (capabilitiesResult.status === 'fulfilled') setPlannerCapabilities(capabilitiesResult.value)
|
||||
if (planResult.status === 'fulfilled') setInternalPlan(planResult.value)
|
||||
|
||||
const failedCount = [bridgeResult, capabilitiesResult, planResult].filter(
|
||||
(result) => result.status === 'rejected'
|
||||
).length
|
||||
|
||||
if (failedCount === 3) {
|
||||
setLoadingError('WETH operations data is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
}
|
||||
|
||||
load().catch((error) => {
|
||||
if (!cancelled) {
|
||||
setLoadingError(error instanceof Error ? error.message : 'WETH operations data is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const relays = useMemo(() => getMissionControlRelays(bridgeStatus), [bridgeStatus])
|
||||
const mainnetWeth = relaySnapshot(relays?.mainnet_weth)
|
||||
const mainnetCw = relaySnapshot(relays?.mainnet_cw)
|
||||
const wethProviders = useMemo(
|
||||
() =>
|
||||
(plannerCapabilities?.providers || []).filter((provider) =>
|
||||
(provider.pairs || []).some(
|
||||
(pair) => pair.tokenInSymbol === 'WETH' || pair.tokenOutSymbol === 'WETH'
|
||||
)
|
||||
),
|
||||
[plannerCapabilities]
|
||||
)
|
||||
|
||||
return (
|
||||
<OperationsPageShell page={page}>
|
||||
{loadingError ? (
|
||||
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
||||
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Mainnet WETH Lane"
|
||||
value={mainnetWeth?.status || 'unknown'}
|
||||
description={`Queue ${formatNumber(mainnetWeth?.queue?.size ?? 0)} · ${mainnetWeth?.destination?.chain_name || 'destination unknown'}`}
|
||||
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Mainnet cW Lane"
|
||||
value={mainnetCw?.status || 'unknown'}
|
||||
description={`Queue ${formatNumber(mainnetCw?.queue?.size ?? 0)} · ${mainnetCw?.destination?.chain_name || 'destination unknown'}`}
|
||||
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="WETH Providers"
|
||||
value={formatNumber(wethProviders.length)}
|
||||
description="Providers that currently publish at least one WETH leg in planner v2."
|
||||
/>
|
||||
<MetricCard
|
||||
title="Fallback Decision"
|
||||
value={internalPlan?.plannerResponse?.decision || 'unknown'}
|
||||
description={
|
||||
internalPlan?.execution?.contractAddress
|
||||
? `Execution contract ${truncateMiddle(internalPlan.execution.contractAddress)}`
|
||||
: 'Current internal execution-plan posture for a WETH route.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Mainnet Bridge Lanes">
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: 'Mainnet WETH', snapshot: mainnetWeth },
|
||||
{ label: 'Mainnet cW', snapshot: mainnetCw },
|
||||
].map(({ label, snapshot }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{label}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Source {snapshot?.source?.chain_name || 'Unknown'} · destination {snapshot?.destination?.chain_name || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={snapshot?.status || 'unknown'} tone={relayTone(snapshot?.status)} />
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Queue {formatNumber(snapshot?.queue?.size ?? 0)} · last poll {relativeAge(snapshot?.last_source_poll?.at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="WETH Route-Ready Providers">
|
||||
<div className="space-y-4">
|
||||
{wethProviders.map((provider) => {
|
||||
const samplePairs = (provider.pairs || [])
|
||||
.filter((pair) => pair.tokenInSymbol === 'WETH' || pair.tokenOutSymbol === 'WETH')
|
||||
.slice(0, 3)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.provider}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{provider.provider}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{provider.executionMode || 'unknown mode'} · {(provider.supportedLegTypes || []).join(', ') || 'no leg types'}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={provider.live ? 'live' : 'inactive'} tone={provider.live ? 'normal' : 'warning'} />
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{samplePairs.map((pair) => `${pair.tokenInSymbol} -> ${pair.tokenOutSymbol}`).join(' · ') || 'No WETH pairs published'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{wethProviders.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No WETH-aware providers reported.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</OperationsPageShell>
|
||||
)
|
||||
}
|
||||
@@ -55,9 +55,16 @@ type TokenListCatalog = {
|
||||
|
||||
type CapabilitiesCatalog = {
|
||||
name?: string
|
||||
version?: {
|
||||
major?: number
|
||||
minor?: number
|
||||
patch?: number
|
||||
}
|
||||
timestamp?: string
|
||||
chainId?: number
|
||||
chainName?: string
|
||||
rpcUrl?: string
|
||||
explorerUrl?: string
|
||||
explorerApiUrl?: string
|
||||
generatedBy?: string
|
||||
walletSupport?: {
|
||||
@@ -128,6 +135,80 @@ const FEATURED_TOKEN_SYMBOLS = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAU
|
||||
/** npm-published Snap using open Snap permissions only; stable MetaMask still requires MetaMask’s install allowlist. */
|
||||
const CHAIN138_OPEN_SNAP_ID = 'npm:chain138-open-snap' as const
|
||||
|
||||
const FALLBACK_CAPABILITIES_138: CapabilitiesCatalog = {
|
||||
name: 'Chain 138 RPC Capabilities',
|
||||
version: { major: 1, minor: 1, patch: 0 },
|
||||
timestamp: '2026-03-28T00:00:00Z',
|
||||
generatedBy: 'SolaceScanScout',
|
||||
chainId: 138,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
rpcUrl: 'https://rpc-http-pub.d-bis.org',
|
||||
explorerUrl: 'https://explorer.d-bis.org',
|
||||
explorerApiUrl: 'https://explorer.d-bis.org/api/v2',
|
||||
walletSupport: {
|
||||
walletAddEthereumChain: true,
|
||||
walletWatchAsset: true,
|
||||
notes: [
|
||||
'MetaMask primarily relies on JSON-RPC correctness for balances, gas estimation, calls, and transaction submission.',
|
||||
'Explorer-served network metadata and token list metadata complement wallet UX but do not replace RPC method support.',
|
||||
],
|
||||
},
|
||||
http: {
|
||||
supportedMethods: [
|
||||
'web3_clientVersion',
|
||||
'net_version',
|
||||
'eth_chainId',
|
||||
'eth_blockNumber',
|
||||
'eth_syncing',
|
||||
'eth_gasPrice',
|
||||
'eth_maxPriorityFeePerGas',
|
||||
'eth_feeHistory',
|
||||
'eth_estimateGas',
|
||||
'eth_getCode',
|
||||
],
|
||||
unsupportedMethods: [],
|
||||
notes: [
|
||||
'eth_feeHistory is available for wallet fee estimation.',
|
||||
'eth_maxPriorityFeePerGas is exposed on the public RPC for wallet-grade fee suggestion compatibility.',
|
||||
],
|
||||
},
|
||||
tracing: {
|
||||
supportedMethods: ['trace_block', 'trace_replayBlockTransactions'],
|
||||
unsupportedMethods: ['debug_traceBlockByNumber'],
|
||||
notes: [
|
||||
'TRACE support is enabled for explorer-grade indexing and internal transaction analysis.',
|
||||
'Debug tracing is intentionally not enabled on the public RPC tier.',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
function isTokenListToken(value: unknown): value is TokenListToken {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
|
||||
const candidate = value as Partial<TokenListToken>
|
||||
return (
|
||||
typeof candidate.chainId === 'number' &&
|
||||
typeof candidate.address === 'string' &&
|
||||
candidate.address.trim().length > 0 &&
|
||||
typeof candidate.name === 'string' &&
|
||||
typeof candidate.symbol === 'string' &&
|
||||
typeof candidate.decimals === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isCapabilitiesCatalog(value: unknown): value is CapabilitiesCatalog {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
|
||||
const candidate = value as Partial<CapabilitiesCatalog>
|
||||
return (
|
||||
typeof candidate.chainId === 'number' &&
|
||||
typeof candidate.chainName === 'string' &&
|
||||
candidate.chainName.trim().length > 0 &&
|
||||
typeof candidate.rpcUrl === 'string' &&
|
||||
candidate.rpcUrl.trim().length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function getApiBase() {
|
||||
return resolveExplorerApiBase({
|
||||
serverFallback: 'https://explorer.d-bis.org',
|
||||
@@ -152,6 +233,10 @@ export function AddToMetaMask() {
|
||||
const tokenListUrl = `${apiBase}/api/config/token-list`
|
||||
const networksUrl = `${apiBase}/api/config/networks`
|
||||
const capabilitiesUrl = `${apiBase}/api/config/capabilities`
|
||||
const staticCapabilitiesUrl =
|
||||
typeof window !== 'undefined'
|
||||
? `${window.location.origin.replace(/\/$/, '')}/config/CHAIN138_RPC_CAPABILITIES.json`
|
||||
: `${apiBase}/config/CHAIN138_RPC_CAPABILITIES.json`
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
@@ -180,21 +265,46 @@ export function AddToMetaMask() {
|
||||
fetchJson(capabilitiesUrl),
|
||||
])
|
||||
|
||||
let resolvedCapabilities = capabilitiesResponse
|
||||
if (!isCapabilitiesCatalog(resolvedCapabilities.json)) {
|
||||
const staticCapabilitiesResponse = await fetchJson(staticCapabilitiesUrl)
|
||||
if (isCapabilitiesCatalog(staticCapabilitiesResponse.json)) {
|
||||
resolvedCapabilities = {
|
||||
json: staticCapabilitiesResponse.json,
|
||||
meta: {
|
||||
source: staticCapabilitiesResponse.meta.source || 'public-static-fallback',
|
||||
lastModified: staticCapabilitiesResponse.meta.lastModified,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
resolvedCapabilities = {
|
||||
json: FALLBACK_CAPABILITIES_138,
|
||||
meta: {
|
||||
source: 'frontend-fallback',
|
||||
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!active) return
|
||||
setNetworks(networksResponse.json)
|
||||
setTokenList(tokenListResponse.json)
|
||||
setCapabilities(capabilitiesResponse.json)
|
||||
setCapabilities(resolvedCapabilities.json)
|
||||
setNetworksMeta(networksResponse.meta)
|
||||
setTokenListMeta(tokenListResponse.meta)
|
||||
setCapabilitiesMeta(capabilitiesResponse.meta)
|
||||
setCapabilitiesMeta(resolvedCapabilities.meta)
|
||||
} catch {
|
||||
if (!active) return
|
||||
setNetworks(null)
|
||||
setTokenList(null)
|
||||
setCapabilities(null)
|
||||
setCapabilities(FALLBACK_CAPABILITIES_138)
|
||||
setNetworksMeta(null)
|
||||
setTokenListMeta(null)
|
||||
setCapabilitiesMeta(null)
|
||||
setCapabilitiesMeta({
|
||||
source: 'frontend-fallback',
|
||||
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
|
||||
})
|
||||
} finally {
|
||||
if (active) {
|
||||
timer = setTimeout(() => {
|
||||
@@ -210,7 +320,12 @@ export function AddToMetaMask() {
|
||||
active = false
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [capabilitiesUrl, networksUrl, tokenListUrl])
|
||||
}, [capabilitiesUrl, networksUrl, staticCapabilitiesUrl, tokenListUrl])
|
||||
|
||||
const catalogTokens = useMemo(
|
||||
() => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []),
|
||||
[tokenList],
|
||||
)
|
||||
|
||||
const chains = useMemo(() => {
|
||||
const chainMap = new Map<number, WalletChain>()
|
||||
@@ -230,7 +345,7 @@ export function AddToMetaMask() {
|
||||
|
||||
const featuredTokens = useMemo(() => {
|
||||
const tokenMap = new Map<string, TokenListToken>()
|
||||
for (const token of tokenList?.tokens || []) {
|
||||
for (const token of catalogTokens) {
|
||||
if (token.chainId !== 138) continue
|
||||
if (!FEATURED_TOKEN_SYMBOLS.includes(token.symbol)) continue
|
||||
tokenMap.set(token.symbol, token)
|
||||
@@ -239,7 +354,7 @@ export function AddToMetaMask() {
|
||||
return FEATURED_TOKEN_SYMBOLS
|
||||
.map((symbol) => tokenMap.get(symbol))
|
||||
.filter((token): token is TokenListToken => !!token)
|
||||
}, [tokenList])
|
||||
}, [catalogTokens])
|
||||
|
||||
const addChain = async (chain: WalletChain) => {
|
||||
setError(null)
|
||||
@@ -304,6 +419,11 @@ export function AddToMetaMask() {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
|
||||
if (!isTokenListToken(token)) {
|
||||
setError('Token metadata is incomplete right now. Refresh the page and try again.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
return
|
||||
@@ -312,7 +432,7 @@ export function AddToMetaMask() {
|
||||
try {
|
||||
const added = await ethereum.request({
|
||||
method: 'wallet_watchAsset',
|
||||
params: [{
|
||||
params: {
|
||||
type: 'ERC20',
|
||||
options: {
|
||||
address: token.address,
|
||||
@@ -320,7 +440,7 @@ export function AddToMetaMask() {
|
||||
decimals: token.decimals,
|
||||
image: token.logoURI,
|
||||
},
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
setStatus(added ? `Added ${token.symbol} to your wallet.` : `${token.symbol} request was dismissed.`)
|
||||
@@ -342,11 +462,15 @@ export function AddToMetaMask() {
|
||||
}
|
||||
}
|
||||
|
||||
const tokenCount138 = (tokenList?.tokens || []).filter((token) => token.chainId === 138).length
|
||||
const tokenCount138 = catalogTokens.filter((token) => token.chainId === 138).length
|
||||
const metadataKeywordString = (tokenList?.keywords || []).join(', ')
|
||||
const supportedHTTPMethods = capabilities?.http?.supportedMethods || []
|
||||
const unsupportedHTTPMethods = capabilities?.http?.unsupportedMethods || []
|
||||
const supportedTraceMethods = capabilities?.tracing?.supportedMethods || []
|
||||
const displayedCapabilitiesUrl =
|
||||
capabilitiesMeta?.source === 'public-static-fallback' || capabilitiesMeta?.source === 'frontend-fallback'
|
||||
? staticCapabilitiesUrl
|
||||
: capabilitiesUrl
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
|
||||
@@ -432,12 +556,12 @@ export function AddToMetaMask() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Capabilities URL</p>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{capabilitiesUrl}</code>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{displayedCapabilitiesUrl}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => copyText(capabilitiesUrl, 'capabilities URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
<button type="button" onClick={() => copyText(displayedCapabilitiesUrl, 'capabilities URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
Copy URL
|
||||
</button>
|
||||
<a href={capabilitiesUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
<a href={displayedCapabilitiesUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
Open JSON
|
||||
</a>
|
||||
</div>
|
||||
|
||||
307
frontend/src/data/explorerOperations.ts
Normal file
307
frontend/src/data/explorerOperations.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
export interface ExplorerFeatureAction {
|
||||
title: string
|
||||
description: string
|
||||
href: string
|
||||
label: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
export interface ExplorerFeaturePage {
|
||||
eyebrow: string
|
||||
title: string
|
||||
description: string
|
||||
note?: string
|
||||
actions: ExplorerFeatureAction[]
|
||||
}
|
||||
|
||||
const legacyNote =
|
||||
'These tools were restored in the legacy explorer asset first. The live Next explorer now exposes them here so they are reachable from the public UI without falling back to hidden static routes.'
|
||||
|
||||
export const explorerFeaturePages = {
|
||||
bridge: {
|
||||
eyebrow: 'Bridge Monitoring',
|
||||
title: 'Bridge & Relay Monitoring',
|
||||
description:
|
||||
'Inspect the CCIP relay status, follow the live mission-control stream, trace bridge transactions, and review the managed Mainnet, BSC, Avalanche, Avalanche cW, and Avalanche to Chain 138 lanes.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Mission-control live stream',
|
||||
description: 'Open the server-sent event stream that powers live relay and RPC monitoring.',
|
||||
href: '/explorer-api/v1/mission-control/stream',
|
||||
label: 'Open SSE stream',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Bridge status snapshot',
|
||||
description: 'Review the current relay health payload, queue posture, and destination summary.',
|
||||
href: '/explorer-api/v1/track1/bridge/status',
|
||||
label: 'Open status JSON',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Reverse AVAX lane',
|
||||
description: 'Check the managed Avalanche cW burn-back lane to Chain 138 that now runs as its own relay service.',
|
||||
href: '/explorer-api/v1/track1/bridge/status',
|
||||
label: 'Review AVAX -> 138 lane',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Bridge trace API',
|
||||
description: 'Resolve source and destination addresses for a bridge transaction through mission control.',
|
||||
href: '/explorer-api/v1/mission-control/bridge/trace?tx=0x2f31d4f9a97be754b800f4af1a9eedf3b107d353bfa1a19e81417497a76c05c2',
|
||||
label: 'Open trace example',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Open the interactive topology map for Chain 138, CCIP, Alltra, and adjacent integrations.',
|
||||
href: '/chain138-command-center.html',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Routes & liquidity',
|
||||
description: 'Move from bridge health into route coverage, pools, and execution access points.',
|
||||
href: '/routes',
|
||||
label: 'Open routes page',
|
||||
},
|
||||
],
|
||||
},
|
||||
routes: {
|
||||
eyebrow: 'Route Coverage',
|
||||
title: 'Routes, Pools, and Execution Access',
|
||||
description:
|
||||
'Surface the route matrix, live pool inventory, public liquidity endpoints, and bridge-adjacent execution paths that were previously only visible in the legacy explorer shell.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Liquidity access',
|
||||
description: 'Review the public Chain 138 PMM access points, route helpers, and fallback execution endpoints.',
|
||||
href: '/liquidity',
|
||||
label: 'Open liquidity access',
|
||||
},
|
||||
{
|
||||
title: 'Pools inventory',
|
||||
description: 'Jump to the pool overview page for quick PMM route and asset discovery.',
|
||||
href: '/pools',
|
||||
label: 'Open pools page',
|
||||
},
|
||||
{
|
||||
title: 'Liquidity mission-control example',
|
||||
description: 'Open a live mission-control liquidity lookup for a canonical Chain 138 token.',
|
||||
href: '/explorer-api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools',
|
||||
label: 'Open liquidity JSON',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Bridge monitoring',
|
||||
description: 'Cross-check route availability with live relay and bridge health before operator actions.',
|
||||
href: '/bridge',
|
||||
label: 'Open bridge monitoring',
|
||||
},
|
||||
{
|
||||
title: 'Operations hub',
|
||||
description: 'Open the consolidated page for WETH utilities, analytics, operator shortcuts, and system views.',
|
||||
href: '/more',
|
||||
label: 'Open operations hub',
|
||||
},
|
||||
],
|
||||
},
|
||||
weth: {
|
||||
eyebrow: 'WETH Utilities',
|
||||
title: 'WETH Utilities & Bridge References',
|
||||
description:
|
||||
'Reach the WETH-focused tooling that operators use during support and bridge investigation without depending on the hidden legacy explorer navigation.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Bridge monitoring',
|
||||
description: 'Start with relay and bridge health before reviewing WETH-specific flows.',
|
||||
href: '/bridge',
|
||||
label: 'Open bridge monitoring',
|
||||
},
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Use the interactive topology map for contract placement, hub flow, and system context.',
|
||||
href: '/chain138-command-center.html',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Wallet tools',
|
||||
description: 'Open the wallet page if you need supported network and token setup before testing flows.',
|
||||
href: '/wallet',
|
||||
label: 'Open wallet tools',
|
||||
},
|
||||
{
|
||||
title: 'Operations hub',
|
||||
description: 'Return to the larger operations landing page for adjacent route, analytics, and system shortcuts.',
|
||||
href: '/more',
|
||||
label: 'Open operations hub',
|
||||
},
|
||||
],
|
||||
},
|
||||
analytics: {
|
||||
eyebrow: 'Analytics Access',
|
||||
title: 'Analytics & Network Activity',
|
||||
description:
|
||||
'Use the public explorer pages and live monitoring endpoints as the visible analytics surface for chain activity, recent blocks, and transaction flow.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Blocks',
|
||||
description: 'Inspect recent block production, timestamps, and miner attribution.',
|
||||
href: '/blocks',
|
||||
label: 'Open blocks',
|
||||
},
|
||||
{
|
||||
title: 'Transactions',
|
||||
description: 'Review recent transactions, status, and linked address flow.',
|
||||
href: '/transactions',
|
||||
label: 'Open transactions',
|
||||
},
|
||||
{
|
||||
title: 'Addresses',
|
||||
description: 'Browse saved and active addresses as part of the explorer activity surface.',
|
||||
href: '/addresses',
|
||||
label: 'Open addresses',
|
||||
},
|
||||
{
|
||||
title: 'Mission-control stream',
|
||||
description: 'Supplement the explorer pages with the live relay and RPC event feed.',
|
||||
href: '/explorer-api/v1/mission-control/stream',
|
||||
label: 'Open SSE stream',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
operator: {
|
||||
eyebrow: 'Operator Shortcuts',
|
||||
title: 'Operator Panel Shortcuts',
|
||||
description:
|
||||
'Expose the public operational shortcuts that were restored in the legacy explorer for bridge checks, route validation, liquidity entry points, and documentation.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Bridge monitoring',
|
||||
description: 'Open relay status, queue posture, and bridge trace tools.',
|
||||
href: '/bridge',
|
||||
label: 'Open bridge monitoring',
|
||||
},
|
||||
{
|
||||
title: 'Routes',
|
||||
description: 'Inspect route coverage and liquidity path access before operator intervention.',
|
||||
href: '/routes',
|
||||
label: 'Open routes page',
|
||||
},
|
||||
{
|
||||
title: 'Liquidity access',
|
||||
description: 'Open partner payload helpers, route APIs, and execution-plan endpoints.',
|
||||
href: '/liquidity',
|
||||
label: 'Open liquidity access',
|
||||
},
|
||||
{
|
||||
title: 'Explorer docs',
|
||||
description: 'Use the static documentation landing page for explorer-specific reference material.',
|
||||
href: '/docs.html',
|
||||
label: 'Open docs',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Open the graphical deployment and integration topology in a dedicated page.',
|
||||
href: '/chain138-command-center.html',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
system: {
|
||||
eyebrow: 'System Topology',
|
||||
title: 'System & Topology',
|
||||
description:
|
||||
'Jump straight into the public topology and reference surfaces that describe how Chain 138, bridge monitoring, and adjacent systems fit together.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Open the topology map for Chain 138, CCIP, Alltra, OP Stack, and service flows.',
|
||||
href: '/chain138-command-center.html',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Bridge monitoring',
|
||||
description: 'Correlate topology context with the live bridge and relay status surface.',
|
||||
href: '/bridge',
|
||||
label: 'Open bridge monitoring',
|
||||
},
|
||||
{
|
||||
title: 'Explorer docs',
|
||||
description: 'Open the documentation landing page for static reference material shipped with the explorer.',
|
||||
href: '/docs.html',
|
||||
label: 'Open docs',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Operations hub',
|
||||
description: 'Return to the consolidated operations landing page for adjacent public tools.',
|
||||
href: '/more',
|
||||
label: 'Open operations hub',
|
||||
},
|
||||
],
|
||||
},
|
||||
more: {
|
||||
eyebrow: 'Operations Hub',
|
||||
title: 'More Explorer Tools',
|
||||
description:
|
||||
'This hub exposes the restored public tools that were previously buried in the legacy explorer shell: bridge monitoring, routes, WETH utilities, analytics shortcuts, operator links, and topology views.',
|
||||
note: legacyNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Bridge & relay monitoring',
|
||||
description: 'Open mission-control status, SSE monitoring, and bridge trace helpers.',
|
||||
href: '/bridge',
|
||||
label: 'Open bridge monitoring',
|
||||
},
|
||||
{
|
||||
title: 'Routes & liquidity',
|
||||
description: 'Open route coverage, pools, and public liquidity access points.',
|
||||
href: '/routes',
|
||||
label: 'Open routes page',
|
||||
},
|
||||
{
|
||||
title: 'WETH utilities',
|
||||
description: 'Open the WETH-focused landing page and bridge-adjacent shortcuts.',
|
||||
href: '/weth',
|
||||
label: 'Open WETH utilities',
|
||||
},
|
||||
{
|
||||
title: 'Analytics',
|
||||
description: 'Open the public analytics landing page for blocks, transactions, and live monitoring.',
|
||||
href: '/analytics',
|
||||
label: 'Open analytics page',
|
||||
},
|
||||
{
|
||||
title: 'Operator panel shortcuts',
|
||||
description: 'Open the operator landing page for bridge, route, liquidity, and docs shortcuts.',
|
||||
href: '/operator',
|
||||
label: 'Open operator page',
|
||||
},
|
||||
{
|
||||
title: 'System topology',
|
||||
description: 'Open the system landing page for topology references and command-center access.',
|
||||
href: '/system',
|
||||
label: 'Open system page',
|
||||
},
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Open the dedicated interactive topology asset in a new tab.',
|
||||
href: '/chain138-command-center.html',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as const satisfies Record<string, ExplorerFeaturePage>
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { AppProps } from 'next/app'
|
||||
import '../app/globals.css'
|
||||
import ExplorerChrome from '@/components/common/ExplorerChrome'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />
|
||||
return (
|
||||
<ExplorerChrome>
|
||||
<Component {...pageProps} />
|
||||
</ExplorerChrome>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,14 @@ import { useRouter } from 'next/router'
|
||||
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { addressesApi, AddressInfo, TransactionSummary } from '@/services/api/addresses'
|
||||
import { formatWeiAsEth } from '@/utils/format'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
import {
|
||||
isWatchlistEntry,
|
||||
readWatchlistFromStorage,
|
||||
writeWatchlistToStorage,
|
||||
normalizeWatchlistAddress,
|
||||
} from '@/utils/watchlist'
|
||||
|
||||
export default function AddressDetailPage() {
|
||||
const router = useRouter()
|
||||
@@ -13,6 +21,7 @@ export default function AddressDetailPage() {
|
||||
|
||||
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
|
||||
const [transactions, setTransactions] = useState<TransactionSummary[]>([])
|
||||
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadAddressInfo = useCallback(async () => {
|
||||
@@ -54,16 +63,39 @@ export default function AddressDetailPage() {
|
||||
loadTransactions()
|
||||
}, [address, loadAddressInfo, loadTransactions, router.isReady])
|
||||
|
||||
if (!router.isReady) {
|
||||
return <div className="p-8">Loading address...</div>
|
||||
}
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading address...</div>
|
||||
}
|
||||
try {
|
||||
setWatchlistEntries(readWatchlistFromStorage(window.localStorage))
|
||||
} catch {
|
||||
setWatchlistEntries([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!addressInfo) {
|
||||
return <div className="p-8">Address not found</div>
|
||||
const watchlistAddress = normalizeWatchlistAddress(addressInfo?.address || address)
|
||||
const isSavedToWatchlist = watchlistAddress
|
||||
? isWatchlistEntry(watchlistEntries, watchlistAddress)
|
||||
: false
|
||||
|
||||
const handleWatchlistToggle = () => {
|
||||
if (!watchlistAddress || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
setWatchlistEntries((current) => {
|
||||
const next = isSavedToWatchlist
|
||||
? current.filter((entry) => entry.toLowerCase() !== watchlistAddress.toLowerCase())
|
||||
: [...current, watchlistAddress]
|
||||
|
||||
try {
|
||||
writeWatchlistToStorage(window.localStorage, next)
|
||||
} catch {}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const transactionColumns = [
|
||||
@@ -71,7 +103,7 @@ export default function AddressDetailPage() {
|
||||
header: 'Hash',
|
||||
accessor: (tx: TransactionSummary) => (
|
||||
<Link href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.hash} truncate />
|
||||
<Address address={tx.hash} truncate showCopy={false} />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
@@ -87,17 +119,13 @@ export default function AddressDetailPage() {
|
||||
header: 'To',
|
||||
accessor: (tx: TransactionSummary) => tx.to_address ? (
|
||||
<Link href={`/addresses/${tx.to_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.to_address} truncate />
|
||||
<Address address={tx.to_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
) : 'Contract Creation',
|
||||
},
|
||||
{
|
||||
header: 'Value',
|
||||
accessor: (tx: TransactionSummary) => {
|
||||
const value = BigInt(tx.value)
|
||||
const eth = Number(value) / 1e18
|
||||
return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH'
|
||||
},
|
||||
accessor: (tx: TransactionSummary) => formatWeiAsEth(tx.value),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
@@ -110,56 +138,74 @@ export default function AddressDetailPage() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">
|
||||
{addressInfo.label || 'Address'}
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">
|
||||
{addressInfo?.label || 'Address'}
|
||||
</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/addresses" className="text-primary-600 hover:underline">
|
||||
Back to addresses
|
||||
</Link>
|
||||
<Link href={`/search?q=${encodeURIComponent(addressInfo.address)}`} className="text-primary-600 hover:underline">
|
||||
Search this address
|
||||
</Link>
|
||||
{(addressInfo?.address || address) && (
|
||||
<Link href={`/search?q=${encodeURIComponent(addressInfo?.address || address)}`} className="text-primary-600 hover:underline">
|
||||
Search this address
|
||||
</Link>
|
||||
)}
|
||||
{watchlistAddress && router.isReady && !loading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleWatchlistToggle}
|
||||
className="rounded-full border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
{isSavedToWatchlist ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card title="Address Information" className="mb-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold">Address:</span>
|
||||
<Address address={addressInfo.address} className="ml-2" />
|
||||
</div>
|
||||
{addressInfo.tags.length > 0 && (
|
||||
<div>
|
||||
<span className="font-semibold">Tags:</span>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{addressInfo.tags.map((tag, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-sm">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Transactions:</span>
|
||||
<span className="ml-2">{addressInfo.transaction_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Tokens:</span>
|
||||
<span className="ml-2">{addressInfo.token_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Type:</span>
|
||||
<span className="ml-2">{addressInfo.is_contract ? 'Contract' : 'EOA'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{!router.isReady || loading ? (
|
||||
<Card className="mb-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading address...</p>
|
||||
</Card>
|
||||
) : !addressInfo ? (
|
||||
<Card className="mb-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Address not found.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card title="Address Information" className="mb-6">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Address">
|
||||
<Address address={addressInfo.address} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Watchlist">
|
||||
{isSavedToWatchlist ? 'Saved for quick access' : 'Not saved yet'}
|
||||
</DetailRow>
|
||||
{addressInfo.tags.length > 0 && (
|
||||
<DetailRow label="Tags" valueClassName="flex flex-wrap gap-2">
|
||||
{addressInfo.tags.map((tag, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-sm">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Transactions">{addressInfo.transaction_count}</DetailRow>
|
||||
<DetailRow label="Tokens">{addressInfo.token_count}</DetailRow>
|
||||
<DetailRow label="Type">{addressInfo.is_contract ? 'Contract' : 'EOA'}</DetailRow>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card title="Transactions">
|
||||
<Table columns={transactionColumns} data={transactions} keyExtractor={(tx) => tx.hash} />
|
||||
</Card>
|
||||
<Card title="Transactions">
|
||||
<Table
|
||||
columns={transactionColumns}
|
||||
data={transactions}
|
||||
emptyMessage="No recent transactions were found for this address."
|
||||
keyExtractor={(tx) => tx.hash}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import { transactionsApi, Transaction } from '@/services/api/transactions'
|
||||
import { readWatchlistFromStorage } from '@/utils/watchlist'
|
||||
|
||||
function normalizeAddress(value: string) {
|
||||
const trimmed = value.trim()
|
||||
@@ -35,10 +36,12 @@ export default function AddressesPage() {
|
||||
}, [chainId])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem('explorerWatchlist')
|
||||
const entries = raw ? JSON.parse(raw) : []
|
||||
setWatchlist(Array.isArray(entries) ? entries.filter((entry): entry is string => typeof entry === 'string') : [])
|
||||
setWatchlist(readWatchlistFromStorage(window.localStorage))
|
||||
} catch {
|
||||
setWatchlist([])
|
||||
}
|
||||
@@ -70,8 +73,8 @@ export default function AddressesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Addresses</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Addresses</h1>
|
||||
|
||||
<Card className="mb-6" title="Open An Address">
|
||||
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
|
||||
@@ -85,7 +88,7 @@ export default function AddressesPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!normalizeAddress(query)}
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Open address
|
||||
</button>
|
||||
@@ -105,7 +108,7 @@ export default function AddressesPage() {
|
||||
<div className="space-y-3">
|
||||
{watchlist.map((entry) => (
|
||||
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
|
||||
<Address address={entry} />
|
||||
<Address address={entry} showCopy={false} />
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-2">
|
||||
@@ -126,7 +129,7 @@ export default function AddressesPage() {
|
||||
<div className="space-y-3">
|
||||
{activeAddresses.map((entry) => (
|
||||
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
|
||||
<Address address={entry} />
|
||||
<Address address={entry} showCopy={false} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
9
frontend/src/pages/analytics/index.tsx
Normal file
9
frontend/src/pages/analytics/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const AnalyticsOperationsPage = dynamic(() => import('@/components/explorer/AnalyticsOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
return <AnalyticsOperationsPage />
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/router'
|
||||
import { blocksApi, Block } from '@/services/api/blocks'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
|
||||
export default function BlockDetailPage() {
|
||||
const router = useRouter()
|
||||
@@ -40,68 +41,63 @@ export default function BlockDetailPage() {
|
||||
loadBlock()
|
||||
}, [isValidBlock, loadBlock, router.isReady])
|
||||
|
||||
if (!router.isReady) {
|
||||
return <div className="p-8">Loading block...</div>
|
||||
}
|
||||
|
||||
if (!isValidBlock) {
|
||||
return <div className="p-8">Invalid block number. Please use a valid block number from the URL.</div>
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading block...</div>
|
||||
}
|
||||
|
||||
if (!block) {
|
||||
return <div className="p-8">Block not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Block #{block.number}</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">{block ? `Block #${block.number}` : 'Block'}</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/blocks" className="text-primary-600 hover:underline">
|
||||
Back to blocks
|
||||
</Link>
|
||||
{block.number > 0 ? (
|
||||
{block && block.number > 0 ? (
|
||||
<Link href={`/blocks/${block.number - 1}`} className="text-primary-600 hover:underline">
|
||||
Previous block
|
||||
</Link>
|
||||
) : null}
|
||||
<Link href={`/blocks/${block.number + 1}`} className="text-primary-600 hover:underline">
|
||||
Next block
|
||||
</Link>
|
||||
{block && (
|
||||
<Link href={`/blocks/${block.number + 1}`} className="text-primary-600 hover:underline">
|
||||
Next block
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card title="Block Information">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold">Hash:</span>
|
||||
<Address address={block.hash} className="ml-2" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Timestamp:</span>
|
||||
<span className="ml-2">{new Date(block.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Miner:</span>
|
||||
<Link href={`/addresses/${block.miner}`} className="ml-2 text-primary-600 hover:underline">
|
||||
<Address address={block.miner} truncate />
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Transactions:</span>
|
||||
<Link href="/transactions" className="ml-2 text-primary-600 hover:underline">
|
||||
{block.transaction_count}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Gas Used:</span>
|
||||
<span className="ml-2">{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{!router.isReady || loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading block...</p>
|
||||
</Card>
|
||||
) : !isValidBlock ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Invalid block number. Please use a valid block number from the URL.</p>
|
||||
</Card>
|
||||
) : !block ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Block not found.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card title="Block Information">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Hash">
|
||||
<Address address={block.hash} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Timestamp">
|
||||
{new Date(block.timestamp).toLocaleString()}
|
||||
</DetailRow>
|
||||
<DetailRow label="Miner">
|
||||
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
|
||||
<Address address={block.miner} truncate showCopy={false} />
|
||||
</Link>
|
||||
</DetailRow>
|
||||
<DetailRow label="Transactions">
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
{block.transaction_count}
|
||||
</Link>
|
||||
</DetailRow>
|
||||
<DetailRow label="Gas Used">
|
||||
{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function BlocksPage() {
|
||||
const pageSize = 20
|
||||
const [blocks, setBlocks] = useState<Block[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -17,75 +18,89 @@ export default function BlocksPage() {
|
||||
const response = await blocksApi.list({
|
||||
chain_id: chainId,
|
||||
page,
|
||||
page_size: 20,
|
||||
page_size: pageSize,
|
||||
sort: 'number',
|
||||
order: 'desc',
|
||||
})
|
||||
setBlocks(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load blocks:', error)
|
||||
setBlocks([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [chainId, page])
|
||||
}, [chainId, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
loadBlocks()
|
||||
}, [loadBlocks])
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading blocks...</div>
|
||||
}
|
||||
const showPagination = page > 1 || blocks.length > 0
|
||||
const canGoNext = blocks.length === pageSize
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Blocks</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Blocks</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
{blocks.map((block) => (
|
||||
<Card key={block.number}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Link
|
||||
href={`/blocks/${block.number}`}
|
||||
className="text-lg font-semibold text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
Block #{block.number}
|
||||
</Link>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
<Address address={block.hash} truncate />
|
||||
{loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading blocks...</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{blocks.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Recent blocks are unavailable right now.</p>
|
||||
</Card>
|
||||
) : (
|
||||
blocks.map((block) => (
|
||||
<Card key={block.number}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<Link
|
||||
href={`/blocks/${block.number}`}
|
||||
className="text-lg font-semibold text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
Block #{block.number}
|
||||
</Link>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Address address={block.hash} truncate showCopy={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left sm:text-right">
|
||||
<div className="text-sm">
|
||||
{new Date(block.timestamp).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{block.transaction_count} transactions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm">
|
||||
{new Date(block.timestamp).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{block.transaction_count} transactions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-4 py-2">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="px-4 py-2 bg-gray-200 rounded"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
{showPagination && (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={loading || page === 1}
|
||||
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-3 py-2 text-sm sm:px-4">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={loading || !canGoNext}
|
||||
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
9
frontend/src/pages/bridge/index.tsx
Normal file
9
frontend/src/pages/bridge/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const BridgeMonitoringPage = dynamic(() => import('@/components/explorer/BridgeMonitoringPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function BridgePage() {
|
||||
return <BridgeMonitoringPage />
|
||||
}
|
||||
28
frontend/src/pages/home/index.tsx
Normal file
28
frontend/src/pages/home/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function HomeAliasPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
void router.replace('/')
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-12">
|
||||
<div className="mx-auto max-w-xl rounded-xl border border-gray-200 bg-white p-6 text-center shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Redirecting to SolaceScanScout</h1>
|
||||
<p className="mt-3 text-sm leading-7 text-gray-600 dark:text-gray-400">
|
||||
The legacy <code className="rounded bg-gray-100 px-1 py-0.5 text-xs dark:bg-gray-900">/home</code> route now redirects to the main explorer landing page.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-5 inline-flex rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Continue to the explorer
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
9
frontend/src/pages/more/index.tsx
Normal file
9
frontend/src/pages/more/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const MoreOperationsPage = dynamic(() => import('@/components/explorer/MoreOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function MorePage() {
|
||||
return <MoreOperationsPage />
|
||||
}
|
||||
9
frontend/src/pages/operator/index.tsx
Normal file
9
frontend/src/pages/operator/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const OperatorOperationsPage = dynamic(() => import('@/components/explorer/OperatorOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function OperatorPage() {
|
||||
return <OperatorOperationsPage />
|
||||
}
|
||||
@@ -1,88 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
|
||||
const poolCards = [
|
||||
{
|
||||
title: 'Canonical PMM routes',
|
||||
description: 'Review the public Chain 138 DODO PMM route matrix, live pool freshness, and payload examples.',
|
||||
href: '/liquidity',
|
||||
label: 'Open liquidity access',
|
||||
},
|
||||
{
|
||||
title: 'Wallet Funding Path',
|
||||
description: 'Open wallet tools first if you need Chain 138 setup, token import links, or a quick route into supported assets.',
|
||||
href: '/wallet',
|
||||
label: 'Open wallet tools',
|
||||
},
|
||||
{
|
||||
title: 'Explorer Docs',
|
||||
description: 'Static documentation covers the live pool map, expected web content, and route access details.',
|
||||
href: '/docs.html',
|
||||
label: 'Open docs landing page',
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
|
||||
const shortcutCards = [
|
||||
{
|
||||
title: 'cUSDT / USDT',
|
||||
description: 'Open the canonical direct stable route coverage and compare the live pool snapshot.',
|
||||
href: '/liquidity',
|
||||
},
|
||||
{
|
||||
title: 'cUSDC / USDC',
|
||||
description: 'Check the public stable bridge route and inspect the live reserves block.',
|
||||
href: '/liquidity',
|
||||
},
|
||||
{
|
||||
title: 'cUSDT / cXAUC',
|
||||
description: 'Review one of the live gold-backed route families from the liquidity access page.',
|
||||
href: '/liquidity',
|
||||
},
|
||||
]
|
||||
import PoolsOperationsPage from '@/components/explorer/PoolsOperationsPage'
|
||||
|
||||
export default function PoolsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Pools</h1>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{poolCards.map((card) => (
|
||||
<Card key={card.title} title={card.title}>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{card.description}</p>
|
||||
<div className="mt-4">
|
||||
{card.external ? (
|
||||
<a href={card.href} className="text-primary-600 hover:underline">
|
||||
{card.label} →
|
||||
</a>
|
||||
) : (
|
||||
<Link href={card.href} className="text-primary-600 hover:underline">
|
||||
{card.label} →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Card title="Pool operation shortcuts">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{shortcutCards.map((card) => (
|
||||
<Link
|
||||
key={card.title}
|
||||
href={card.href}
|
||||
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-700"
|
||||
>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{card.title}</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{card.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <PoolsOperationsPage />
|
||||
}
|
||||
|
||||
9
frontend/src/pages/routes/index.tsx
Normal file
9
frontend/src/pages/routes/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const RoutesMonitoringPage = dynamic(() => import('@/components/explorer/RoutesMonitoringPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function RoutesPage() {
|
||||
return <RoutesMonitoringPage />
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/router'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { getExplorerApiBase } from '@/services/api/blockscout'
|
||||
import { inferDirectSearchTarget } from '@/utils/search'
|
||||
|
||||
interface SearchResult {
|
||||
type: string
|
||||
@@ -24,17 +25,29 @@ export default function SearchPage() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasSearched, setHasSearched] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const runSearch = async (rawQuery: string) => {
|
||||
if (!rawQuery.trim()) return
|
||||
const trimmedQuery = rawQuery.trim()
|
||||
if (!trimmedQuery) {
|
||||
setHasSearched(false)
|
||||
setResults([])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
setHasSearched(true)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${getExplorerApiBase()}/api/v2/search?q=${encodeURIComponent(rawQuery)}`
|
||||
`${getExplorerApiBase()}/api/v2/search?q=${encodeURIComponent(trimmedQuery)}`
|
||||
)
|
||||
const data = await response.json()
|
||||
const data = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
setResults([])
|
||||
setError('Search is temporarily unavailable right now.')
|
||||
return
|
||||
}
|
||||
const normalizedResults = Array.isArray(data?.items)
|
||||
@@ -59,6 +72,7 @@ export default function SearchPage() {
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
setResults([])
|
||||
setError('Search is temporarily unavailable right now.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -73,15 +87,37 @@ export default function SearchPage() {
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
await runSearch(query)
|
||||
const trimmedQuery = query.trim()
|
||||
if (!trimmedQuery) {
|
||||
return
|
||||
}
|
||||
|
||||
const directTarget = inferDirectSearchTarget(trimmedQuery)
|
||||
if (directTarget) {
|
||||
void router.push(directTarget.href)
|
||||
return
|
||||
}
|
||||
|
||||
void router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: { q: trimmedQuery },
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
)
|
||||
await runSearch(trimmedQuery)
|
||||
}
|
||||
|
||||
const trimmedQuery = query.trim()
|
||||
const directTarget = inferDirectSearchTarget(trimmedQuery)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Search</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Search</h1>
|
||||
|
||||
<Card className="mb-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-4">
|
||||
<form onSubmit={handleSearch} className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
@@ -91,42 +127,75 @@ export default function SearchPage() {
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
disabled={loading || !trimmedQuery}
|
||||
className="w-full rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
|
||||
>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{!loading && error && (
|
||||
<Card className="mb-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{error}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && directTarget && (
|
||||
<Card className="mb-6" title="Direct Match">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
This looks like a direct explorer identifier. You can open it without waiting for indexed search results.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href={directTarget.href} className="text-primary-600 hover:underline">
|
||||
{directTarget.label} →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<Card title="Search Results">
|
||||
<div className="space-y-4">
|
||||
{results.map((result, index) => (
|
||||
<div key={result.data?.hash ?? result.data?.address ?? result.data?.number ?? index} className="border-b border-gray-200 dark:border-gray-700 pb-4 last:border-0">
|
||||
<div key={result.data?.hash ?? result.data?.address ?? result.data?.number ?? index} className="border-b border-gray-200 pb-4 last:border-0 dark:border-gray-700">
|
||||
{result.type === 'block' && result.data.number && (
|
||||
<Link href={`/blocks/${result.data.number}`} className="text-primary-600 hover:underline">
|
||||
<Link href={`/blocks/${result.data.number}`} className="inline-flex flex-col gap-1 text-primary-600 hover:underline">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Block</span>
|
||||
Block #{result.data.number}
|
||||
</Link>
|
||||
)}
|
||||
{result.type === 'transaction' && result.data.hash && (
|
||||
<Link href={`/transactions/${result.data.hash}`} className="text-primary-600 hover:underline">
|
||||
Transaction <Address address={result.data.hash} truncate />
|
||||
<Link href={`/transactions/${result.data.hash}`} className="inline-flex flex-col gap-1 text-primary-600 hover:underline">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Transaction</span>
|
||||
<Address address={result.data.hash} truncate showCopy={false} />
|
||||
</Link>
|
||||
)}
|
||||
{result.type === 'address' && result.data.address && (
|
||||
<Link href={`/addresses/${result.data.address}`} className="text-primary-600 hover:underline">
|
||||
Address <Address address={result.data.address} truncate />
|
||||
<Link href={`/addresses/${result.data.address}`} className="inline-flex flex-col gap-1 text-primary-600 hover:underline">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Address</span>
|
||||
<Address address={result.data.address} truncate showCopy={false} />
|
||||
</Link>
|
||||
)}
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
Type: {result.type} | Chain: {result.chain_id ?? 138} | Score: {(result.score ?? 0).toFixed(2)}
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500">
|
||||
<span>Type: {result.type}</span>
|
||||
<span>Chain: {result.chain_id ?? 138}</span>
|
||||
<span>Score: {(result.score ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && hasSearched && !error && results.length === 0 && (
|
||||
<Card title="No Results Found">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No explorer results matched <span className="font-medium text-gray-900 dark:text-white">{trimmedQuery}</span>.
|
||||
Try a full address, transaction hash, token symbol, or block number.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
9
frontend/src/pages/system/index.tsx
Normal file
9
frontend/src/pages/system/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const SystemOperationsPage = dynamic(() => import('@/components/explorer/SystemOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function SystemPage() {
|
||||
return <SystemOperationsPage />
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export default function TokensPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!query.trim()}
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useRouter } from 'next/router'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { transactionsApi, Transaction } from '@/services/api/transactions'
|
||||
import { formatWeiAsEth } from '@/utils/format'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
|
||||
export default function TransactionDetailPage() {
|
||||
const router = useRouter()
|
||||
@@ -42,92 +44,76 @@ export default function TransactionDetailPage() {
|
||||
loadTransaction()
|
||||
}, [hash, loadTransaction, router.isReady])
|
||||
|
||||
if (!router.isReady) {
|
||||
return <div className="p-8">Loading transaction...</div>
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading transaction...</div>
|
||||
}
|
||||
|
||||
if (!transaction) {
|
||||
return <div className="p-8">Transaction not found</div>
|
||||
}
|
||||
|
||||
const value = BigInt(transaction.value)
|
||||
const ethValue = Number(value) / 1e18
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Transaction</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Transaction</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
Back to transactions
|
||||
</Link>
|
||||
<Link href={`/search?q=${encodeURIComponent(transaction.hash)}`} className="text-primary-600 hover:underline">
|
||||
Search this hash
|
||||
</Link>
|
||||
{(transaction?.hash || hash) && (
|
||||
<Link href={`/search?q=${encodeURIComponent(transaction?.hash || hash)}`} className="text-primary-600 hover:underline">
|
||||
Search this hash
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card title="Transaction Information">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold">Hash:</span>
|
||||
<Address address={transaction.hash} className="ml-2" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Block:</span>
|
||||
<Link href={`/blocks/${transaction.block_number}`} className="ml-2 text-primary-600 hover:underline">
|
||||
#{transaction.block_number}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">From:</span>
|
||||
<Link href={`/addresses/${transaction.from_address}`} className="ml-2">
|
||||
<Address address={transaction.from_address} truncate />
|
||||
</Link>
|
||||
</div>
|
||||
{transaction.to_address && (
|
||||
<div>
|
||||
<span className="font-semibold">To:</span>
|
||||
<Link href={`/addresses/${transaction.to_address}`} className="ml-2">
|
||||
<Address address={transaction.to_address} truncate />
|
||||
{!router.isReady || loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading transaction...</p>
|
||||
</Card>
|
||||
) : !transaction ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Transaction not found.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card title="Transaction Information">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Hash">
|
||||
<Address address={transaction.hash} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Block">
|
||||
<Link href={`/blocks/${transaction.block_number}`} className="text-primary-600 hover:underline">
|
||||
#{transaction.block_number}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Value:</span>
|
||||
<span className="ml-2">{ethValue.toFixed(4)} ETH</span>
|
||||
</div>
|
||||
{transaction.gas_price && (
|
||||
<div>
|
||||
<span className="font-semibold">Gas Price:</span>
|
||||
<span className="ml-2">{transaction.gas_price / 1e9} Gwei</span>
|
||||
</div>
|
||||
)}
|
||||
{transaction.gas_used && (
|
||||
<div>
|
||||
<span className="font-semibold">Gas Used:</span>
|
||||
<span className="ml-2">{transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Status:</span>
|
||||
<span className={`ml-2 ${transaction.status === 1 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{transaction.status === 1 ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
{transaction.contract_address && (
|
||||
<div>
|
||||
<span className="font-semibold">Contract Created:</span>
|
||||
<Link href={`/addresses/${transaction.contract_address}`} className="ml-2">
|
||||
<Address address={transaction.contract_address} truncate />
|
||||
</DetailRow>
|
||||
<DetailRow label="From">
|
||||
<Link href={`/addresses/${transaction.from_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={transaction.from_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</DetailRow>
|
||||
{transaction.to_address && (
|
||||
<DetailRow label="To">
|
||||
<Link href={`/addresses/${transaction.to_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={transaction.to_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Value">{formatWeiAsEth(transaction.value)}</DetailRow>
|
||||
<DetailRow label="Gas Price">
|
||||
{transaction.gas_price != null ? `${transaction.gas_price / 1e9} Gwei` : 'N/A'}
|
||||
</DetailRow>
|
||||
{transaction.gas_used != null && (
|
||||
<DetailRow label="Gas Used">
|
||||
{transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()}
|
||||
</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Status">
|
||||
<span className={transaction.status === 1 ? 'text-green-600' : 'text-red-600'}>
|
||||
{transaction.status === 1 ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
{transaction.contract_address && (
|
||||
<DetailRow label="Contract Created">
|
||||
<Link href={`/addresses/${transaction.contract_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={transaction.contract_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
</DetailRow>
|
||||
)}
|
||||
</dl>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Table, Address } from '@/libs/frontend-ui-primitives'
|
||||
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { transactionsApi, Transaction } from '@/services/api/transactions'
|
||||
import { formatWeiAsEth } from '@/utils/format'
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const pageSize = 20
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -14,7 +16,7 @@ export default function TransactionsPage() {
|
||||
const loadTransactions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { ok, data } = await transactionsApi.listSafe(chainId, page, 20)
|
||||
const { ok, data } = await transactionsApi.listSafe(chainId, page, pageSize)
|
||||
setTransactions(ok ? data : [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error)
|
||||
@@ -22,18 +24,21 @@ export default function TransactionsPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [chainId, page])
|
||||
}, [chainId, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
loadTransactions()
|
||||
}, [loadTransactions])
|
||||
|
||||
const showPagination = page > 1 || transactions.length > 0
|
||||
const canGoNext = transactions.length === pageSize
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: 'Hash',
|
||||
accessor: (tx: Transaction) => (
|
||||
<Link href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.hash} truncate />
|
||||
<Address address={tx.hash} truncate showCopy={false} />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
@@ -49,7 +54,7 @@ export default function TransactionsPage() {
|
||||
header: 'From',
|
||||
accessor: (tx: Transaction) => (
|
||||
<Link href={`/addresses/${tx.from_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.from_address} truncate />
|
||||
<Address address={tx.from_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
@@ -57,17 +62,13 @@ export default function TransactionsPage() {
|
||||
header: 'To',
|
||||
accessor: (tx: Transaction) => tx.to_address ? (
|
||||
<Link href={`/addresses/${tx.to_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.to_address} truncate />
|
||||
<Address address={tx.to_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
) : <span className="text-gray-400">Contract Creation</span>,
|
||||
},
|
||||
{
|
||||
header: 'Value',
|
||||
accessor: (tx: Transaction) => {
|
||||
const value = BigInt(tx.value)
|
||||
const eth = Number(value) / 1e18
|
||||
return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH'
|
||||
},
|
||||
accessor: (tx: Transaction) => formatWeiAsEth(tx.value),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
@@ -79,32 +80,42 @@ export default function TransactionsPage() {
|
||||
},
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading transactions...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Transactions</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Transactions</h1>
|
||||
|
||||
<Table columns={columns} data={transactions} keyExtractor={(tx) => tx.hash} />
|
||||
{loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading transactions...</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={transactions}
|
||||
emptyMessage="Recent transactions are unavailable right now."
|
||||
keyExtractor={(tx) => tx.hash}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-4 py-2">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="px-4 py-2 bg-gray-200 rounded"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
{showPagination && (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={loading || page === 1}
|
||||
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-3 py-2 text-sm sm:px-4">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={loading || !canGoNext}
|
||||
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import {
|
||||
readWatchlistFromStorage,
|
||||
writeWatchlistToStorage,
|
||||
sanitizeWatchlistEntries,
|
||||
} from '@/utils/watchlist'
|
||||
|
||||
export default function WatchlistPage() {
|
||||
const [entries, setEntries] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem('explorerWatchlist')
|
||||
const parsed = raw ? JSON.parse(raw) : []
|
||||
setEntries(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [])
|
||||
setEntries(readWatchlistFromStorage(window.localStorage))
|
||||
} catch {
|
||||
setEntries([])
|
||||
}
|
||||
@@ -21,13 +28,17 @@ export default function WatchlistPage() {
|
||||
setEntries((current) => {
|
||||
const next = current.filter((entry) => entry.toLowerCase() !== address.toLowerCase())
|
||||
try {
|
||||
window.localStorage.setItem('explorerWatchlist', JSON.stringify(next))
|
||||
writeWatchlistToStorage(window.localStorage, next)
|
||||
} catch {}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const exportWatchlist = () => {
|
||||
if (entries.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -45,12 +56,9 @@ export default function WatchlistPage() {
|
||||
|
||||
file.text().then((text) => {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
const next = Array.isArray(parsed)
|
||||
? parsed.filter((entry): entry is string => typeof entry === 'string')
|
||||
: []
|
||||
const next = sanitizeWatchlistEntries(JSON.parse(text))
|
||||
setEntries(next)
|
||||
window.localStorage.setItem('explorerWatchlist', JSON.stringify(next))
|
||||
writeWatchlistToStorage(window.localStorage, next)
|
||||
} catch {}
|
||||
}).catch(() => {})
|
||||
|
||||
@@ -58,8 +66,8 @@ export default function WatchlistPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Watchlist</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Watchlist</h1>
|
||||
|
||||
<Card title="Saved Addresses">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
@@ -75,7 +83,7 @@ export default function WatchlistPage() {
|
||||
type="button"
|
||||
onClick={exportWatchlist}
|
||||
disabled={entries.length === 0}
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 disabled:opacity-100 dark:disabled:bg-gray-700 dark:disabled:text-gray-400"
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
@@ -98,7 +106,7 @@ export default function WatchlistPage() {
|
||||
{entries.map((entry) => (
|
||||
<div key={entry} className="flex flex-col gap-2 rounded-lg border border-gray-200 p-3 dark:border-gray-700 md:flex-row md:items-center md:justify-between">
|
||||
<Link href={`/addresses/${entry}`} className="text-primary-600 hover:underline">
|
||||
<Address address={entry} />
|
||||
<Address address={entry} showCopy={false} />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
9
frontend/src/pages/weth/index.tsx
Normal file
9
frontend/src/pages/weth/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const WethOperationsPage = dynamic(() => import('@/components/explorer/WethOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function WethPage() {
|
||||
return <WethOperationsPage />
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||||
import type { Block } from './blocks'
|
||||
import type { Transaction } from './transactions'
|
||||
import type { TransactionSummary } from './addresses'
|
||||
|
||||
export function getExplorerApiBase() {
|
||||
return (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080').replace(/\/$/, '')
|
||||
return resolveExplorerApiBase()
|
||||
}
|
||||
|
||||
export async function fetchBlockscoutJson<T>(path: string): Promise<T> {
|
||||
|
||||
@@ -11,7 +11,6 @@ function getApiKey(): string | null {
|
||||
}
|
||||
|
||||
export const apiClient = createApiClient(
|
||||
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
|
||||
undefined,
|
||||
getApiKey
|
||||
)
|
||||
|
||||
|
||||
61
frontend/src/services/api/config.ts
Normal file
61
frontend/src/services/api/config.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { getExplorerApiBase } from './blockscout'
|
||||
|
||||
export interface NetworksConfigChain {
|
||||
chainIdDecimal?: number
|
||||
chainName?: string
|
||||
shortName?: string
|
||||
}
|
||||
|
||||
export interface NetworksConfigResponse {
|
||||
defaultChainId?: number
|
||||
chains?: NetworksConfigChain[]
|
||||
}
|
||||
|
||||
export interface TokenListToken {
|
||||
chainId?: number
|
||||
symbol?: string
|
||||
address?: string
|
||||
name?: string
|
||||
decimals?: number
|
||||
logoURI?: string
|
||||
}
|
||||
|
||||
export interface TokenListResponse {
|
||||
tokens?: TokenListToken[]
|
||||
}
|
||||
|
||||
export interface CapabilitiesResponse {
|
||||
chainId?: number
|
||||
chainName?: string
|
||||
walletSupport?: {
|
||||
walletAddEthereumChain?: boolean
|
||||
walletWatchAsset?: boolean
|
||||
}
|
||||
http?: {
|
||||
supportedMethods?: string[]
|
||||
unsupportedMethods?: string[]
|
||||
}
|
||||
tracing?: {
|
||||
supportedMethods?: string[]
|
||||
unsupportedMethods?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${getExplorerApiBase()}${path}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
getNetworks: async (): Promise<NetworksConfigResponse> =>
|
||||
fetchJson<NetworksConfigResponse>('/api/config/networks'),
|
||||
|
||||
getTokenList: async (): Promise<TokenListResponse> =>
|
||||
fetchJson<TokenListResponse>('/api/config/token-list'),
|
||||
|
||||
getCapabilities: async (): Promise<CapabilitiesResponse> =>
|
||||
fetchJson<CapabilitiesResponse>('/api/config/capabilities'),
|
||||
}
|
||||
105
frontend/src/services/api/liquidity.ts
Normal file
105
frontend/src/services/api/liquidity.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { TokenListToken } from './config'
|
||||
import type { MissionControlLiquidityPool, RouteMatrixResponse, RouteMatrixRoute } from './routes'
|
||||
import type { PlannerCapabilitiesResponse, PlannerProviderCapability } from './planner'
|
||||
|
||||
export const featuredLiquiditySymbols = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cEURT', 'WETH10']
|
||||
|
||||
export interface FeaturedLiquidityToken {
|
||||
symbol: string
|
||||
address: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface AggregatedLiquidityPool extends MissionControlLiquidityPool {
|
||||
sourceSymbols: string[]
|
||||
}
|
||||
|
||||
export function selectFeaturedLiquidityTokens(tokens: TokenListToken[] = []): FeaturedLiquidityToken[] {
|
||||
const selected = new Map<string, FeaturedLiquidityToken>()
|
||||
|
||||
for (const symbol of featuredLiquiditySymbols) {
|
||||
const token = tokens.find(
|
||||
(entry) => entry.chainId === 138 && entry.symbol === symbol && typeof entry.address === 'string' && entry.address.trim().length > 0
|
||||
)
|
||||
|
||||
if (token?.address) {
|
||||
selected.set(symbol, {
|
||||
symbol,
|
||||
address: token.address,
|
||||
name: token.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(selected.values())
|
||||
}
|
||||
|
||||
export function aggregateLiquidityPools(
|
||||
records: Array<{ symbol: string; pools: MissionControlLiquidityPool[] }>
|
||||
): AggregatedLiquidityPool[] {
|
||||
const merged = new Map<string, AggregatedLiquidityPool>()
|
||||
|
||||
for (const record of records) {
|
||||
for (const pool of record.pools || []) {
|
||||
if (!pool.address) continue
|
||||
const key = pool.address.toLowerCase()
|
||||
const existing = merged.get(key)
|
||||
|
||||
if (existing) {
|
||||
if (!existing.sourceSymbols.includes(record.symbol)) {
|
||||
existing.sourceSymbols.push(record.symbol)
|
||||
}
|
||||
if ((pool.tvl || 0) > (existing.tvl || 0)) {
|
||||
existing.tvl = pool.tvl
|
||||
}
|
||||
} else {
|
||||
merged.set(key, {
|
||||
...pool,
|
||||
sourceSymbols: [record.symbol],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(merged.values()).sort((left, right) => {
|
||||
const tvlDelta = (right.tvl || 0) - (left.tvl || 0)
|
||||
if (tvlDelta !== 0) return tvlDelta
|
||||
return (left.address || '').localeCompare(right.address || '')
|
||||
})
|
||||
}
|
||||
|
||||
export function getRouteBackedPoolAddresses(routeMatrix: RouteMatrixResponse | null | undefined): string[] {
|
||||
const addresses = new Set<string>()
|
||||
|
||||
for (const route of routeMatrix?.liveRoutes || []) {
|
||||
for (const leg of route.legs || []) {
|
||||
if (leg.poolAddress) {
|
||||
addresses.add(leg.poolAddress.toLowerCase())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(addresses)
|
||||
}
|
||||
|
||||
export function getTopLiquidityRoutes(
|
||||
routeMatrix: RouteMatrixResponse | null | undefined,
|
||||
limit = 8
|
||||
): RouteMatrixRoute[] {
|
||||
const liveRoutes = routeMatrix?.liveRoutes || []
|
||||
return [...liveRoutes]
|
||||
.filter((route) => route.routeType === 'swap')
|
||||
.sort((left, right) => {
|
||||
const leftPools = (left.legs || []).filter((leg) => leg.poolAddress).length
|
||||
const rightPools = (right.legs || []).filter((leg) => leg.poolAddress).length
|
||||
if (leftPools !== rightPools) return rightPools - leftPools
|
||||
return (left.label || left.routeId).localeCompare(right.label || right.routeId)
|
||||
})
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
export function getLivePlannerProviders(
|
||||
capabilities: PlannerCapabilitiesResponse | null | undefined
|
||||
): PlannerProviderCapability[] {
|
||||
return (capabilities?.providers || []).filter((provider) => provider.live)
|
||||
}
|
||||
252
frontend/src/services/api/missionControl.ts
Normal file
252
frontend/src/services/api/missionControl.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { getExplorerApiBase } from './blockscout'
|
||||
|
||||
export interface MissionControlRelaySummary {
|
||||
text: string
|
||||
tone: 'normal' | 'warning' | 'danger'
|
||||
items: MissionControlRelayItemSummary[]
|
||||
}
|
||||
|
||||
export interface MissionControlRelayItemSummary {
|
||||
key: string
|
||||
label: string
|
||||
status: string
|
||||
text: string
|
||||
tone: 'normal' | 'warning' | 'danger'
|
||||
}
|
||||
|
||||
export interface MissionControlRelaySnapshot {
|
||||
status?: string
|
||||
service?: {
|
||||
profile?: string
|
||||
}
|
||||
source?: {
|
||||
chain_name?: string
|
||||
bridge_filter?: string
|
||||
}
|
||||
destination?: {
|
||||
chain_name?: string
|
||||
relay_bridge?: string
|
||||
relay_bridge_default?: string
|
||||
}
|
||||
queue?: {
|
||||
size?: number
|
||||
processed?: number
|
||||
failed?: number
|
||||
}
|
||||
last_source_poll?: {
|
||||
at?: string
|
||||
ok?: boolean
|
||||
logs_fetched?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MissionControlRelayProbe {
|
||||
ok?: boolean
|
||||
body?: MissionControlRelaySnapshot
|
||||
}
|
||||
|
||||
export interface MissionControlRelayPayload {
|
||||
url_probe?: MissionControlRelayProbe
|
||||
file_snapshot?: MissionControlRelaySnapshot
|
||||
file_snapshot_error?: string
|
||||
}
|
||||
|
||||
export interface MissionControlChainStatus {
|
||||
status?: string
|
||||
name?: string
|
||||
head_age_sec?: number
|
||||
latency_ms?: number
|
||||
block_number?: string
|
||||
}
|
||||
|
||||
export interface MissionControlBridgeStatusResponse {
|
||||
data?: {
|
||||
status?: string
|
||||
checked_at?: string
|
||||
chains?: Record<string, MissionControlChainStatus>
|
||||
ccip_relay?: MissionControlRelayPayload
|
||||
ccip_relays?: Record<string, MissionControlRelayPayload>
|
||||
}
|
||||
}
|
||||
|
||||
const missionControlRelayLabels: Record<string, string> = {
|
||||
mainnet: 'Mainnet',
|
||||
mainnet_weth: 'Mainnet WETH',
|
||||
mainnet_cw: 'Mainnet cW',
|
||||
bsc: 'BSC',
|
||||
avax: 'Avalanche',
|
||||
avalanche: 'Avalanche',
|
||||
avax_cw: 'Avalanche cW',
|
||||
avax_to_138: 'Avalanche -> 138',
|
||||
}
|
||||
|
||||
function getMissionControlStreamUrl(): string {
|
||||
return `${getExplorerApiBase()}/explorer-api/v1/mission-control/stream`
|
||||
}
|
||||
|
||||
function getMissionControlBridgeStatusUrl(): string {
|
||||
return `${getExplorerApiBase()}/explorer-api/v1/track1/bridge/status`
|
||||
}
|
||||
|
||||
function relativeAge(isoString?: string): string {
|
||||
if (!isoString) return ''
|
||||
const parsed = Date.parse(isoString)
|
||||
if (!Number.isFinite(parsed)) return ''
|
||||
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
export function summarizeMissionControlRelay(
|
||||
response: MissionControlBridgeStatusResponse | null | undefined
|
||||
): MissionControlRelaySummary | null {
|
||||
const relays = getMissionControlRelays(response)
|
||||
if (!relays) return null
|
||||
|
||||
const items = Object.entries(relays)
|
||||
.map(([key, relay]): MissionControlRelayItemSummary | null => {
|
||||
const label = getMissionControlRelayLabel(key)
|
||||
|
||||
if (relay.url_probe && relay.url_probe.ok === false) {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
status: 'down',
|
||||
text: `${label}: probe failed`,
|
||||
tone: 'danger',
|
||||
}
|
||||
}
|
||||
|
||||
if (relay.file_snapshot_error) {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
status: 'snapshot-error',
|
||||
text: `${label}: snapshot error`,
|
||||
tone: 'danger',
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = relay.url_probe?.body || relay.file_snapshot
|
||||
if (!snapshot) {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
status: 'configured',
|
||||
text: `${label}: probe configured`,
|
||||
tone: 'normal',
|
||||
}
|
||||
}
|
||||
|
||||
const status = String(snapshot.status || 'unknown').toLowerCase()
|
||||
const destination = snapshot.destination?.chain_name
|
||||
const queueSize = snapshot.queue?.size
|
||||
const pollAge = relativeAge(snapshot.last_source_poll?.at)
|
||||
|
||||
let text = `${label}: ${status}`
|
||||
if (destination) text += ` -> ${destination}`
|
||||
if (queueSize != null) text += ` · queue ${queueSize}`
|
||||
if (pollAge) text += ` · polled ${pollAge}`
|
||||
|
||||
let tone: MissionControlRelaySummary['tone'] = 'normal'
|
||||
if (['paused', 'starting'].includes(status)) {
|
||||
tone = 'warning'
|
||||
}
|
||||
if (['degraded', 'stale', 'stopped', 'down'].includes(status)) {
|
||||
tone = 'danger'
|
||||
}
|
||||
|
||||
return { key, label, status, text, tone }
|
||||
})
|
||||
.filter((item): item is MissionControlRelayItemSummary => item !== null)
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
const tone: MissionControlRelaySummary['tone'] = items.some((item) => item.tone === 'danger')
|
||||
? 'danger'
|
||||
: items.some((item) => item.tone === 'warning')
|
||||
? 'warning'
|
||||
: 'normal'
|
||||
|
||||
const text =
|
||||
items.length === 1
|
||||
? items[0].text
|
||||
: tone === 'normal'
|
||||
? `${items.length} relay lanes operational`
|
||||
: `Relay lanes need attention`
|
||||
|
||||
return { text, tone, items }
|
||||
}
|
||||
|
||||
export function getMissionControlRelays(
|
||||
response: MissionControlBridgeStatusResponse | null | undefined
|
||||
): Record<string, MissionControlRelayPayload> | null {
|
||||
return response?.data?.ccip_relays && Object.keys(response.data.ccip_relays).length > 0
|
||||
? response.data.ccip_relays
|
||||
: response?.data?.ccip_relay
|
||||
? { mainnet: response.data.ccip_relay }
|
||||
: null
|
||||
}
|
||||
|
||||
export function getMissionControlRelayLabel(key: string): string {
|
||||
return missionControlRelayLabels[key] || key.toUpperCase()
|
||||
}
|
||||
|
||||
export const missionControlApi = {
|
||||
getBridgeStatus: async (): Promise<MissionControlBridgeStatusResponse> => {
|
||||
const response = await fetch(getMissionControlBridgeStatusUrl())
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as MissionControlBridgeStatusResponse
|
||||
},
|
||||
|
||||
getRelaySummary: async (): Promise<MissionControlRelaySummary | null> => {
|
||||
const json = await missionControlApi.getBridgeStatus()
|
||||
return summarizeMissionControlRelay(json)
|
||||
},
|
||||
|
||||
subscribeBridgeStatus: (
|
||||
onStatus: (status: MissionControlBridgeStatusResponse) => void,
|
||||
onError?: (error: unknown) => void
|
||||
): (() => void) => {
|
||||
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') {
|
||||
onError?.(new Error('EventSource is not available in this environment'))
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const eventSource = new window.EventSource(getMissionControlStreamUrl())
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as MissionControlBridgeStatusResponse
|
||||
onStatus(payload)
|
||||
} catch (error) {
|
||||
onError?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
onError?.(new Error('Mission-control live stream connection lost'))
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
}
|
||||
},
|
||||
|
||||
subscribeRelaySummary: (
|
||||
onSummary: (summary: MissionControlRelaySummary | null) => void,
|
||||
onError?: (error: unknown) => void
|
||||
): (() => void) => {
|
||||
return missionControlApi.subscribeBridgeStatus(
|
||||
(payload) => {
|
||||
onSummary(summarizeMissionControlRelay(payload))
|
||||
},
|
||||
onError
|
||||
)
|
||||
},
|
||||
}
|
||||
62
frontend/src/services/api/planner.ts
Normal file
62
frontend/src/services/api/planner.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { getExplorerApiBase } from './blockscout'
|
||||
|
||||
export interface PlannerProviderPair {
|
||||
tokenInSymbol?: string
|
||||
tokenOutSymbol?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface PlannerProviderCapability {
|
||||
chainId?: number
|
||||
provider?: string
|
||||
executionMode?: string
|
||||
live?: boolean
|
||||
quoteLive?: boolean
|
||||
executionLive?: boolean
|
||||
supportedLegTypes?: string[]
|
||||
pairs?: PlannerProviderPair[]
|
||||
notes?: string[]
|
||||
}
|
||||
|
||||
export interface PlannerCapabilitiesResponse {
|
||||
providers?: PlannerProviderCapability[]
|
||||
}
|
||||
|
||||
export interface InternalExecutionPlanResponse {
|
||||
plannerResponse?: {
|
||||
decision?: string
|
||||
steps?: unknown[]
|
||||
}
|
||||
execution?: {
|
||||
contractAddress?: string
|
||||
}
|
||||
}
|
||||
|
||||
const plannerBase = `${getExplorerApiBase()}/token-aggregation/api/v2`
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, init)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export const plannerApi = {
|
||||
getCapabilities: async (chainId = 138): Promise<PlannerCapabilitiesResponse> =>
|
||||
fetchJson<PlannerCapabilitiesResponse>(`${plannerBase}/providers/capabilities?chainId=${chainId}`),
|
||||
|
||||
getInternalExecutionPlan: async (): Promise<InternalExecutionPlanResponse> =>
|
||||
fetchJson<InternalExecutionPlanResponse>(`${plannerBase}/routes/internal-execution-plan`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sourceChainId: 138,
|
||||
tokenIn: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
tokenOut: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
|
||||
amountIn: '100000000000000000',
|
||||
}),
|
||||
}),
|
||||
}
|
||||
95
frontend/src/services/api/routes.ts
Normal file
95
frontend/src/services/api/routes.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { getExplorerApiBase } from './blockscout'
|
||||
|
||||
export interface RouteMatrixLeg {
|
||||
protocol?: string
|
||||
executor?: string
|
||||
poolAddress?: string
|
||||
}
|
||||
|
||||
export interface RouteMatrixRoute {
|
||||
routeId: string
|
||||
status?: string
|
||||
label?: string
|
||||
routeType?: string
|
||||
fromChainId?: number
|
||||
toChainId?: number
|
||||
tokenInSymbol?: string
|
||||
tokenOutSymbol?: string
|
||||
assetSymbol?: string
|
||||
hopCount?: number
|
||||
aggregatorFamilies?: string[]
|
||||
notes?: string[]
|
||||
reason?: string
|
||||
tokenInSymbols?: string[]
|
||||
legs?: RouteMatrixLeg[]
|
||||
}
|
||||
|
||||
export interface RouteMatrixCounts {
|
||||
liveSwapRoutes?: number
|
||||
liveBridgeRoutes?: number
|
||||
blockedOrPlannedRoutes?: number
|
||||
filteredLiveRoutes?: number
|
||||
}
|
||||
|
||||
export interface RouteMatrixResponse {
|
||||
generatedAt?: string
|
||||
updated?: string
|
||||
version?: string
|
||||
homeChainId?: number
|
||||
liveRoutes?: RouteMatrixRoute[]
|
||||
blockedOrPlannedRoutes?: RouteMatrixRoute[]
|
||||
counts?: RouteMatrixCounts
|
||||
}
|
||||
|
||||
export interface ExplorerNetwork {
|
||||
chainIdDecimal?: number
|
||||
chainName?: string
|
||||
shortName?: string
|
||||
}
|
||||
|
||||
export interface NetworksResponse {
|
||||
version?: string
|
||||
source?: string
|
||||
lastModified?: string
|
||||
networks?: ExplorerNetwork[]
|
||||
}
|
||||
|
||||
export interface MissionControlLiquidityPool {
|
||||
address: string
|
||||
dex?: string
|
||||
token0?: {
|
||||
symbol?: string
|
||||
}
|
||||
token1?: {
|
||||
symbol?: string
|
||||
}
|
||||
tvl?: number
|
||||
}
|
||||
|
||||
export interface MissionControlLiquidityPoolsResponse {
|
||||
pools?: MissionControlLiquidityPool[]
|
||||
}
|
||||
|
||||
const tokenAggregationBase = `${getExplorerApiBase()}/token-aggregation/api/v1`
|
||||
const missionControlBase = `${getExplorerApiBase()}/explorer-api/v1/mission-control`
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export const routesApi = {
|
||||
getNetworks: async (): Promise<NetworksResponse> =>
|
||||
fetchJson<NetworksResponse>(`${tokenAggregationBase}/networks`),
|
||||
|
||||
getRouteMatrix: async (): Promise<RouteMatrixResponse> =>
|
||||
fetchJson<RouteMatrixResponse>(`${tokenAggregationBase}/routes/matrix?includeNonLive=true`),
|
||||
|
||||
getTokenPools: async (tokenAddress: string): Promise<MissionControlLiquidityPoolsResponse> =>
|
||||
fetchJson<MissionControlLiquidityPoolsResponse>(
|
||||
`${missionControlBase}/liquidity/token/${tokenAddress}/pools`
|
||||
),
|
||||
}
|
||||
35
frontend/src/services/api/stats.test.ts
Normal file
35
frontend/src/services/api/stats.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeExplorerStats } from './stats'
|
||||
|
||||
describe('normalizeExplorerStats', () => {
|
||||
it('normalizes the local explorer stats shape', () => {
|
||||
expect(
|
||||
normalizeExplorerStats({
|
||||
total_blocks: 12,
|
||||
total_transactions: 34,
|
||||
total_addresses: 56,
|
||||
latest_block: 78,
|
||||
})
|
||||
).toEqual({
|
||||
total_blocks: 12,
|
||||
total_transactions: 34,
|
||||
total_addresses: 56,
|
||||
latest_block: 78,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes the public Blockscout stats shape with string counts and no latest block', () => {
|
||||
expect(
|
||||
normalizeExplorerStats({
|
||||
total_blocks: '2784760',
|
||||
total_transactions: '15788',
|
||||
total_addresses: '376',
|
||||
})
|
||||
).toEqual({
|
||||
total_blocks: 2784760,
|
||||
total_transactions: 15788,
|
||||
total_addresses: 376,
|
||||
latest_block: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
46
frontend/src/services/api/stats.ts
Normal file
46
frontend/src/services/api/stats.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { getExplorerApiBase } from './blockscout'
|
||||
|
||||
export interface ExplorerStats {
|
||||
total_blocks: number
|
||||
total_transactions: number
|
||||
total_addresses: number
|
||||
latest_block: number | null
|
||||
}
|
||||
|
||||
interface RawExplorerStats {
|
||||
total_blocks?: number | string | null
|
||||
total_transactions?: number | string | null
|
||||
total_addresses?: number | string | null
|
||||
latest_block?: number | string | null
|
||||
}
|
||||
|
||||
function toNumber(value: number | string | null | undefined): number {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string' && value.trim() !== '') return Number(value)
|
||||
return 0
|
||||
}
|
||||
|
||||
export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
|
||||
const latestBlockValue = raw.latest_block
|
||||
|
||||
return {
|
||||
total_blocks: toNumber(raw.total_blocks),
|
||||
total_transactions: toNumber(raw.total_transactions),
|
||||
total_addresses: toNumber(raw.total_addresses),
|
||||
latest_block:
|
||||
latestBlockValue == null || latestBlockValue === ''
|
||||
? null
|
||||
: toNumber(latestBlockValue),
|
||||
}
|
||||
}
|
||||
|
||||
export const statsApi = {
|
||||
get: async (): Promise<ExplorerStats> => {
|
||||
const response = await fetch(`${getExplorerApiBase()}/api/v2/stats`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const json = (await response.json()) as RawExplorerStats
|
||||
return normalizeExplorerStats(json)
|
||||
},
|
||||
}
|
||||
76
frontend/src/utils/dashboard.test.ts
Normal file
76
frontend/src/utils/dashboard.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Block } from '@/services/api/blocks'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
import { loadDashboardData } from './dashboard'
|
||||
|
||||
const sampleStats: ExplorerStats = {
|
||||
total_blocks: 12,
|
||||
total_transactions: 34,
|
||||
total_addresses: 56,
|
||||
latest_block: 78,
|
||||
}
|
||||
|
||||
const sampleBlocks: Block[] = [
|
||||
{
|
||||
chain_id: 138,
|
||||
number: 123,
|
||||
hash: '0xabc',
|
||||
timestamp: '2026-04-04T00:00:00.000Z',
|
||||
miner: '0xdef',
|
||||
transaction_count: 4,
|
||||
gas_used: 21000,
|
||||
gas_limit: 30000000,
|
||||
},
|
||||
]
|
||||
|
||||
describe('loadDashboardData', () => {
|
||||
it('returns both stats and recent blocks when both loaders succeed', async () => {
|
||||
const result = await loadDashboardData({
|
||||
loadStats: async () => sampleStats,
|
||||
loadRecentBlocks: async () => sampleBlocks,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: sampleStats,
|
||||
recentBlocks: sampleBlocks,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves recent blocks when stats loading fails', async () => {
|
||||
const onError = vi.fn()
|
||||
|
||||
const result = await loadDashboardData({
|
||||
loadStats: async () => {
|
||||
throw new Error('stats unavailable')
|
||||
},
|
||||
loadRecentBlocks: async () => sampleBlocks,
|
||||
onError,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: null,
|
||||
recentBlocks: sampleBlocks,
|
||||
})
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(onError).toHaveBeenCalledWith('stats', expect.any(Error))
|
||||
})
|
||||
|
||||
it('preserves stats when recent blocks loading fails', async () => {
|
||||
const onError = vi.fn()
|
||||
|
||||
const result = await loadDashboardData({
|
||||
loadStats: async () => sampleStats,
|
||||
loadRecentBlocks: async () => {
|
||||
throw new Error('blocks unavailable')
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: sampleStats,
|
||||
recentBlocks: [],
|
||||
})
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(onError).toHaveBeenCalledWith('blocks', expect.any(Error))
|
||||
})
|
||||
})
|
||||
37
frontend/src/utils/dashboard.ts
Normal file
37
frontend/src/utils/dashboard.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Block } from '@/services/api/blocks'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
|
||||
export interface DashboardData {
|
||||
stats: ExplorerStats | null
|
||||
recentBlocks: Block[]
|
||||
}
|
||||
|
||||
export interface DashboardLoaders {
|
||||
loadStats: () => Promise<ExplorerStats>
|
||||
loadRecentBlocks: () => Promise<Block[]>
|
||||
onError?: (scope: 'stats' | 'blocks', error: unknown) => void
|
||||
}
|
||||
|
||||
export async function loadDashboardData({
|
||||
loadStats,
|
||||
loadRecentBlocks,
|
||||
onError,
|
||||
}: DashboardLoaders): Promise<DashboardData> {
|
||||
const [statsResult, recentBlocksResult] = await Promise.allSettled([
|
||||
loadStats(),
|
||||
loadRecentBlocks(),
|
||||
])
|
||||
|
||||
if (statsResult.status === 'rejected') {
|
||||
onError?.('stats', statsResult.reason)
|
||||
}
|
||||
|
||||
if (recentBlocksResult.status === 'rejected') {
|
||||
onError?.('blocks', recentBlocksResult.reason)
|
||||
}
|
||||
|
||||
return {
|
||||
stats: statsResult.status === 'fulfilled' ? statsResult.value : null,
|
||||
recentBlocks: recentBlocksResult.status === 'fulfilled' ? recentBlocksResult.value : [],
|
||||
}
|
||||
}
|
||||
15
frontend/src/utils/format.test.ts
Normal file
15
frontend/src/utils/format.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatWeiAsEth } from './format'
|
||||
|
||||
describe('formatWeiAsEth', () => {
|
||||
it('formats zero and whole ETH values without losing precision', () => {
|
||||
expect(formatWeiAsEth('0')).toBe('0 ETH')
|
||||
expect(formatWeiAsEth('1000000000000000000')).toBe('1 ETH')
|
||||
expect(formatWeiAsEth('123450000000000000000')).toBe('123.45 ETH')
|
||||
})
|
||||
|
||||
it('truncates fractional ETH safely using bigint math', () => {
|
||||
expect(formatWeiAsEth('123456789123456789')).toBe('0.1234 ETH')
|
||||
expect(formatWeiAsEth('9007199254740993')).toBe('0.009 ETH')
|
||||
})
|
||||
})
|
||||
30
frontend/src/utils/format.ts
Normal file
30
frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
const WEI_DECIMALS = 18n
|
||||
|
||||
export function formatWeiAsEth(value: string, fractionDigits = 4): string {
|
||||
try {
|
||||
const normalizedDigits = Math.max(0, Math.min(18, fractionDigits))
|
||||
const wei = BigInt(value)
|
||||
const divisor = 10n ** WEI_DECIMALS
|
||||
const whole = wei / divisor
|
||||
const fraction = wei % divisor
|
||||
|
||||
if (normalizedDigits === 0) {
|
||||
return `${whole.toString()} ETH`
|
||||
}
|
||||
|
||||
const scale = 10n ** (WEI_DECIMALS - BigInt(normalizedDigits))
|
||||
const truncatedFraction = fraction / scale
|
||||
const paddedFraction = truncatedFraction
|
||||
.toString()
|
||||
.padStart(normalizedDigits, '0')
|
||||
.replace(/0+$/, '')
|
||||
|
||||
if (!paddedFraction) {
|
||||
return `${whole.toString()} ETH`
|
||||
}
|
||||
|
||||
return `${whole.toString()}.${paddedFraction} ETH`
|
||||
} catch {
|
||||
return '0 ETH'
|
||||
}
|
||||
}
|
||||
38
frontend/src/utils/search.test.ts
Normal file
38
frontend/src/utils/search.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { inferDirectSearchTarget } from './search'
|
||||
|
||||
describe('inferDirectSearchTarget', () => {
|
||||
it('detects addresses and normalizes the prefix', () => {
|
||||
expect(
|
||||
inferDirectSearchTarget('0X1234567890123456789012345678901234567890'),
|
||||
).toEqual({
|
||||
kind: 'address',
|
||||
href: '/addresses/0x1234567890123456789012345678901234567890',
|
||||
label: 'Open address',
|
||||
})
|
||||
})
|
||||
|
||||
it('detects transaction hashes', () => {
|
||||
expect(
|
||||
inferDirectSearchTarget(
|
||||
'0x1234567890123456789012345678901234567890123456789012345678901234',
|
||||
),
|
||||
).toEqual({
|
||||
kind: 'transaction',
|
||||
href: '/transactions/0x1234567890123456789012345678901234567890123456789012345678901234',
|
||||
label: 'Open transaction',
|
||||
})
|
||||
})
|
||||
|
||||
it('detects block numbers', () => {
|
||||
expect(inferDirectSearchTarget(' 12345 ')).toEqual({
|
||||
kind: 'block',
|
||||
href: '/blocks/12345',
|
||||
label: 'Open block',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for generic text', () => {
|
||||
expect(inferDirectSearchTarget('cUSDT')).toBeNull()
|
||||
})
|
||||
})
|
||||
41
frontend/src/utils/search.ts
Normal file
41
frontend/src/utils/search.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type DirectSearchTarget =
|
||||
| { kind: 'address'; href: string; label: string }
|
||||
| { kind: 'transaction'; href: string; label: string }
|
||||
| { kind: 'block'; href: string; label: string }
|
||||
|
||||
const addressPattern = /^0x[a-f0-9]{40}$/i
|
||||
const transactionHashPattern = /^0x[a-f0-9]{64}$/i
|
||||
const blockNumberPattern = /^\d+$/
|
||||
|
||||
export function inferDirectSearchTarget(query: string): DirectSearchTarget | null {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (addressPattern.test(trimmed)) {
|
||||
return {
|
||||
kind: 'address',
|
||||
href: `/addresses/0x${trimmed.slice(2)}`,
|
||||
label: 'Open address',
|
||||
}
|
||||
}
|
||||
|
||||
if (transactionHashPattern.test(trimmed)) {
|
||||
return {
|
||||
kind: 'transaction',
|
||||
href: `/transactions/0x${trimmed.slice(2)}`,
|
||||
label: 'Open transaction',
|
||||
}
|
||||
}
|
||||
|
||||
if (blockNumberPattern.test(trimmed)) {
|
||||
return {
|
||||
kind: 'block',
|
||||
href: `/blocks/${trimmed}`,
|
||||
label: 'Open block',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
42
frontend/src/utils/watchlist.test.ts
Normal file
42
frontend/src/utils/watchlist.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isWatchlistEntry,
|
||||
normalizeWatchlistAddress,
|
||||
parseStoredWatchlist,
|
||||
sanitizeWatchlistEntries,
|
||||
} from './watchlist'
|
||||
|
||||
describe('watchlist utils', () => {
|
||||
it('normalizes only valid addresses', () => {
|
||||
expect(normalizeWatchlistAddress(' 0x1234567890123456789012345678901234567890 ')).toBe(
|
||||
'0x1234567890123456789012345678901234567890',
|
||||
)
|
||||
expect(normalizeWatchlistAddress('not-an-address')).toBe('')
|
||||
})
|
||||
|
||||
it('filters invalid entries and deduplicates case-insensitively', () => {
|
||||
expect(
|
||||
sanitizeWatchlistEntries([
|
||||
'0x1234567890123456789012345678901234567890',
|
||||
'0x1234567890123456789012345678901234567890',
|
||||
'0x1234567890123456789012345678901234567890'.toUpperCase(),
|
||||
'bad',
|
||||
42,
|
||||
]),
|
||||
).toEqual(['0x1234567890123456789012345678901234567890'])
|
||||
})
|
||||
|
||||
it('parses stored JSON safely', () => {
|
||||
expect(parseStoredWatchlist('["0x1234567890123456789012345678901234567890"]')).toEqual([
|
||||
'0x1234567890123456789012345678901234567890',
|
||||
])
|
||||
expect(parseStoredWatchlist('not json')).toEqual([])
|
||||
})
|
||||
|
||||
it('matches saved addresses case-insensitively', () => {
|
||||
const entries = ['0x1234567890123456789012345678901234567890']
|
||||
expect(
|
||||
isWatchlistEntry(entries, '0x1234567890123456789012345678901234567890'.toUpperCase()),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
73
frontend/src/utils/watchlist.ts
Normal file
73
frontend/src/utils/watchlist.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export const WATCHLIST_STORAGE_KEY = 'explorerWatchlist'
|
||||
|
||||
const addressPattern = /^0x[a-f0-9]{40}$/i
|
||||
|
||||
export function normalizeWatchlistAddress(value: string) {
|
||||
const trimmed = value.trim()
|
||||
return addressPattern.test(trimmed) ? `0x${trimmed.slice(2)}` : ''
|
||||
}
|
||||
|
||||
export function sanitizeWatchlistEntries(input: unknown) {
|
||||
if (!Array.isArray(input)) {
|
||||
return [] as string[]
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const next: string[] = []
|
||||
|
||||
for (const entry of input) {
|
||||
if (typeof entry !== 'string') {
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = normalizeWatchlistAddress(entry)
|
||||
if (!normalized) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = normalized.toLowerCase()
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(key)
|
||||
next.push(normalized)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function parseStoredWatchlist(raw: string | null) {
|
||||
if (!raw) {
|
||||
return [] as string[]
|
||||
}
|
||||
|
||||
try {
|
||||
return sanitizeWatchlistEntries(JSON.parse(raw))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function readWatchlistFromStorage(storage: Pick<Storage, 'getItem'>) {
|
||||
return parseStoredWatchlist(storage.getItem(WATCHLIST_STORAGE_KEY))
|
||||
}
|
||||
|
||||
export function writeWatchlistToStorage(
|
||||
storage: Pick<Storage, 'setItem'>,
|
||||
entries: string[],
|
||||
) {
|
||||
storage.setItem(
|
||||
WATCHLIST_STORAGE_KEY,
|
||||
JSON.stringify(sanitizeWatchlistEntries(entries)),
|
||||
)
|
||||
}
|
||||
|
||||
export function isWatchlistEntry(entries: string[], address: string) {
|
||||
const normalized = normalizeWatchlistAddress(address)
|
||||
if (!normalized) {
|
||||
return false
|
||||
}
|
||||
|
||||
return entries.some((entry) => entry.toLowerCase() === normalized.toLowerCase())
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@/libs': path.resolve(__dirname, './libs'),
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
Reference in New Issue
Block a user