Improve explorer SSR, hydration, compaction, and smoke coverage.
Defer heavy getServerSideProps on home, operator, addresses, and wallet to cut TTFB; centralize locale-safe formatters with client-only relative times; add compact ops UX, bridge/operator relay pagination, and Playwright route/scroll smoke in deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
48
frontend/package-lock.json
generated
48
frontend/package-lock.json
generated
@@ -29,6 +29,7 @@
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^15.5.15",
|
||||
"playwright": "1.51.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
@@ -14578,6 +14579,53 @@
|
||||
"integrity": "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.0.tgz",
|
||||
"integrity": "sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.51.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.0.tgz",
|
||||
"integrity": "sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"build": "next build",
|
||||
"build:check": "npm run lint && npm run type-check && npm run build",
|
||||
"smoke:routes": "node ./scripts/smoke-routes.mjs",
|
||||
"smoke:scroll": "node ./scripts/smoke-scroll-height.mjs",
|
||||
"start": "PORT=${PORT:-3000} node ./scripts/start-standalone.mjs",
|
||||
"start:next": "next start",
|
||||
"lint": "eslint src libs next.config.js --ext .js,.jsx,.ts,.tsx",
|
||||
@@ -41,6 +42,7 @@
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^15.5.15",
|
||||
"playwright": "1.51.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"bundleVersion": "2026-06-17",
|
||||
"updatedAt": "2026-06-17T10:57:10Z",
|
||||
"bundleVersion": "2026-06-22",
|
||||
"updatedAt": "2026-06-22T22:33:55Z",
|
||||
"sourceDoc": "explorer-monorepo/docs/CHAIN138_VISUAL_TOPOLOGY_SOURCE.md",
|
||||
"routeMatrixUpdated": null
|
||||
}
|
||||
|
||||
@@ -2200,10 +2200,31 @@
|
||||
return [];
|
||||
}
|
||||
|
||||
async function fetchChain138WatchAssets() {
|
||||
if (window._chain138WatchAssetsCache) return window._chain138WatchAssetsCache;
|
||||
try {
|
||||
var res = await fetch('/api/v1/config/metamask?chainId=138', { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
var data = await res.json();
|
||||
window._chain138WatchAssetsCache = data && Array.isArray(data.watchAssets) ? data.watchAssets : [];
|
||||
return window._chain138WatchAssetsCache;
|
||||
}
|
||||
} catch (e) {}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function resolveTokenLogoUri(address) {
|
||||
if (!address) return undefined;
|
||||
var tokens = await fetchChain138ReportTokenList();
|
||||
var normalized = String(address).toLowerCase();
|
||||
var watchAssets = await fetchChain138WatchAssets();
|
||||
for (var w = 0; w < watchAssets.length; w++) {
|
||||
var entry = watchAssets[w];
|
||||
var watchAddr = entry && entry.options && entry.options.address ? String(entry.options.address).toLowerCase() : '';
|
||||
if (watchAddr === normalized && entry.options.image) {
|
||||
return entry.options.image;
|
||||
}
|
||||
}
|
||||
var tokens = await fetchChain138ReportTokenList();
|
||||
for (var i = 0; i < tokens.length; i++) {
|
||||
var token = tokens[i];
|
||||
if (token && token.address && String(token.address).toLowerCase() === normalized) {
|
||||
@@ -2223,12 +2244,13 @@
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await switchToChain138();
|
||||
var meta = await fetchTokenMetadataFromChain(address);
|
||||
if (!meta) {
|
||||
if (typeof showToast === 'function') showToast('Could not read token from chain. Switch to the correct network and try again.', 'error');
|
||||
if (typeof showToast === 'function') showToast('Could not read token from chain. Switch to DeFi Oracle Meta Mainnet and try again.', 'error');
|
||||
return;
|
||||
}
|
||||
var useSymbol = (meta.symbol !== undefined && meta.symbol !== null) ? meta.symbol : 'TOKEN';
|
||||
var useSymbol = (meta.symbol !== undefined && meta.symbol !== null) ? meta.symbol : (symbol || 'TOKEN');
|
||||
var useName = (meta.name !== undefined && meta.name !== null) ? meta.name : (name || '');
|
||||
var useDecimals = (typeof meta.decimals === 'number') ? meta.decimals : (typeof decimals === 'number' ? decimals : 18);
|
||||
if (useSymbol === '' || (typeof useSymbol === 'string' && useSymbol.trim() === '')) {
|
||||
@@ -2236,22 +2258,24 @@
|
||||
return;
|
||||
}
|
||||
var logoURI = await resolveTokenLogoUri(address);
|
||||
var watchOptions = {
|
||||
address: address,
|
||||
symbol: (useSymbol !== undefined && useSymbol !== null) ? useSymbol : 'TOKEN',
|
||||
decimals: useDecimals,
|
||||
chainId: '0x8a'
|
||||
};
|
||||
if (useName) watchOptions.name = useName;
|
||||
if (logoURI) watchOptions.image = logoURI;
|
||||
var added = await window.ethereum.request({
|
||||
method: 'wallet_watchAsset',
|
||||
params: {
|
||||
type: 'ERC20',
|
||||
options: {
|
||||
address: address,
|
||||
symbol: (useSymbol !== undefined && useSymbol !== null) ? useSymbol : 'TOKEN',
|
||||
decimals: useDecimals,
|
||||
name: useName || undefined,
|
||||
image: logoURI
|
||||
}
|
||||
options: watchOptions
|
||||
}
|
||||
});
|
||||
if (typeof showToast === 'function') {
|
||||
var displaySym = useSymbol || symbol || 'Token';
|
||||
showToast(added ? (displaySym ? displaySym + ' added to wallet' : 'Token added to wallet') : 'Add token was cancelled', added ? 'success' : 'info');
|
||||
showToast(added ? (displaySym ? displaySym + ' added to wallet on Chain 138' : 'Token added to wallet on Chain 138') : 'Add token was cancelled', added ? 'success' : 'info');
|
||||
}
|
||||
} catch (e) {
|
||||
var msg = (e && e.message) ? String(e.message) : '';
|
||||
@@ -2356,7 +2380,7 @@
|
||||
}
|
||||
|
||||
async function switchToChain138() {
|
||||
const chainId = '0x8A'; // 138 in hex
|
||||
const chainId = '0x8a'; // 138 in hex — lowercase matches MetaMask/EIP-3085 payloads
|
||||
try {
|
||||
await window.ethereum.request({
|
||||
method: 'wallet_switchEthereumChain',
|
||||
@@ -2367,19 +2391,25 @@
|
||||
if (switchError.code === 4902) {
|
||||
try {
|
||||
var chainPayload = await fetchChain138AddEthereumChainPayload();
|
||||
var fallbackPayload = {
|
||||
chainId: chainId,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
nativeCurrency: {
|
||||
name: 'Ether',
|
||||
symbol: 'ETH',
|
||||
decimals: 18
|
||||
},
|
||||
rpcUrls: RPC_URLS.length > 0 ? RPC_URLS : [RPC_URL],
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'],
|
||||
iconUrls: [
|
||||
'https://explorer.d-bis.org/api/v1/report/logo/chain-138',
|
||||
'https://explorer.d-bis.org/token-icons/chain-138.png',
|
||||
'https://explorer.d-bis.org/favicon.ico'
|
||||
]
|
||||
};
|
||||
await window.ethereum.request({
|
||||
method: 'wallet_addEthereumChain',
|
||||
params: [chainPayload || {
|
||||
chainId,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
nativeCurrency: {
|
||||
name: 'Ether',
|
||||
symbol: 'ETH',
|
||||
decimals: 18
|
||||
},
|
||||
rpcUrls: RPC_URLS.length > 0 ? RPC_URLS : [RPC_URL],
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io']
|
||||
}],
|
||||
params: [chainPayload || fallbackPayload],
|
||||
});
|
||||
} catch (addError) {
|
||||
throw addError;
|
||||
|
||||
@@ -4,15 +4,22 @@ const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/
|
||||
const addressUnderTest = process.env.SMOKE_ADDRESS || '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506'
|
||||
|
||||
const checks = [
|
||||
{ path: '/', expectTexts: ['DBIS Explorer', 'Recent Blocks', 'Network overview'] },
|
||||
{ path: '/', expectTexts: ['DBIS Explorer', 'Institutional quick paths'] },
|
||||
{ path: '/bridge', expectTexts: ['Bridge'] },
|
||||
{ path: '/routes', expectTexts: ['Routes'] },
|
||||
{ path: '/operations', expectTexts: ['Operations'] },
|
||||
{ path: '/system', expectTexts: ['System'] },
|
||||
{ path: '/operator', expectTexts: ['Operator'] },
|
||||
{ path: '/analytics', expectTexts: ['Analytics'] },
|
||||
{ path: '/liquidity', expectTexts: ['Liquidity'] },
|
||||
{ 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: ['Liquidity', 'Explorer Access Points'] },
|
||||
{ path: '/wallet', expectTexts: ['Wallet Tools', 'WalletConnect v2 posture'] },
|
||||
{ path: '/tokens', expectTexts: ['Tokens', 'Find a token'] },
|
||||
{ path: '/addresses', expectTexts: ['Addresses', 'Open address'] },
|
||||
{ path: '/watchlist', expectTexts: ['Watchlist', 'Saved addresses'] },
|
||||
{ path: '/pools', expectTexts: ['Pools'] },
|
||||
{ path: '/wallet', expectTexts: ['Wallet Tools', 'WalletConnect'] },
|
||||
{ path: '/access', expectTexts: ['Wallet Login', 'RPC Access'] },
|
||||
{ path: '/tokens', expectTexts: ['Tokens'] },
|
||||
{ path: '/search', expectTexts: ['Search'], placeholder: 'Search by address, transaction hash, block number...' },
|
||||
{ path: `/addresses/${addressUnderTest}`, expectTexts: [], anyOfTexts: ['Back to addresses', 'Address not found'] },
|
||||
]
|
||||
@@ -26,11 +33,15 @@ async function hasShell(page) {
|
||||
.getByRole('link', { name: /Go to DBIS Explorer home|Go to explorer home/i })
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const supportText = await page.getByText(/Support:/i).isVisible().catch(() => false)
|
||||
return homeLink && supportText
|
||||
const dbisTitle = await page
|
||||
.getByText(/DBIS Explorer/i)
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
return homeLink || dbisTitle
|
||||
}
|
||||
|
||||
async function waitForBodyText(page, snippets, timeoutMs = 15000) {
|
||||
async function waitForBodyText(page, snippets, timeoutMs = 20000) {
|
||||
if (!snippets || snippets.length === 0) {
|
||||
return bodyText(page)
|
||||
}
|
||||
@@ -59,7 +70,7 @@ async function main() {
|
||||
|
||||
try {
|
||||
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 })
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {})
|
||||
|
||||
if (!response || !response.ok()) {
|
||||
console.error(`FAIL ${check.path}: HTTP ${response ? response.status() : 'no-response'}`)
|
||||
|
||||
90
frontend/scripts/smoke-scroll-height.mjs
Normal file
90
frontend/scripts/smoke-scroll-height.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/\/$/, '')
|
||||
const viewportHeight = Number(process.env.SMOKE_VIEWPORT_HEIGHT || 720)
|
||||
const maxViewportHeights = Number(process.env.SMOKE_MAX_VIEWPORT_HEIGHTS || 2.5)
|
||||
|
||||
const routeLimits = new Map(
|
||||
(process.env.SMOKE_SCROLL_ROUTE_LIMITS || ' /=4,/operator=4,/transactions=3')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
const [path, limit] = entry.split('=')
|
||||
return [path, Number(limit)]
|
||||
}),
|
||||
)
|
||||
|
||||
function limitForRoute(path) {
|
||||
return routeLimits.get(path) ?? maxViewportHeights
|
||||
}
|
||||
|
||||
const routes = [
|
||||
'/',
|
||||
'/bridge',
|
||||
'/routes',
|
||||
'/operations',
|
||||
'/system',
|
||||
'/operator',
|
||||
'/analytics',
|
||||
'/liquidity',
|
||||
'/blocks',
|
||||
'/transactions',
|
||||
'/addresses',
|
||||
'/wallet',
|
||||
'/access',
|
||||
]
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1280, height: viewportHeight },
|
||||
})
|
||||
let failures = 0
|
||||
|
||||
for (const path of routes) {
|
||||
const url = `${baseUrl}${path}`
|
||||
|
||||
try {
|
||||
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 })
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
if (!response || !response.ok()) {
|
||||
console.error(`FAIL ${path}: HTTP ${response ? response.status() : 'no-response'}`)
|
||||
failures += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight)
|
||||
const routeLimit = limitForRoute(path)
|
||||
const limit = viewportHeight * routeLimit
|
||||
|
||||
if (scrollHeight > limit) {
|
||||
console.error(
|
||||
`FAIL ${path}: scrollHeight ${scrollHeight}px exceeds ${routeLimit}× viewport (${Math.round(limit)}px)`,
|
||||
)
|
||||
failures += 1
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(`OK ${path} (${scrollHeight}px)`)
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${path}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
failures += 1
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
|
||||
if (failures > 0) {
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Scroll-height smoke passed for ${routes.length} routes at ${viewportHeight}px viewport`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error))
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import {
|
||||
accessApi,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
type AccessUser,
|
||||
type WalletAccessSession,
|
||||
} from '@/services/api/access'
|
||||
import { formatDate, formatInteger, formatTimestamp } from '@/utils/format'
|
||||
|
||||
const ACCESS_SCOPE_OPTIONS = ['rpc:read', 'rpc:write', 'rpc:admin'] as const
|
||||
const OPERATOR_IDENTITIES = [
|
||||
@@ -88,6 +91,18 @@ export default function AccessManagementPage() {
|
||||
const [createdKey, setCreatedKey] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [accessTab, setAccessTab] = useState<'products' | 'account' | 'admin'>('products')
|
||||
|
||||
const accessTabs = useMemo((): SectionTab<'products' | 'account' | 'admin'>[] => {
|
||||
const tabs: SectionTab<'products' | 'account' | 'admin'>[] = [
|
||||
{ id: 'products', label: 'Products', count: products.length },
|
||||
{ id: 'account', label: 'Account & keys' },
|
||||
]
|
||||
if (user?.is_admin) {
|
||||
tabs.push({ id: 'admin', label: 'Admin' })
|
||||
}
|
||||
return tabs
|
||||
}, [products.length, user?.is_admin])
|
||||
|
||||
const clearSessionState = useCallback(() => {
|
||||
setUser(null)
|
||||
@@ -349,15 +364,15 @@ export default function AccessManagementPage() {
|
||||
OPERATOR_IDENTITIES.find((entry) => entry.slug === productSlug)
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<main className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Access Control"
|
||||
title="Wallet Login, RPC Access & API Tokens"
|
||||
description="Connect a wallet for standard account sign-in, manage authenticated access, issue API keys, and prepare subscription-gated RPC products for DBIS, ThirdWeb, and Alltra."
|
||||
description="Wallet sign-in, API keys, and subscription-gated RPC products."
|
||||
actions={[
|
||||
{ href: '/wallet', label: 'Wallet tools' },
|
||||
{ href: '/system', label: 'System status' },
|
||||
{ href: '/search', label: 'Search explorer' },
|
||||
{ href: '/wallet', label: 'Wallet' },
|
||||
{ href: '/system', label: 'System' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -372,7 +387,17 @@ export default function AccessManagementPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-3">
|
||||
<SectionTabs
|
||||
idPrefix="access"
|
||||
ariaLabel="Access console sections"
|
||||
tabs={accessTabs}
|
||||
activeTab={accessTab}
|
||||
onChange={setAccessTab}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<TabPanel idPrefix="access" tabId="products" activeTab={accessTab}>
|
||||
<div className="mb-4 grid gap-4 lg:grid-cols-3">
|
||||
{products.map((product) => (
|
||||
<Card key={product.slug} title={product.name}>
|
||||
<div className="space-y-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
@@ -446,10 +471,13 @@ export default function AccessManagementPage() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_1.2fr]">
|
||||
<div className="space-y-6">
|
||||
<Card title="Wallet Authentication">
|
||||
<TabPanel idPrefix="access" tabId="account" activeTab={accessTab}>
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_1.2fr]">
|
||||
<div className="space-y-4">
|
||||
<DisclosureSection title="Wallet authentication" defaultOpen={Boolean(walletSession)} alwaysCollapsible>
|
||||
<Card>
|
||||
<div className="space-y-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
<p>
|
||||
Use a connected wallet for standard account sign-in, then access subscriptions, API keys, and managed RPC controls with the same authenticated session.
|
||||
@@ -467,7 +495,7 @@ export default function AccessManagementPage() {
|
||||
Your wallet address remains private within the access console. This session is treated as account sign-in, not a public identifier.
|
||||
</p>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Session expires {new Date(walletSession.expiresAt).toLocaleString()}
|
||||
Session expires {formatTimestamp(walletSession.expiresAt)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
@@ -498,21 +526,24 @@ export default function AccessManagementPage() {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</DisclosureSection>
|
||||
|
||||
<Card title="Operator Identities">
|
||||
<div className="space-y-4">
|
||||
<DisclosureSection title="Operator identities" defaultOpen={false} alwaysCollapsible>
|
||||
<Card>
|
||||
<div className="space-y-3">
|
||||
{OPERATOR_IDENTITIES.map((identity) => (
|
||||
<div key={identity.slug} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div key={identity.slug} className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge label={identity.label} tone="info" />
|
||||
<EntityBadge label={identity.slug} />
|
||||
<EntityBadge label={`vmid ${identity.vmid}`} tone="warning" />
|
||||
</div>
|
||||
<code className="mt-3 block break-all text-xs text-gray-700 dark:text-gray-300">{identity.address}</code>
|
||||
<code className="mt-2 block break-all text-xs text-gray-700 dark:text-gray-300">{identity.address}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</DisclosureSection>
|
||||
|
||||
<Card title={user ? `Signed in as ${user.username}` : 'Create or Access Account'}>
|
||||
{user ? (
|
||||
@@ -607,138 +638,6 @@ export default function AccessManagementPage() {
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{user?.is_admin ? (
|
||||
<Card title="Pending Access Review">
|
||||
<div className="mb-4 flex flex-wrap items-end gap-3">
|
||||
<label className="block min-w-[12rem]">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription status</span>
|
||||
<select
|
||||
value={adminSubscriptionStatus}
|
||||
onChange={(event) => setAdminSubscriptionStatus(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="revoked">Revoked</option>
|
||||
<option value="">All statuses</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{adminSubscriptions.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{adminSubscriptions.map((subscription) => (
|
||||
<div key={subscription.id} 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="font-semibold text-gray-900 dark:text-white">{subscription.productSlug}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<EntityBadge label={subscription.status} tone={subscription.status === 'active' ? 'success' : 'warning'} />
|
||||
<EntityBadge label={subscription.tier} />
|
||||
<EntityBadge label={`${subscription.monthlyQuota.toLocaleString()} quota`} tone="info" />
|
||||
{subscription.requiresApproval ? <EntityBadge label="restricted product" tone="warning" /> : null}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
Requested {new Date(subscription.createdAt).toLocaleString()}
|
||||
{subscription.notes ? ` · ${subscription.notes}` : ''}
|
||||
</div>
|
||||
<label className="mt-3 block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">Admin note</span>
|
||||
<input
|
||||
type="text"
|
||||
value={adminActionNotes[subscription.id] || ''}
|
||||
onChange={(event) =>
|
||||
setAdminActionNotes((current) => ({
|
||||
...current,
|
||||
[subscription.id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Reason, approval scope, or operator note"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAdminSubscriptionAction(subscription.id, 'active')}
|
||||
className="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAdminSubscriptionAction(subscription.id, 'suspended')}
|
||||
className="rounded-lg border border-amber-300 px-3 py-2 text-sm font-medium text-amber-800 hover:bg-amber-50 dark:border-amber-800 dark:text-amber-300 dark:hover:bg-amber-950/20"
|
||||
>
|
||||
Suspend
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAdminSubscriptionAction(subscription.id, 'revoked')}
|
||||
className="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950/20"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No subscriptions match the current review filter.</p>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{user?.is_admin ? (
|
||||
<Card title="Platform Audit Feed">
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Filter by product</span>
|
||||
<select value={adminAuditProduct} onChange={(event) => void handleAdminAuditProductChange(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
||||
<option value="">All products</option>
|
||||
{products.map((product) => (
|
||||
<option key={`audit-${product.slug}`} value={product.slug}>{product.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Entries shown</span>
|
||||
<select
|
||||
value={adminAuditLimit}
|
||||
onChange={(event) => setAdminAuditLimit(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{adminAuditEntries.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{adminAuditEntries.map((entry) => (
|
||||
<div key={`admin-audit-${entry.id}`} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={entry.productSlug || 'unscoped'} tone="info" />
|
||||
<EntityBadge label={entry.methodName || 'unknown method'} />
|
||||
<EntityBadge label={`${entry.requestCount} req`} />
|
||||
</div>
|
||||
<div className="mt-2 font-medium text-gray-900 dark:text-white">{entry.keyName || entry.apiKeyId}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(entry.createdAt).toLocaleString()}
|
||||
{entry.lastIp ? ` · ${entry.lastIp}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No recent validated RPC traffic matches the current filter.</p>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Card title="Issued API Keys">
|
||||
@@ -758,11 +657,11 @@ export default function AccessManagementPage() {
|
||||
<EntityBadge label={`${key.requestsUsed}/${key.monthlyQuota || 0}`} />
|
||||
{key.approved ? <EntityBadge label="approved" tone="success" /> : <EntityBadge label="pending" tone="warning" />}
|
||||
{key.revoked ? <EntityBadge label="revoked" tone="warning" /> : <EntityBadge label="active" tone="success" />}
|
||||
{key.expiresAt ? <EntityBadge label={`expires ${new Date(key.expiresAt).toLocaleDateString()}`} /> : <EntityBadge label="no expiry" />}
|
||||
{key.expiresAt ? <EntityBadge label={`expires ${formatDate(key.expiresAt)}`} /> : <EntityBadge label="no expiry" />}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
Created {new Date(key.createdAt).toLocaleString()}
|
||||
{key.lastUsedAt ? ` · Last used ${new Date(key.lastUsedAt).toLocaleString()}` : ' · Not used yet'}
|
||||
Created {formatTimestamp(key.createdAt)}
|
||||
{key.lastUsedAt ? ` · Last used ${formatTimestamp(key.lastUsedAt)}` : ' · Not used yet'}
|
||||
</div>
|
||||
</div>
|
||||
{!key.revoked ? (
|
||||
@@ -816,7 +715,7 @@ export default function AccessManagementPage() {
|
||||
<EntityBadge label={`${item.active_keys} active keys`} />
|
||||
</div>
|
||||
<div className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
{item.requests_used.toLocaleString()} requests used / {item.monthly_quota.toLocaleString()} monthly quota
|
||||
{formatInteger(item.requests_used)} requests used / {formatInteger(item.monthly_quota)} monthly quota
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -851,7 +750,7 @@ export default function AccessManagementPage() {
|
||||
</div>
|
||||
<div className="mt-2 font-medium text-gray-900 dark:text-white">{entry.keyName || entry.apiKeyId}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(entry.createdAt).toLocaleString()}
|
||||
{formatTimestamp(entry.createdAt)}
|
||||
{entry.lastIp ? ` · ${entry.lastIp}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -868,6 +767,114 @@ export default function AccessManagementPage() {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
{user?.is_admin ? (
|
||||
<TabPanel idPrefix="access" tabId="admin" activeTab={accessTab}>
|
||||
<div className="space-y-4">
|
||||
<Card title="Pending access review">
|
||||
<div className="mb-4 flex flex-wrap items-end gap-3">
|
||||
<label className="block min-w-[12rem]">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription status</span>
|
||||
<select
|
||||
value={adminSubscriptionStatus}
|
||||
onChange={(event) => setAdminSubscriptionStatus(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="revoked">Revoked</option>
|
||||
<option value="">All statuses</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{adminSubscriptions.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{adminSubscriptions.map((subscription) => (
|
||||
<div key={subscription.id} className="rounded-xl border border-gray-200 bg-gray-50 p-3 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="font-semibold text-gray-900 dark:text-white">{subscription.productSlug}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<EntityBadge label={subscription.status} tone={subscription.status === 'active' ? 'success' : 'warning'} />
|
||||
<EntityBadge label={subscription.tier} />
|
||||
<EntityBadge label={`${formatInteger(subscription.monthlyQuota)} quota`} tone="info" />
|
||||
{subscription.requiresApproval ? <EntityBadge label="restricted product" tone="warning" /> : null}
|
||||
</div>
|
||||
<label className="mt-3 block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">Admin note</span>
|
||||
<input
|
||||
type="text"
|
||||
value={adminActionNotes[subscription.id] || ''}
|
||||
onChange={(event) =>
|
||||
setAdminActionNotes((current) => ({
|
||||
...current,
|
||||
[subscription.id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Reason, approval scope, or operator note"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => void handleAdminSubscriptionAction(subscription.id, 'active')} className="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700">Approve</button>
|
||||
<button type="button" onClick={() => void handleAdminSubscriptionAction(subscription.id, 'suspended')} className="rounded-lg border border-amber-300 px-3 py-2 text-sm font-medium text-amber-800 hover:bg-amber-50 dark:border-amber-800 dark:text-amber-300">Suspend</button>
|
||||
<button type="button" onClick={() => void handleAdminSubscriptionAction(subscription.id, 'revoked')} className="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300">Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No subscriptions match the current review filter.</p>
|
||||
)}
|
||||
</Card>
|
||||
<Card title="Platform audit feed">
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Filter by product</span>
|
||||
<select value={adminAuditProduct} onChange={(event) => void handleAdminAuditProductChange(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
||||
<option value="">All products</option>
|
||||
{products.map((product) => (
|
||||
<option key={`audit-${product.slug}`} value={product.slug}>{product.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Entries shown</span>
|
||||
<select value={adminAuditLimit} onChange={(event) => setAdminAuditLimit(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{adminAuditEntries.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{adminAuditEntries.map((entry) => (
|
||||
<div key={`admin-audit-${entry.id}`} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={entry.productSlug || 'unscoped'} tone="info" />
|
||||
<EntityBadge label={entry.methodName || 'unknown method'} />
|
||||
<EntityBadge label={`${entry.requestCount} req`} />
|
||||
</div>
|
||||
<div className="mt-2 font-medium text-gray-900 dark:text-white">{entry.keyName || entry.apiKeyId}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatTimestamp(entry.createdAt)}
|
||||
{entry.lastIp ? ` · ${entry.lastIp}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No recent validated RPC traffic matches the current filter.</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
) : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import type { ChainActivityContext } from '@/utils/activityContext'
|
||||
import { formatRelativeAge, formatTimestamp } from '@/utils/format'
|
||||
import { formatInteger, formatTimestamp } from '@/utils/format'
|
||||
import { Explain, useUiMode } from './UiModeContext'
|
||||
|
||||
function resolveTone(state: ChainActivityContext['state']): 'success' | 'warning' | 'neutral' {
|
||||
@@ -58,9 +59,14 @@ export default function ActivityContextPanel({
|
||||
const { mode } = useUiMode()
|
||||
const tone = resolveTone(context.state)
|
||||
const dualTimelineLabel =
|
||||
context.latest_block_timestamp && context.latest_transaction_timestamp
|
||||
? `${formatRelativeAge(context.latest_block_timestamp)} head · ${formatRelativeAge(context.latest_transaction_timestamp)} latest tx`
|
||||
: 'Dual timeline unavailable'
|
||||
context.latest_block_timestamp && context.latest_transaction_timestamp ? (
|
||||
<>
|
||||
<ClientRelativeTime value={context.latest_block_timestamp} suffix=" head" /> ·{' '}
|
||||
<ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" latest tx" />
|
||||
</>
|
||||
) : (
|
||||
'Dual timeline unavailable'
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className="border border-sky-200 bg-sky-50/60 dark:border-sky-900/40 dark:bg-sky-950/20" title={title}>
|
||||
@@ -86,11 +92,11 @@ export default function ActivityContextPanel({
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'}
|
||||
</span>{' '}
|
||||
was {formatRelativeAge(context.latest_block_timestamp)}. Latest visible transaction was{' '}
|
||||
was <ClientRelativeTime value={context.latest_block_timestamp} suffix=" ago" />. Latest visible transaction was{' '}
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'}
|
||||
</span>{' '}
|
||||
{formatRelativeAge(context.latest_transaction_timestamp)}.{' '}
|
||||
<ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />.{' '}
|
||||
{mode === 'guided'
|
||||
? 'Use the block gap and last non-empty block above to tell low activity apart from an indexing issue.'
|
||||
: dualTimelineLabel}
|
||||
@@ -104,11 +110,11 @@ export default function ActivityContextPanel({
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'}
|
||||
</span>{' '}
|
||||
was {formatRelativeAge(context.latest_block_timestamp)}. Latest visible transaction was{' '}
|
||||
was <ClientRelativeTime value={context.latest_block_timestamp} suffix=" ago" />. Latest visible transaction was{' '}
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'}
|
||||
</span>{' '}
|
||||
{formatRelativeAge(context.latest_transaction_timestamp)}.
|
||||
<ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />.
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
@@ -135,7 +141,7 @@ export default function ActivityContextPanel({
|
||||
) : null}
|
||||
{context.block_gap_to_latest_transaction != null ? (
|
||||
<span>
|
||||
Block gap to latest visible transaction: {context.block_gap_to_latest_transaction.toLocaleString()}
|
||||
Block gap to latest visible transaction: {formatInteger(context.block_gap_to_latest_transaction)}
|
||||
</span>
|
||||
) : null}
|
||||
{context.latest_transaction_timestamp ? (
|
||||
|
||||
57
frontend/src/components/common/ClientRelativeTime.tsx
Normal file
57
frontend/src/components/common/ClientRelativeTime.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatRelativeAgeAt } from '@/utils/format'
|
||||
|
||||
const PLACEHOLDER = '…'
|
||||
|
||||
export default function ClientRelativeTime({
|
||||
value,
|
||||
prefix = '',
|
||||
suffix = '',
|
||||
fallback = 'Unknown',
|
||||
className,
|
||||
}: {
|
||||
value?: string | null
|
||||
prefix?: string
|
||||
suffix?: string
|
||||
fallback?: string
|
||||
className?: string
|
||||
}) {
|
||||
const [label, setLabel] = useState(PLACEHOLDER)
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setLabel(fallback)
|
||||
return
|
||||
}
|
||||
const parsed = Date.parse(value)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setLabel(fallback)
|
||||
return
|
||||
}
|
||||
const update = () => setLabel(formatRelativeAgeAt(Date.now(), value))
|
||||
update()
|
||||
const id = window.setInterval(update, 30_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [value, fallback])
|
||||
|
||||
const content = label === PLACEHOLDER ? PLACEHOLDER : label
|
||||
if (className) {
|
||||
return (
|
||||
<span className={className} suppressHydrationWarning>
|
||||
{prefix}
|
||||
{content}
|
||||
{suffix}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span suppressHydrationWarning>
|
||||
{prefix}
|
||||
{content}
|
||||
{suffix}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
29
frontend/src/components/common/CompactMetricBar.tsx
Normal file
29
frontend/src/components/common/CompactMetricBar.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
export type CompactMetric = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export default function CompactMetricBar({
|
||||
metrics,
|
||||
className = 'mb-4',
|
||||
}: {
|
||||
metrics: CompactMetric[]
|
||||
className?: string
|
||||
}) {
|
||||
if (metrics.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300 ${className}`}
|
||||
>
|
||||
{metrics.map((metric) => (
|
||||
<span key={metric.label}>
|
||||
<span className="text-gray-500 dark:text-gray-400">{metric.label}:</span>{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">{metric.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
frontend/src/components/common/DisclosureSection.tsx
Normal file
70
frontend/src/components/common/DisclosureSection.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
|
||||
interface DisclosureSectionProps {
|
||||
title: string
|
||||
children: ReactNode
|
||||
defaultOpen?: boolean
|
||||
className?: string
|
||||
headingClassName?: string
|
||||
/** When true, content is always visible and the toggle is hidden (desktop layout). */
|
||||
forceOpen?: boolean
|
||||
/** When true, keep accordion behavior on all breakpoints (no md:auto-expand). */
|
||||
alwaysCollapsible?: boolean
|
||||
}
|
||||
|
||||
export default function DisclosureSection({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
className = '',
|
||||
headingClassName = '',
|
||||
forceOpen = false,
|
||||
alwaysCollapsible = false,
|
||||
}: DisclosureSectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen || forceOpen)
|
||||
const panelId = useId().replace(/:/g, '')
|
||||
const isOpen = forceOpen || open
|
||||
|
||||
return (
|
||||
<section className={className}>
|
||||
{forceOpen ? (
|
||||
<div className={`mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 ${headingClassName}`}>
|
||||
{title}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50/80 px-3 py-2.5 text-left text-sm font-semibold uppercase tracking-wide text-gray-700 transition hover:border-primary-300 dark:border-gray-800 dark:bg-gray-900/40 dark:text-gray-200 dark:hover:border-primary-700 ${alwaysCollapsible ? '' : 'md:hidden'} ${headingClassName}`}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<span aria-hidden="true" className="text-base text-gray-500 dark:text-gray-400">
|
||||
{isOpen ? '−' : '+'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
id={panelId}
|
||||
className={
|
||||
forceOpen
|
||||
? ''
|
||||
: alwaysCollapsible
|
||||
? `${isOpen ? 'block' : 'hidden'} mt-3`
|
||||
: `${isOpen ? 'block' : 'hidden'} mt-3 md:mt-0 md:block`
|
||||
}
|
||||
>
|
||||
{!forceOpen && !alwaysCollapsible ? (
|
||||
<div className="mb-3 hidden text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 md:block">
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
import type { ChainActivityContext } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
|
||||
export default function ExplorerFreshnessDisclosure({
|
||||
context,
|
||||
stats,
|
||||
bridgeStatus,
|
||||
scopeLabel,
|
||||
activityTitle = 'Recency',
|
||||
title = 'Index freshness',
|
||||
className = 'mb-4',
|
||||
children,
|
||||
}: {
|
||||
context: ChainActivityContext
|
||||
stats?: ExplorerStats | null
|
||||
bridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||
scopeLabel: string
|
||||
activityTitle?: string
|
||||
title?: string
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<DisclosureSection title={title} defaultOpen={false} alwaysCollapsible className={className}>
|
||||
<ActivityContextPanel compact context={context} title={activityTitle} />
|
||||
<FreshnessTrustNote
|
||||
className="mt-2"
|
||||
context={context}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel={scopeLabel}
|
||||
/>
|
||||
{children}
|
||||
</DisclosureSection>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import FooterLinkGroups from '@/components/common/FooterLinkGroups'
|
||||
import FooterPublicApiLinks from '@/components/common/FooterPublicApiLinks'
|
||||
|
||||
const footerLinkClass =
|
||||
@@ -8,96 +10,53 @@ export default function Footer() {
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
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-6 sm:py-8">
|
||||
<div className="grid gap-4 sm:gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
<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">
|
||||
DBIS Explorer
|
||||
</div>
|
||||
<p className="max-w-xl text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Built on Blockscout for the DBIS Chain 138 explorer surface.
|
||||
Explorer data is powered by Blockscout, Chain 138 RPC, and the companion MetaMask Snap.
|
||||
</p>
|
||||
<p className="max-w-xl text-xs leading-5 text-gray-500 dark:text-gray-500">
|
||||
Primary public explorer access is served at <code>explorer.d-bis.org</code>.
|
||||
<code> blockscout.defi-oracle.io</code> is the Blockscout companion domain for the same Chain 138 explorer surface.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
© {year} DBIS. All rights reserved.
|
||||
<footer className="mt-auto border-t border-gray-200 bg-white/90 backdrop-blur dark:border-gray-700 dark:bg-gray-900/90">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 shrink-0 lg:max-w-xs">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">DBIS Explorer</div>
|
||||
<p className="mt-1 text-xs leading-5 text-gray-600 dark:text-gray-400">
|
||||
Chain 138 explorer on Blockscout. Primary domain: <code>explorer.d-bis.org</code>.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-500">© {year} DBIS</p>
|
||||
</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><Link className={footerLinkClass} href="/search">Search</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/docs">Documentation</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/blocks">Blocks</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/transactions">Transactions</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/tokens">Tokens</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/addresses">Addresses</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/watchlist">Watchlist</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/access">Account access</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/wallet">Wallet tools</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/operations">Operations hub</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/bridge">Bridge</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/liquidity">Liquidity</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/pools">Pools</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/protocols">Protocols</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/analytics">Analytics</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/operator">Operator</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/system">System</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/weth">WETH</Link></li>
|
||||
<li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li>
|
||||
<li><a className={footerLinkClass} href="/terms.html">Terms of Service</a></li>
|
||||
<li><a className={footerLinkClass} href="/acknowledgments.html">Acknowledgments</a></li>
|
||||
</ul>
|
||||
<div className="hidden min-w-0 flex-1 lg:block">
|
||||
<FooterLinkGroups />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisclosureSection
|
||||
title="Site navigation"
|
||||
alwaysCollapsible
|
||||
className="mt-3 lg:hidden"
|
||||
>
|
||||
<FooterLinkGroups />
|
||||
</DisclosureSection>
|
||||
|
||||
<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">
|
||||
Public APIs
|
||||
</div>
|
||||
<FooterPublicApiLinks />
|
||||
<p className="mt-3 text-xs leading-5 text-gray-500 dark:text-gray-500">
|
||||
Read-only JSON endpoints on the public explorer domain. No API key required.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<DisclosureSection title="Public APIs" alwaysCollapsible className="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<FooterPublicApiLinks compact />
|
||||
</DisclosureSection>
|
||||
|
||||
<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>
|
||||
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<DisclosureSection title="Contact" alwaysCollapsible className="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<div className="space-y-1.5 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
Support:{' '}
|
||||
<a className={footerLinkClass} href="mailto:support@d-bis.org">
|
||||
support@d-bis.org
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Snap site:{' '}
|
||||
<Link className={footerLinkClass} href="/topology">
|
||||
Chain 138 map
|
||||
</Link>
|
||||
{' · '}
|
||||
<a className={footerLinkClass} href="/snap/" target="_blank" rel="noopener noreferrer">
|
||||
/snap/ on the current explorer domain
|
||||
Snap site
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Command center:{' '}
|
||||
<Link className={footerLinkClass} href="/topology">
|
||||
Chain 138 visual map
|
||||
</Link>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DisclosureSection>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
46
frontend/src/components/common/FooterLinkGroups.tsx
Normal file
46
frontend/src/components/common/FooterLinkGroups.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { footerNavGroups, type FooterNavLink } from '@/data/footerNav'
|
||||
|
||||
const footerLinkClass =
|
||||
'text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors'
|
||||
|
||||
function FooterNavLinkItem({ link }: { link: FooterNavLink }) {
|
||||
if (link.external) {
|
||||
return (
|
||||
<li>
|
||||
<a className={footerLinkClass} href={link.href}>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link className={footerLinkClass} href={link.href}>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FooterLinkGroups() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-4">
|
||||
{footerNavGroups.map((group) => (
|
||||
<div key={group.id}>
|
||||
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{group.title}
|
||||
</div>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{group.links.map((link) => (
|
||||
<FooterNavLinkItem key={`${group.id}-${link.href}`} link={link} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ function absoluteApiUrl(href: string): string {
|
||||
return `${window.location.origin}${href.startsWith('/') ? href : `/${href}`}`
|
||||
}
|
||||
|
||||
export default function FooterPublicApiLinks() {
|
||||
export default function FooterPublicApiLinks({ compact = false }: { compact?: boolean }) {
|
||||
const [copiedHref, setCopiedHref] = useState<string | null>(null)
|
||||
|
||||
const copyUrl = async (href: string) => {
|
||||
@@ -27,15 +27,23 @@ export default function FooterPublicApiLinks() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-3 text-sm">
|
||||
<ul className={compact ? 'space-y-1.5 text-sm' : 'space-y-3 text-sm'}>
|
||||
{explorerPublicApiLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<a className={footerLinkClass} href={link.href} target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
className={footerLinkClass}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={compact ? link.description : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
<p className="mt-0.5 text-xs leading-5 text-gray-500 dark:text-gray-500">{link.description}</p>
|
||||
{!compact ? (
|
||||
<p className="mt-0.5 text-xs leading-5 text-gray-500 dark:text-gray-500">{link.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
68
frontend/src/components/common/FreshnessDetail.tsx
Normal file
68
frontend/src/components/common/FreshnessDetail.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ChainActivityContext } from '@/utils/activityContext'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import { formatInteger } from '@/utils/format'
|
||||
|
||||
export default function FreshnessDetail({
|
||||
context,
|
||||
diagnosticExplanation,
|
||||
activityState,
|
||||
}: {
|
||||
context: ChainActivityContext
|
||||
diagnosticExplanation?: string | null
|
||||
activityState?: string | null
|
||||
}) {
|
||||
if (diagnosticExplanation) {
|
||||
return <>{diagnosticExplanation}</>
|
||||
}
|
||||
|
||||
if (activityState === 'quiet_chain') {
|
||||
const latestNonEmptyBlock =
|
||||
context.last_non_empty_block_number != null ? `#${formatInteger(context.last_non_empty_block_number)}` : 'unknown'
|
||||
const blockGap =
|
||||
context.block_gap_to_latest_transaction != null
|
||||
? `${formatInteger(context.block_gap_to_latest_transaction)} blocks`
|
||||
: 'unknown'
|
||||
return (
|
||||
<>
|
||||
Quiet-chain signal: head blocks may be empty while the chain remains current. Block gap to latest visible
|
||||
transaction: {blockGap}. Last non-empty block: {latestNonEmptyBlock}.
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (context.transaction_visibility_unavailable) {
|
||||
return <>Use chain-head visibility and the last non-empty block as the current trust anchors.</>
|
||||
}
|
||||
|
||||
const latestNonEmptyBlock =
|
||||
context.last_non_empty_block_number != null ? `#${formatInteger(context.last_non_empty_block_number)}` : 'unknown'
|
||||
const blockGap =
|
||||
context.block_gap_to_latest_transaction != null
|
||||
? `${formatInteger(context.block_gap_to_latest_transaction)} blocks`
|
||||
: null
|
||||
|
||||
if (context.head_is_idle) {
|
||||
return (
|
||||
<>
|
||||
Latest visible transaction: <ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />. Block
|
||||
gap: {blockGap || 'unknown'}. Last non-empty block: {latestNonEmptyBlock}.
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (context.state === 'active') {
|
||||
return (
|
||||
<>
|
||||
Latest visible transaction: <ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />.
|
||||
Recent indexed activity remains close to the tip.
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Latest visible transaction: <ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />. Recent
|
||||
head blocks may be quiet even while the chain remains current.
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
resolveFreshnessSourceLabel,
|
||||
summarizeFreshnessConfidence,
|
||||
} from '@/utils/explorerFreshness'
|
||||
import { formatRelativeAge } from '@/utils/format'
|
||||
import FreshnessDetail from './FreshnessDetail'
|
||||
import { useUiMode } from './UiModeContext'
|
||||
|
||||
function buildSummary(context: ChainActivityContext, activityState?: string | null) {
|
||||
@@ -32,44 +32,6 @@ function buildSummary(context: ChainActivityContext, activityState?: string | nu
|
||||
return 'Freshness context is based on the latest visible public explorer evidence.'
|
||||
}
|
||||
|
||||
function buildDetail(context: ChainActivityContext, diagnosticExplanation?: string | null, activityState?: string | null) {
|
||||
if (diagnosticExplanation) {
|
||||
return diagnosticExplanation
|
||||
}
|
||||
|
||||
if (activityState === 'quiet_chain') {
|
||||
const latestNonEmptyBlock =
|
||||
context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown'
|
||||
const blockGap =
|
||||
context.block_gap_to_latest_transaction != null
|
||||
? `${context.block_gap_to_latest_transaction.toLocaleString()} blocks`
|
||||
: 'unknown'
|
||||
return `Quiet-chain signal: head blocks may be empty while the chain remains current. Block gap to latest visible transaction: ${blockGap}. Last non-empty block: ${latestNonEmptyBlock}.`
|
||||
}
|
||||
|
||||
if (context.transaction_visibility_unavailable) {
|
||||
return 'Use chain-head visibility and the last non-empty block as the current trust anchors.'
|
||||
}
|
||||
|
||||
const latestTxAge = formatRelativeAge(context.latest_transaction_timestamp)
|
||||
const latestNonEmptyBlock =
|
||||
context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown'
|
||||
const blockGap =
|
||||
context.block_gap_to_latest_transaction != null
|
||||
? `${context.block_gap_to_latest_transaction.toLocaleString()} blocks`
|
||||
: null
|
||||
|
||||
if (context.head_is_idle) {
|
||||
return `Latest visible transaction: ${latestTxAge}. Block gap: ${blockGap || 'unknown'}. Last non-empty block: ${latestNonEmptyBlock}.`
|
||||
}
|
||||
|
||||
if (context.state === 'active') {
|
||||
return `Latest visible transaction: ${latestTxAge}. Recent indexed activity remains close to the tip.`
|
||||
}
|
||||
|
||||
return `Latest visible transaction: ${latestTxAge}. Recent head blocks may be quiet even while the chain remains current.`
|
||||
}
|
||||
|
||||
function normalizeSentence(value?: string | null): string {
|
||||
if (!value) return ''
|
||||
return value.trim().replace(/[.\s]+$/, '')
|
||||
@@ -120,8 +82,12 @@ export default function FreshnessTrustNote({
|
||||
<div className={`rounded-2xl border border-gray-200 bg-white/80 px-4 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/40${normalizedClassName}`}>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context, activityState)}</div>
|
||||
<div className="mt-1 text-gray-600 dark:text-gray-400">
|
||||
{normalizeSentence(buildDetail(context, diagnosticExplanation, activityState))}.{' '}
|
||||
{scopeLabel ? `${normalizeSentence(scopeLabel)}. ` : ''}
|
||||
<FreshnessDetail
|
||||
context={context}
|
||||
diagnosticExplanation={diagnosticExplanation}
|
||||
activityState={activityState}
|
||||
/>
|
||||
. {scopeLabel ? `${normalizeSentence(scopeLabel)}. ` : ''}
|
||||
{normalizeSentence(sourceLabel)}.
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
|
||||
45
frontend/src/components/common/ListFilterBar.tsx
Normal file
45
frontend/src/components/common/ListFilterBar.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { formatInteger } from '@/utils/format'
|
||||
|
||||
interface ListFilterBarProps {
|
||||
query: string
|
||||
onQueryChange: (value: string) => void
|
||||
placeholder?: string
|
||||
resultCount?: number
|
||||
totalCount?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function ListFilterBar({
|
||||
query,
|
||||
onQueryChange,
|
||||
placeholder = 'Filter…',
|
||||
resultCount,
|
||||
totalCount,
|
||||
className = '',
|
||||
}: ListFilterBarProps) {
|
||||
const showCounts = typeof resultCount === 'number' && typeof totalCount === 'number'
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between ${className}`}>
|
||||
<label className="block min-w-0 flex-1">
|
||||
<span className="sr-only">{placeholder}</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</label>
|
||||
{showCounts ? (
|
||||
<div className="shrink-0 text-sm text-gray-600 dark:text-gray-400">
|
||||
{resultCount === totalCount
|
||||
? `${formatInteger(totalCount)} items`
|
||||
: `${formatInteger(resultCount)} of ${formatInteger(totalCount)}`}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatRelativeAge, formatTimestamp } from '@/utils/format'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import { formatTimestamp } from '@/utils/format'
|
||||
|
||||
function formatSource(source?: string | null): string {
|
||||
switch (source) {
|
||||
@@ -26,14 +27,33 @@ export default function MarketEvidenceNote({
|
||||
method?: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
const freshness = lastUpdated ? `${formatRelativeAge(lastUpdated)} (${formatTimestamp(lastUpdated)})` : 'timestamp unavailable'
|
||||
const text = compact
|
||||
? `Updated ${freshness} · ${formatSource(source)}`
|
||||
: `Source: ${formatSource(source)}. Updated: ${freshness}. Method: ${method}`
|
||||
|
||||
return (
|
||||
<p className={`${compact ? 'mt-1' : 'mt-3'} text-xs leading-5 text-gray-500 dark:text-gray-400`}>
|
||||
{text}
|
||||
{compact ? (
|
||||
<>
|
||||
Updated{' '}
|
||||
{lastUpdated ? (
|
||||
<>
|
||||
<ClientRelativeTime value={lastUpdated} suffix={` (${formatTimestamp(lastUpdated)})`} />
|
||||
</>
|
||||
) : (
|
||||
'timestamp unavailable'
|
||||
)}{' '}
|
||||
· {formatSource(source)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Source: {formatSource(source)}. Updated:{' '}
|
||||
{lastUpdated ? (
|
||||
<>
|
||||
<ClientRelativeTime value={lastUpdated} suffix={` (${formatTimestamp(lastUpdated)})`} />
|
||||
</>
|
||||
) : (
|
||||
'timestamp unavailable'
|
||||
)}
|
||||
. Method: {method}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import type { MissionControlMode } from '@/services/api/missionControl'
|
||||
import { formatRelativeAge } from '@/utils/format'
|
||||
|
||||
const REASON_LABELS: Record<string, string> = {
|
||||
live_homepage_stream_not_attached: 'Live homepage stream is not attached; relay posture uses snapshot polling.',
|
||||
@@ -57,7 +57,9 @@ export default function MissionDeliveryModePanel({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={`mode ${mode.kind}`} tone={modeTone(mode.kind)} />
|
||||
{mode.updated_at ? (
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Updated {formatRelativeAge(mode.updated_at)}</span>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Updated <ClientRelativeTime value={mode.updated_at} suffix=" ago" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{mode.reason ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { accessApi, institutionalTierLabels, type WalletAccessSession } from '@/
|
||||
import BrandLockup from './BrandLockup'
|
||||
import HeaderCommandPalette, { type HeaderCommandItem } from './HeaderCommandPalette'
|
||||
import { useUiMode } from './UiModeContext'
|
||||
import { formatTimestamp } from '@/utils/format'
|
||||
|
||||
type MenuItem = {
|
||||
href?: string
|
||||
@@ -430,7 +431,7 @@ function AccountButton({
|
||||
const sessionSummary = getSessionSummary(walletSession)
|
||||
const tierLabel = getAccessTier(walletSession)
|
||||
const expiresLabel = walletSession.expiresAt
|
||||
? new Date(walletSession.expiresAt).toLocaleString()
|
||||
? formatTimestamp(walletSession.expiresAt)
|
||||
: 'Session expiry unavailable'
|
||||
|
||||
return (
|
||||
@@ -488,6 +489,7 @@ export default function Navbar() {
|
||||
pathname.startsWith('/addresses')
|
||||
const isDataActive =
|
||||
pathname.startsWith('/tokens') ||
|
||||
pathname.startsWith('/wallet') ||
|
||||
pathname.startsWith('/analytics') ||
|
||||
pathname.startsWith('/pools') ||
|
||||
pathname.startsWith('/protocols') ||
|
||||
@@ -592,6 +594,7 @@ export default function Navbar() {
|
||||
const dataItems: MenuItem[] = useMemo(
|
||||
() => [
|
||||
{ href: '/tokens', label: 'Tokens', description: 'Review curated assets, standards, and token detail pages.' },
|
||||
{ href: '/wallet', label: 'Wallet tools', description: 'Add Chain 138 to MetaMask and import tokens via EIP-747.' },
|
||||
{ href: '/analytics', label: 'Analytics', description: 'Open explorer-visible transaction and block activity summaries.' },
|
||||
{ href: '/pools', label: 'Pools', description: 'Browse mission-control pool inventory and route-backed liquidity context.' },
|
||||
{ href: '/protocols', label: 'Protocols', description: 'Review official upstream protocol contracts and production guardrails on Chain 138.' },
|
||||
|
||||
@@ -10,39 +10,53 @@ export default function PageIntro({
|
||||
title,
|
||||
description,
|
||||
actions = [],
|
||||
compact = false,
|
||||
}: {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
description: string
|
||||
actions?: PageIntroAction[]
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<section className="mb-5 border-b border-gray-200 pb-5 dark:border-gray-800 sm:mb-6 sm:pb-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<section
|
||||
className={
|
||||
compact
|
||||
? 'mb-4 border-b border-gray-200 pb-3 dark:border-gray-800'
|
||||
: 'mb-5 border-b border-gray-200 pb-5 dark:border-gray-800 sm:mb-6 sm:pb-6'
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
{eyebrow ? (
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary-700 dark:text-primary-300">
|
||||
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary-700 dark:text-primary-300">
|
||||
{eyebrow}
|
||||
</div>
|
||||
) : null}
|
||||
<h1 className="text-2xl font-semibold tracking-normal text-gray-950 dark:text-white sm:text-3xl">{title}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
<h1 className={`font-semibold tracking-normal text-gray-950 dark:text-white ${compact ? 'text-xl sm:text-2xl' : 'text-2xl sm:text-3xl'}`}>
|
||||
{title}
|
||||
</h1>
|
||||
<p
|
||||
className={`mt-1.5 max-w-3xl text-sm text-gray-600 dark:text-gray-400 ${
|
||||
compact ? 'line-clamp-2 leading-5' : 'leading-6'
|
||||
}`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
{actions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
{actions.map((action) => (
|
||||
<Link
|
||||
key={`${action.href}-${action.label}`}
|
||||
href={action.href}
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:text-primary-300"
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{actions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
{actions.map((action) => (
|
||||
<Link
|
||||
key={`${action.href}-${action.label}`}
|
||||
href={action.href}
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:text-primary-300"
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useId } from 'react'
|
||||
import { useCallback, useId, type ReactNode } from 'react'
|
||||
import { formatInteger } from '@/utils/format'
|
||||
|
||||
export interface SectionTab<T extends string> {
|
||||
id: T
|
||||
@@ -28,6 +29,33 @@ export function sectionTabPanelProps<T extends string>(
|
||||
}
|
||||
}
|
||||
|
||||
interface TabPanelProps<T extends string> {
|
||||
idPrefix: string
|
||||
tabId: T
|
||||
activeTab: T
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Renders tab content only while active to keep pages short and avoid hidden-panel scroll. */
|
||||
export function TabPanel<T extends string>({
|
||||
idPrefix,
|
||||
tabId,
|
||||
activeTab,
|
||||
children,
|
||||
className = '',
|
||||
}: TabPanelProps<T>) {
|
||||
if (activeTab !== tabId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...sectionTabPanelProps(idPrefix, tabId, activeTab)} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SectionTabs<T extends string>({
|
||||
tabs,
|
||||
activeTab,
|
||||
@@ -78,7 +106,7 @@ export default function SectionTabs<T extends string>({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}
|
||||
className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-2 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}
|
||||
>
|
||||
<div
|
||||
role="tablist"
|
||||
@@ -88,6 +116,10 @@ export default function SectionTabs<T extends string>({
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
const accessibleLabel =
|
||||
typeof tab.count === 'number'
|
||||
? `${tab.label} (${formatInteger(tab.count)})`
|
||||
: tab.label
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -97,6 +129,7 @@ export default function SectionTabs<T extends string>({
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`${tabListPrefix}-panel-${tab.id}`}
|
||||
aria-label={accessibleLabel}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={
|
||||
@@ -107,8 +140,11 @@ export default function SectionTabs<T extends string>({
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.count === 'number' ? (
|
||||
<span className={isActive ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
|
||||
{tab.count.toLocaleString()}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={isActive ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}
|
||||
>
|
||||
{formatInteger(tab.count)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { useUiMode } from './UiModeContext'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import type { MissionControlSubsystemStatus } from '@/services/api/missionControl'
|
||||
import { formatRelativeAge } from '@/utils/format'
|
||||
|
||||
function subsystemLabel(key: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
@@ -107,7 +107,13 @@ export default function SubsystemPosturePanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{subsystem.updated_at ? `Updated ${formatRelativeAge(subsystem.updated_at)}` : 'Update time unavailable'}
|
||||
{subsystem.updated_at ? (
|
||||
<>
|
||||
Updated <ClientRelativeTime value={subsystem.updated_at} suffix=" ago" />
|
||||
</>
|
||||
) : (
|
||||
'Update time unavailable'
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{compactMeta(subsystem)}
|
||||
|
||||
@@ -17,16 +17,18 @@ import {
|
||||
import { transactionsApi, type Transaction } from '@/services/api/transactions'
|
||||
import { formatWeiAsEth } from '@/utils/format'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure'
|
||||
import CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import SectionTabs, { TabPanel } from '@/components/common/SectionTabs'
|
||||
import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import OperationsPageShell, {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
@@ -61,6 +63,7 @@ export default function AnalyticsOperationsPage({
|
||||
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const [analyticsTab, setAnalyticsTab] = useState<'trend' | 'blocks' | 'transactions'>('trend')
|
||||
const page = explorerFeaturePages.analytics
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,58 +151,36 @@ export default function AnalyticsOperationsPage({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Analytics Freshness Context" />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel="This page combines public stats, recent block samples, and indexed transactions."
|
||||
/>
|
||||
<ExplorerFreshnessDisclosure
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
title="Freshness & delivery"
|
||||
activityTitle="Analytics recency"
|
||||
scopeLabel="Stats, blocks, and indexed transactions on this page."
|
||||
>
|
||||
<MissionDeliveryModePanel className="mt-3" mode={bridgeStatus?.data?.mode} title="Analytics delivery mode" />
|
||||
<SubsystemPosturePanel
|
||||
className="mt-3"
|
||||
subsystems={bridgeStatus?.data?.subsystems}
|
||||
title="Analytics Subsystem Posture"
|
||||
title="Analytics subsystem posture"
|
||||
preferredKeys={['rpc_head', 'tx_index', 'stats_summary', 'freshness_queries']}
|
||||
scopeLabel="These subsystem signals explain whether sparse analytics reflect quiet-chain conditions, partial transaction indexing, or stale summary generation."
|
||||
scopeLabel="Subsystem signals for sparse analytics."
|
||||
/>
|
||||
</div>
|
||||
</ExplorerFreshnessDisclosure>
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Total Blocks"
|
||||
value={formatNumber(stats?.total_blocks)}
|
||||
description="Current block count from the public Blockscout stats endpoint."
|
||||
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Transactions"
|
||||
value={formatNumber(stats?.total_transactions)}
|
||||
description="Total transactions currently indexed by the public explorer."
|
||||
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Addresses"
|
||||
value={formatNumber(stats?.total_addresses)}
|
||||
description="Known addresses from the public stats surface."
|
||||
/>
|
||||
<MetricCard
|
||||
title="Chain Head"
|
||||
value={chainStatus?.head_age_sec != null ? `${Math.round(chainStatus.head_age_sec)}s` : 'Unknown'}
|
||||
description={
|
||||
chainStatus?.latency_ms != null
|
||||
? `RPC latency ${Math.round(chainStatus.latency_ms)}ms on Chain 138.`
|
||||
: 'Latest public RPC head age from mission control.'
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
title="7d Avg Tx"
|
||||
value={formatNumber(Math.round(sevenDayAverage))}
|
||||
description="Average daily transactions over the latest seven charted days."
|
||||
className="border border-violet-200 bg-violet-50/70 dark:border-violet-900/50 dark:bg-violet-950/20"
|
||||
/>
|
||||
<CompactMetricBar
|
||||
metrics={[
|
||||
{ label: 'Blocks', value: formatNumber(stats?.total_blocks) },
|
||||
{ label: 'Tx', value: formatNumber(stats?.total_transactions) },
|
||||
{ label: 'Addresses', value: formatNumber(stats?.total_addresses) },
|
||||
{ label: 'Head', value: chainStatus?.head_age_sec != null ? `${Math.round(chainStatus.head_age_sec)}s` : '—' },
|
||||
{ label: '7d avg', value: formatNumber(Math.round(sevenDayAverage)) },
|
||||
]}
|
||||
/>
|
||||
|
||||
<DisclosureSection title="Extended metrics" defaultOpen={false} alwaysCollapsible className="mb-6">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<MetricCard
|
||||
title="Recent Success Rate"
|
||||
value={activitySnapshot ? `${Math.round(activitySnapshot.success_rate * 100)}%` : 'Unknown'}
|
||||
@@ -222,62 +203,66 @@ export default function AnalyticsOperationsPage({
|
||||
description="Average gas utilization across the latest visible blocks."
|
||||
className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DisclosureSection>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<SectionTabs
|
||||
idPrefix="analytics"
|
||||
ariaLabel="Analytics views"
|
||||
tabs={[
|
||||
{ id: 'trend', label: 'Trend' },
|
||||
{ id: 'blocks', label: 'Blocks', count: blocks.length },
|
||||
{ id: 'transactions', label: 'Transactions', count: transactions.length },
|
||||
]}
|
||||
activeTab={analyticsTab}
|
||||
onChange={setAnalyticsTab}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<TabPanel idPrefix="analytics" tabId="trend" activeTab={analyticsTab}>
|
||||
<Card title="Activity Trend">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Peak Day</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{topDay ? formatNumber(topDay.transaction_count) : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{topDay?.date || 'No trend data yet'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Contract Creations</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{formatNumber(activitySnapshot?.contract_creations)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Within the sampled recent transaction feed.</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Avg Sample Fee</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{activitySnapshot ? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6) : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Average fee from the recent public transaction sample.</div>
|
||||
</div>
|
||||
</div>
|
||||
<CompactMetricBar
|
||||
metrics={[
|
||||
{ label: 'Peak day', value: topDay ? formatNumber(topDay.transaction_count) : '—' },
|
||||
{ label: 'Creations', value: formatNumber(activitySnapshot?.contract_creations) },
|
||||
{
|
||||
label: 'Avg fee',
|
||||
value: activitySnapshot
|
||||
? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6)
|
||||
: '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<DisclosureSection title="Sample breakdown" defaultOpen={false} alwaysCollapsible>
|
||||
{activitySnapshot ? (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token Transfer Share</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token transfers</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{Math.round(activitySnapshot.token_transfer_share * 100)}%
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions involving token transfers.</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Contract Call Share</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Contract calls</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{Math.round(activitySnapshot.contract_call_share * 100)}%
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions calling contracts.</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Creation Share</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Creations</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{Math.round(activitySnapshot.contract_creation_share * 100)}%
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions deploying contracts.</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No sample breakdown available.</p>
|
||||
)}
|
||||
</DisclosureSection>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{trailingWindow.map((point) => {
|
||||
const width = trendPeak > 0 ? Math.max(8, Math.round((point.transaction_count / trendPeak) * 100)) : 0
|
||||
return (
|
||||
@@ -298,9 +283,11 @@ export default function AnalyticsOperationsPage({
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="analytics" tabId="blocks" activeTab={analyticsTab}>
|
||||
<Card title="Recent Blocks">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{shouldExplainEmptyHeadBlocks(blocks, activityContext) ? (
|
||||
<p className="rounded-xl border border-amber-200 bg-amber-50/70 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
|
||||
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
|
||||
@@ -309,22 +296,22 @@ export default function AnalyticsOperationsPage({
|
||||
{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"
|
||||
className="rounded-xl border border-gray-200 bg-gray-50 p-3 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 className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<Link href={`/blocks/${block.number}`} className="text-base font-semibold text-primary-600 hover:underline">
|
||||
<Link href={`/blocks/${block.number}`} className="text-sm font-semibold text-primary-600 hover:underline">
|
||||
Block {formatNumber(block.number)}
|
||||
</Link>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{truncateMiddle(block.hash)} · miner{' '}
|
||||
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{truncateMiddle(block.hash)} ·{' '}
|
||||
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
|
||||
{truncateMiddle(block.miner)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatNumber(block.transaction_count)} tx · {Math.round((block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0) * 100)}% gas · {relativeAge(block.timestamp)}
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{formatNumber(block.transaction_count)} tx · {Math.round((block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0) * 100)}% gas · <ClientRelativeTime value={block.timestamp} suffix=" ago" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,20 +321,22 @@ export default function AnalyticsOperationsPage({
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="analytics" tabId="transactions" activeTab={analyticsTab}>
|
||||
<Card title="Recent Transactions">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{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"
|
||||
className="rounded-xl border border-gray-200 bg-gray-50 p-3 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 className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<Link href={`/transactions/${transaction.hash}`} className="text-base font-semibold text-primary-600 hover:underline">
|
||||
<Link href={`/transactions/${transaction.hash}`} className="text-sm font-semibold text-primary-600 hover:underline">
|
||||
{truncateMiddle(transaction.hash, 12, 10)}
|
||||
</Link>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
Block{' '}
|
||||
<Link href={`/blocks/${transaction.block_number}`} className="text-primary-600 hover:underline">
|
||||
{formatNumber(transaction.block_number)}
|
||||
@@ -366,23 +355,25 @@ export default function AnalyticsOperationsPage({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<ClientRelativeTime value={transaction.created_at} suffix=" ago" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
{transaction.method ? <StatusBadge status={transaction.method} tone="warning" /> : null}
|
||||
{transaction.contract_address ? <StatusBadge status="contract creation" tone="warning" /> : null}
|
||||
{transaction.token_transfers && transaction.token_transfers.length > 0 ? (
|
||||
<StatusBadge status={`${transaction.token_transfers.length} token transfer${transaction.token_transfers.length === 1 ? '' : 's'}`} />
|
||||
) : null}
|
||||
</div>
|
||||
{(transaction.method || transaction.contract_address || (transaction.token_transfers && transaction.token_transfers.length > 0)) ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-xs">
|
||||
{transaction.method ? <StatusBadge status={transaction.method} tone="warning" /> : null}
|
||||
{transaction.contract_address ? <StatusBadge status="contract creation" tone="warning" /> : null}
|
||||
{transaction.token_transfers && transaction.token_transfers.length > 0 ? (
|
||||
<StatusBadge status={`${transaction.token_transfers.length} token transfer${transaction.token_transfers.length === 1 ? '' : 's'}`} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{transactions.length === 0 ? (
|
||||
@@ -390,7 +381,7 @@ export default function AnalyticsOperationsPage({
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</OperationsPageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure'
|
||||
import CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
@@ -23,6 +25,7 @@ import { HOME_DASHBOARD_REFRESH_MS } from '@/utils/featuredTokens'
|
||||
import BridgeLaneHealthPanel from '@/components/explorer/BridgeLaneHealthPanel'
|
||||
import OperationsSurfaceNav from './OperationsSurfaceNav'
|
||||
import OperationsActionGrid from './OperationsActionGrid'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
|
||||
type FeedState = 'connecting' | 'live' | 'fallback'
|
||||
|
||||
@@ -36,7 +39,7 @@ interface RelayLaneCard {
|
||||
queueSize: number
|
||||
processed: number
|
||||
failed: number
|
||||
lastPolled: string
|
||||
lastPolledAt?: string | null
|
||||
bridgeAddress: string
|
||||
issueScope: string | null
|
||||
issueMessage: string | null
|
||||
@@ -46,18 +49,7 @@ interface RelayLaneCard {
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
const BRIDGE_RELAY_PAGE_SIZE = 4
|
||||
|
||||
function shortAddress(value?: string): string {
|
||||
if (!value) return 'Unspecified'
|
||||
@@ -155,6 +147,7 @@ export default function BridgeMonitoringPage({
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||
const [bridgeRoutes, setBridgeRoutes] = useState<BridgeRoutesResponse | null>(null)
|
||||
const [feedState, setFeedState] = useState<FeedState>(initialBridgeStatus ? 'fallback' : 'connecting')
|
||||
const [relayPage, setRelayPage] = useState(1)
|
||||
const page = explorerFeaturePages.bridge
|
||||
|
||||
useEffect(() => {
|
||||
@@ -285,7 +278,7 @@ export default function BridgeMonitoringPage({
|
||||
queueSize: snapshot?.queue?.size ?? 0,
|
||||
processed: snapshot?.queue?.processed ?? 0,
|
||||
failed: snapshot?.queue?.failed ?? 0,
|
||||
lastPolled: relativeAge(snapshot?.last_source_poll?.at),
|
||||
lastPolledAt: snapshot?.last_source_poll?.at || null,
|
||||
bridgeAddress:
|
||||
snapshot?.destination?.relay_bridge_default ||
|
||||
snapshot?.destination?.relay_bridge ||
|
||||
@@ -305,118 +298,91 @@ export default function BridgeMonitoringPage({
|
||||
})
|
||||
}, [bridgeStatus])
|
||||
|
||||
useEffect(() => {
|
||||
setRelayPage(1)
|
||||
}, [relayLanes.length])
|
||||
|
||||
const relayPageCount = relayLanes.length ? Math.max(1, Math.ceil(relayLanes.length / BRIDGE_RELAY_PAGE_SIZE)) : 1
|
||||
const visibleRelayLanes = relayLanes.slice(
|
||||
(relayPage - 1) * BRIDGE_RELAY_PAGE_SIZE,
|
||||
relayPage * BRIDGE_RELAY_PAGE_SIZE,
|
||||
)
|
||||
|
||||
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">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<div className="mb-4 max-w-4xl">
|
||||
<div className="mb-2 inline-flex rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-700 dark:border-sky-900/50 dark:bg-sky-950/30 dark:text-sky-200">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
<h1 className="mb-2 text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{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>
|
||||
<DisclosureSection title="Bridge scope note" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<Card className="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>
|
||||
</DisclosureSection>
|
||||
) : null}
|
||||
|
||||
<OperationsSurfaceNav />
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Bridge Freshness Context" />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel="Bridge relay posture is shown alongside the same explorer freshness model used on the homepage and core explorer routes"
|
||||
/>
|
||||
<ExplorerFreshnessDisclosure
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
title="Freshness & delivery"
|
||||
activityTitle="Bridge recency"
|
||||
scopeLabel="Bridge relay posture on this page."
|
||||
>
|
||||
<MissionDeliveryModePanel className="mt-3" mode={bridgeStatus?.data?.mode} title="Bridge delivery mode" />
|
||||
<SubsystemPosturePanel
|
||||
className="mt-3"
|
||||
subsystems={bridgeStatus?.data?.subsystems}
|
||||
title="Bridge Subsystem Posture"
|
||||
title="Bridge subsystem posture"
|
||||
preferredKeys={['rpc_head', 'tx_index', 'bridge_relay_monitoring', 'stats_summary', 'freshness_queries']}
|
||||
scopeLabel="These bridge-facing subsystem signals show whether the limiting factor is public head visibility, transaction indexing, relay monitoring, or degraded freshness queries."
|
||||
scopeLabel="Bridge-facing subsystem signals."
|
||||
/>
|
||||
</div>
|
||||
</ExplorerFreshnessDisclosure>
|
||||
|
||||
<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>
|
||||
<CompactMetricBar
|
||||
metrics={[
|
||||
{ label: 'Fleet', value: overallStatus },
|
||||
{ label: 'Lanes', value: String(relayLanes.length) },
|
||||
{ label: 'Chain 138', value: chainStatus?.status || 'unknown' },
|
||||
{ label: 'Feed', value: feedState === 'live' ? 'live' : feedState === 'fallback' ? 'snapshot' : 'connecting' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<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) => (
|
||||
<DisclosureSection title="Relay lane detail" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{visibleRelayLanes.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">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{lane.label}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="mt-0.5 text-xs 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)}`}>
|
||||
<div className={`rounded-full px-2.5 py-0.5 text-[10px] 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 className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<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>
|
||||
@@ -436,7 +402,7 @@ export default function BridgeMonitoringPage({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Last polled: {lane.lastPolled}
|
||||
Last polled: <ClientRelativeTime value={lane.lastPolledAt} suffix=" ago" />
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Bridge: {shortAddress(lane.bridgeAddress)}
|
||||
@@ -460,23 +426,38 @@ export default function BridgeMonitoringPage({
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{relayLanes.length > BRIDGE_RELAY_PAGE_SIZE ? (
|
||||
<PaginationControls
|
||||
className="mt-4"
|
||||
page={relayPage}
|
||||
pageCount={relayPageCount}
|
||||
onPageChange={setRelayPage}
|
||||
label="Relay lanes"
|
||||
ariaLabel="Bridge relay lane pagination"
|
||||
/>
|
||||
) : null}
|
||||
</DisclosureSection>
|
||||
|
||||
<BridgeLaneHealthPanel laneHealth={bridgeStatus?.data?.bridge_lanes} />
|
||||
<DisclosureSection title="Lane health panel" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<BridgeLaneHealthPanel laneHealth={bridgeStatus?.data?.bridge_lanes} />
|
||||
</DisclosureSection>
|
||||
|
||||
{routeEntries.length > 0 ? (
|
||||
<Card title="CCIP route catalog" className="mb-8">
|
||||
<DisclosureSection title="CCIP route catalog" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<Card title="CCIP route catalog">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Destination bridge contracts from{' '}
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">
|
||||
/token-aggregation/api/v1/bridge/routes
|
||||
</code>
|
||||
{bridgeRoutes?.source ? (
|
||||
<>
|
||||
{' '}
|
||||
(source: {bridgeRoutes.source}
|
||||
{bridgeRoutes.lastModified ? ` · updated ${relativeAge(bridgeRoutes.lastModified)}` : ''})
|
||||
</>
|
||||
) : null}
|
||||
{bridgeRoutes?.lastModified ? (
|
||||
<>
|
||||
{' '}
|
||||
(source: {bridgeRoutes.source} · updated <ClientRelativeTime value={bridgeRoutes.lastModified} suffix=" ago" />)
|
||||
</>
|
||||
) : bridgeRoutes?.source ? (
|
||||
<> (source: {bridgeRoutes.source})</>
|
||||
) : null}
|
||||
. Gnosis, Cronos, Celo, and Wemix lanes are aligned to deployed CCIP receivers — fund LINK on each remote bridge before live traffic.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -508,9 +489,10 @@ export default function BridgeMonitoringPage({
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</DisclosureSection>
|
||||
) : null}
|
||||
|
||||
<OperationsActionGrid actions={page.actions} />
|
||||
<OperationsActionGrid actions={page.actions} className="mt-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import type { ExplorerFeatureAction, ExplorerFeaturePage } from '@/data/explorerOperations'
|
||||
|
||||
function ActionLink({ action }: { action: ExplorerFeatureAction }) {
|
||||
@@ -23,44 +24,42 @@ function ActionLink({ action }: { action: ExplorerFeatureAction }) {
|
||||
|
||||
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">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<div className="mb-4 max-w-4xl">
|
||||
<div className="mb-2 inline-flex rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-700 dark:border-sky-900/50 dark:bg-sky-950/30 dark:text-sky-200">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
<h1 className="mb-2 text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{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>
|
||||
<DisclosureSection title="Scope note" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<Card className="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>
|
||||
</DisclosureSection>
|
||||
) : 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}
|
||||
<DisclosureSection title="Related actions" defaultOpen={false} alwaysCollapsible>
|
||||
<div className="grid gap-3 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>
|
||||
<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>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</DisclosureSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,18 +20,20 @@ import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse }
|
||||
import { missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure'
|
||||
import CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
|
||||
import OperationsSurfaceNav from './OperationsSurfaceNav'
|
||||
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import {
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
@@ -83,6 +85,7 @@ export default function LiquidityOperationsPage({
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'pools' | 'routes' | 'api'>('pools')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -273,22 +276,27 @@ export default function LiquidityOperationsPage({
|
||||
}
|
||||
}
|
||||
|
||||
const liquidityTabs = useMemo<SectionTab<'pools' | 'routes' | 'api'>[]>(
|
||||
() => [
|
||||
{ id: 'pools', label: 'Pools', count: aggregatedPools.length },
|
||||
{ id: 'routes', label: 'Routes', count: highlightedRoutes.length },
|
||||
{ id: 'api', label: 'API & tools' },
|
||||
],
|
||||
[aggregatedPools.length, highlightedRoutes.length],
|
||||
)
|
||||
|
||||
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">
|
||||
Liquidity
|
||||
</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>
|
||||
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
<main className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Liquidity"
|
||||
title="Liquidity & routes"
|
||||
description="Live route matrix, planner capabilities, and mission-control pool inventory for Chain 138."
|
||||
actions={[
|
||||
{ href: '/pools', label: 'Pools' },
|
||||
{ href: '/routes', label: 'Routes' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<OperationsSurfaceNav />
|
||||
|
||||
@@ -303,25 +311,34 @@ export default function LiquidityOperationsPage({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Liquidity Freshness Context" />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel="Liquidity inventory and planner posture are shown alongside the same explorer freshness model used on the homepage and core explorer routes"
|
||||
/>
|
||||
<ExplorerFreshnessDisclosure
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
title="Freshness & subsystems"
|
||||
activityTitle="Liquidity recency"
|
||||
scopeLabel="Liquidity inventory and planner posture on this page."
|
||||
>
|
||||
<SubsystemPosturePanel
|
||||
className="mt-3"
|
||||
subsystems={bridgeStatus?.data?.subsystems}
|
||||
title="Liquidity Subsystem Posture"
|
||||
title="Liquidity subsystem posture"
|
||||
preferredKeys={['rpc_head', 'tx_index', 'stats_summary', 'freshness_queries']}
|
||||
scopeLabel="Use these subsystem signals to tell whether sparse liquidity visibility is consistent with quiet-chain conditions, partial transaction indexing, or stale public summary data."
|
||||
scopeLabel="Subsystem signals for liquidity visibility."
|
||||
/>
|
||||
</div>
|
||||
</ExplorerFreshnessDisclosure>
|
||||
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<CompactMetricBar
|
||||
metrics={[
|
||||
{ label: 'Pools', value: formatNumber(aggregatedPools.length) },
|
||||
{ label: 'Routes', value: formatNumber(routeMatrix?.counts?.filteredLiveRoutes) },
|
||||
{ label: 'Providers', value: formatNumber(livePlannerProviders.length) },
|
||||
{ label: 'Planner', value: internalPlan?.plannerResponse?.decision || 'unknown' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<DisclosureSection title="Metric details" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<div className="mb-4 grid gap-3 md:grid-cols-2">
|
||||
<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
|
||||
@@ -369,11 +386,22 @@ export default function LiquidityOperationsPage({
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</DisclosureSection>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<SectionTabs
|
||||
tabs={liquidityTabs}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
idPrefix="liquidity-ops"
|
||||
ariaLabel="Liquidity sections"
|
||||
className="-mx-4 mb-4 px-4"
|
||||
/>
|
||||
|
||||
<TabPanel idPrefix="liquidity-ops" tabId="pools" activeTab={activeTab}>
|
||||
<div className="mb-4 grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<Card title="Live Pool Snapshot">
|
||||
<div className="space-y-4">
|
||||
{aggregatedPools.slice(0, 8).map((pool) => (
|
||||
<div className="space-y-3">
|
||||
{aggregatedPools.slice(0, 5).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"
|
||||
@@ -429,7 +457,23 @@ export default function LiquidityOperationsPage({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<DisclosureSection title="Curated LP registry" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<Card title="Curated LP registry (Chain 138)">
|
||||
<LpPositionPanel chainId={138} title="Pool registry & LP scan" compact />
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Connect a wallet on the{' '}
|
||||
<Link href="/wallet" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
|
||||
Wallet
|
||||
</Link>{' '}
|
||||
page to scan your LP shares and estimated USD NAV. LP tokens are chain-local — bridge underlying c*/cW*
|
||||
instead.
|
||||
</p>
|
||||
</Card>
|
||||
</DisclosureSection>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="liquidity-ops" tabId="routes" activeTab={activeTab}>
|
||||
<div className="mb-4 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Top Route-Backed Liquidity Paths">
|
||||
<div className="space-y-4">
|
||||
{highlightedRoutes.map((route) => (
|
||||
@@ -486,22 +530,10 @@ export default function LiquidityOperationsPage({
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Curated LP registry (Chain 138)">
|
||||
<LpPositionPanel chainId={138} title="Pool registry & LP scan" compact />
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Connect a wallet on the{' '}
|
||||
<Link href="/wallet" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
|
||||
Wallet
|
||||
</Link>{' '}
|
||||
page to scan your LP shares and estimated USD NAV. LP tokens are chain-local — bridge underlying c*/cW*
|
||||
instead.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<TabPanel idPrefix="liquidity-ops" tabId="api" activeTab={activeTab}>
|
||||
<div className="mb-4">
|
||||
<Card title="Explorer Access Points">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{endpointCards.map((endpoint) => (
|
||||
@@ -567,7 +599,7 @@ export default function LiquidityOperationsPage({
|
||||
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
|
||||
The live route matrix was updated <ClientRelativeTime value={routeMatrix?.updated} suffix=" ago" />, and the current
|
||||
route-backed pool set references {formatNumber(routeBackedPoolAddresses.length)} unique
|
||||
pool addresses.
|
||||
</p>
|
||||
@@ -594,6 +626,7 @@ export default function LiquidityOperationsPage({
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,14 +38,16 @@ function ActionCard({ action }: { action: ExplorerFeatureAction }) {
|
||||
export default function OperationsActionGrid({
|
||||
actions,
|
||||
title = 'Quick actions',
|
||||
className = '',
|
||||
}: {
|
||||
actions: ExplorerFeatureAction[]
|
||||
title?: string
|
||||
className?: string
|
||||
}) {
|
||||
if (actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={className}>
|
||||
<details className="group mb-6 rounded-2xl border border-gray-200 bg-gray-50/80 dark:border-gray-800 dark:bg-gray-900/40 md:hidden">
|
||||
<summary className="cursor-pointer list-none px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white [&::-webkit-details-marker]:hidden">
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
@@ -68,6 +70,6 @@ export default function OperationsActionGrid({
|
||||
<ActionCard key={`${action.title}-${action.href}`} action={action} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,28 +8,18 @@ import { getMissionControlRelays, missionControlApi, type MissionControlBridgeSt
|
||||
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import { useUiMode } from '@/components/common/UiModeContext'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure'
|
||||
import CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
|
||||
import OperationsActionGrid from '@/components/explorer/OperationsActionGrid'
|
||||
import BridgeLaneHealthPanel from '@/components/explorer/BridgeLaneHealthPanel'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
|
||||
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,
|
||||
@@ -192,119 +182,67 @@ export default function OperationsHubPage({
|
||||
)
|
||||
|
||||
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>
|
||||
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow={page.eyebrow}
|
||||
title={page.title}
|
||||
description={page.description}
|
||||
actions={[
|
||||
{ href: '/bridge', label: 'Bridge' },
|
||||
{ href: '/routes', label: 'Routes' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{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">
|
||||
<DisclosureSection title="Scope note" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50/70 px-3 py-2 text-sm text-amber-950 dark:border-amber-900/50 dark:bg-amber-950/20 dark:text-amber-100">
|
||||
{page.note}
|
||||
</p>
|
||||
</Card>
|
||||
</DisclosureSection>
|
||||
) : null}
|
||||
|
||||
<OperationsSurfaceNav />
|
||||
|
||||
{loadingError ? (
|
||||
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
||||
<Card className="mb-4 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">
|
||||
<ActivityContextPanel context={activityContext} title="Operations Freshness Context" />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel="This page reflects mission-control freshness, public bridge status, and explorer-served config surfaces."
|
||||
/>
|
||||
<ExplorerFreshnessDisclosure
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
title="Freshness & subsystems"
|
||||
activityTitle="Operations recency"
|
||||
scopeLabel="Mission-control freshness, bridge status, and explorer config surfaces."
|
||||
>
|
||||
<SubsystemPosturePanel
|
||||
className="mt-3"
|
||||
subsystems={bridgeStatus?.data?.subsystems}
|
||||
title="Operations Subsystem Posture"
|
||||
title="Subsystem posture"
|
||||
preferredKeys={['rpc_head', 'tx_index', 'bridge_relay_monitoring', 'stats_summary', 'freshness_queries']}
|
||||
scopeLabel="Operations posture is grounded in the same backend subsystem truth used for chain activity, route inventory, and bridge monitoring."
|
||||
scopeLabel="Operations posture from backend subsystem truth."
|
||||
/>
|
||||
</div>
|
||||
</ExplorerFreshnessDisclosure>
|
||||
|
||||
<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>
|
||||
{laneSummary ? (
|
||||
<div className="mt-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
{laneSummary.funded}/{laneSummary.total} config-ready funded
|
||||
{laneSummary.unfunded > 0 ? ` · ${laneSummary.unfunded} unfunded` : ''}
|
||||
{' · '}
|
||||
{laneSummary.proofRecorded}/{laneSummary.total} proof-recorded
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
<CompactMetricBar
|
||||
metrics={[
|
||||
{ label: 'Bridge', value: bridgeStatus?.data?.status || 'unknown' },
|
||||
{ label: 'Lanes', value: String(relayCount) },
|
||||
{ label: 'Routes', value: String(routeMatrix?.counts?.filteredLiveRoutes ?? 0) },
|
||||
{ label: 'Chains', value: String(networksConfig?.chains?.length ?? 0) },
|
||||
]}
|
||||
/>
|
||||
|
||||
<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]">
|
||||
<DisclosureSection title="Operations snapshot & config" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<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)}
|
||||
<ClientRelativeTime value={bridgeStatus?.data?.checked_at} suffix=" ago" />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{mode === 'guided' ? 'Public mission-control snapshot freshness.' : 'Mission-control freshness.'}
|
||||
@@ -313,7 +251,7 @@ export default function OperationsHubPage({
|
||||
<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)}
|
||||
<ClientRelativeTime value={routeMatrix?.updated} suffix=" ago" />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{mode === 'guided' ? 'Token-aggregation route inventory timestamp.' : 'Route inventory timestamp.'}
|
||||
@@ -385,9 +323,11 @@ export default function OperationsHubPage({
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</DisclosureSection>
|
||||
|
||||
<BridgeLaneHealthPanel laneHealth={bridgeStatus?.data?.bridge_lanes} />
|
||||
<DisclosureSection title="Bridge lane health" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<BridgeLaneHealthPanel laneHealth={bridgeStatus?.data?.bridge_lanes} />
|
||||
</DisclosureSection>
|
||||
|
||||
<OperationsActionGrid actions={page.actions} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import type { ExplorerFeaturePage } from '@/data/explorerOperations'
|
||||
import OperationsSurfaceNav from './OperationsSurfaceNav'
|
||||
import OperationsActionGrid from './OperationsActionGrid'
|
||||
@@ -7,18 +8,6 @@ import OperationsTrackNote from './OperationsTrackNote'
|
||||
|
||||
export type StatusTone = 'normal' | 'warning' | 'danger'
|
||||
|
||||
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)
|
||||
@@ -85,28 +74,38 @@ export function MetricCard({
|
||||
export default function OperationsPageShell({
|
||||
page,
|
||||
children,
|
||||
compact = true,
|
||||
}: {
|
||||
page: ExplorerFeaturePage
|
||||
children: ReactNode
|
||||
compact?: boolean
|
||||
}) {
|
||||
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">
|
||||
<div className={`container mx-auto px-4 ${compact ? 'py-4 sm:py-6' : 'py-6 sm:py-8'}`}>
|
||||
<div className={`max-w-4xl ${compact ? 'mb-4' : 'mb-6 sm:mb-8'}`}>
|
||||
<div className="mb-2 inline-flex rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-700 dark:border-sky-900/50 dark:bg-sky-950/30 dark:text-sky-200">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
<h1 className={`mb-2 font-bold text-gray-900 dark:text-white ${compact ? 'text-2xl sm:text-3xl' : 'mb-3 text-3xl 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">
|
||||
<p className={`text-gray-600 dark:text-gray-400 ${compact ? 'text-sm leading-6' : 'text-base leading-7 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>
|
||||
compact ? (
|
||||
<DisclosureSection title="Scope note" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<Card className="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>
|
||||
</DisclosureSection>
|
||||
) : (
|
||||
<Card className="mb-4 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}
|
||||
|
||||
{page.accessTrack && page.accessNote ? (
|
||||
@@ -117,7 +116,13 @@ export default function OperationsPageShell({
|
||||
|
||||
{children}
|
||||
|
||||
<OperationsActionGrid actions={page.actions} />
|
||||
{compact ? (
|
||||
<DisclosureSection title="Related actions" defaultOpen={false} alwaysCollapsible className="mt-4">
|
||||
<OperationsActionGrid actions={page.actions} />
|
||||
</DisclosureSection>
|
||||
) : (
|
||||
<OperationsActionGrid actions={page.actions} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
} from '@/services/api/missionControl'
|
||||
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
|
||||
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import OperationsPageShell, {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
@@ -31,6 +33,8 @@ function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
const OPERATOR_RELAY_PAGE_SIZE = 4
|
||||
|
||||
export default function OperatorOperationsPage({
|
||||
initialBridgeStatus = null,
|
||||
initialRouteMatrix = null,
|
||||
@@ -42,6 +46,8 @@ export default function OperatorOperationsPage({
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'relays' | 'readiness'>('relays')
|
||||
const [relayPage, setRelayPage] = useState(1)
|
||||
const page = explorerFeaturePages.operator
|
||||
|
||||
useEffect(() => {
|
||||
@@ -84,16 +90,25 @@ export default function OperatorOperationsPage({
|
||||
|
||||
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]
|
||||
|
||||
useEffect(() => {
|
||||
setRelayPage(1)
|
||||
}, [relayEntries.length])
|
||||
|
||||
const relayPageCount = relayEntries.length ? Math.max(1, Math.ceil(relayEntries.length / OPERATOR_RELAY_PAGE_SIZE)) : 1
|
||||
const relayVisibleEntries = relayEntries.slice(
|
||||
(relayPage - 1) * OPERATOR_RELAY_PAGE_SIZE,
|
||||
relayPage * OPERATOR_RELAY_PAGE_SIZE,
|
||||
)
|
||||
const providers = plannerCapabilities?.providers || []
|
||||
const liveProviders = providers.filter((provider) => provider.live)
|
||||
const operatorTabs = useMemo<SectionTab<'relays' | 'readiness'>[]>(
|
||||
() => [
|
||||
{ id: 'relays', label: 'Relay lanes', count: relayEntries.length },
|
||||
{ id: 'readiness', label: 'Readiness' },
|
||||
],
|
||||
[relayEntries.length],
|
||||
)
|
||||
|
||||
return (
|
||||
<OperationsPageShell page={page}>
|
||||
@@ -103,39 +118,28 @@ export default function OperatorOperationsPage({
|
||||
</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>
|
||||
<CompactMetricBar
|
||||
metrics={[
|
||||
{ label: 'Fleet', value: bridgeStatus?.data?.status || 'unknown' },
|
||||
{ label: 'Live routes', value: formatNumber(routeMatrix?.counts?.filteredLiveRoutes) },
|
||||
{ label: 'Providers', value: formatNumber(liveProviders.length) },
|
||||
{ label: 'Plan', value: internalPlan?.plannerResponse?.decision || 'unknown' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<SectionTabs
|
||||
tabs={operatorTabs}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
idPrefix="operator-ops"
|
||||
ariaLabel="Operator telemetry sections"
|
||||
className="-mx-4 mb-4 px-4"
|
||||
/>
|
||||
|
||||
<TabPanel idPrefix="operator-ops" tabId="relays" activeTab={activeTab}>
|
||||
<Card title="Managed Relay Lanes">
|
||||
<div className="space-y-4">
|
||||
{relayEntries.map(([key, relay]) => {
|
||||
{relayVisibleEntries.map(([key, relay]) => {
|
||||
const snapshot = relay.url_probe?.body || relay.file_snapshot
|
||||
const status = snapshot?.status || 'unknown'
|
||||
return (
|
||||
@@ -155,7 +159,7 @@ export default function OperatorOperationsPage({
|
||||
<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)}
|
||||
Last source poll <ClientRelativeTime value={snapshot?.last_source_poll?.at} suffix=" ago" /> · processed {formatNumber(snapshot?.queue?.processed ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -163,9 +167,21 @@ export default function OperatorOperationsPage({
|
||||
{relayEntries.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No relay lane data available.</p>
|
||||
) : null}
|
||||
{relayPageCount > 1 ? (
|
||||
<PaginationControls
|
||||
page={relayPage}
|
||||
onPageChange={setRelayPage}
|
||||
label="Relay lanes"
|
||||
ariaLabel="Operator relay lane pagination"
|
||||
hasNextPage={relayPage < relayPageCount}
|
||||
className="pt-2"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="operator-ops" tabId="readiness" activeTab={activeTab}>
|
||||
<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">
|
||||
@@ -199,7 +215,7 @@ export default function OperatorOperationsPage({
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</OperationsPageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import ListFilterBar from '@/components/common/ListFilterBar'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { type TokenListResponse } from '@/services/api/config'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
|
||||
@@ -15,6 +21,26 @@ import {
|
||||
import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import { formatCurrency, formatNumber, truncateMiddle } from './OperationsPageShell'
|
||||
|
||||
const POOLS_PAGE_SIZE = 6
|
||||
|
||||
type PoolsPageTab = 'overview' | 'pools' | 'tokens'
|
||||
|
||||
function matchesPoolQuery(pool: { address?: string | null; token0?: { symbol?: string | null }; token1?: { symbol?: string | null }; dex?: string | null; sourceSymbols?: string[] }, query: string) {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
if (!normalizedQuery) return true
|
||||
const haystack = [
|
||||
pool.address,
|
||||
pool.token0?.symbol,
|
||||
pool.token1?.symbol,
|
||||
pool.dex,
|
||||
...(pool.sourceSymbols || []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return haystack.includes(normalizedQuery)
|
||||
}
|
||||
|
||||
interface TokenPoolRecord {
|
||||
symbol: string
|
||||
pools: MissionControlLiquidityPool[]
|
||||
@@ -25,6 +51,10 @@ export default function PoolsOperationsPage() {
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>([])
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<PoolsPageTab>('pools')
|
||||
const [poolQuery, setPoolQuery] = useState('')
|
||||
const [poolPage, setPoolPage] = useState(1)
|
||||
const [tokenQuery, setTokenQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -88,23 +118,63 @@ export default function PoolsOperationsPage() {
|
||||
() => getRouteBackedPoolAddresses(routeMatrix),
|
||||
[routeMatrix]
|
||||
)
|
||||
const topPools = aggregatedPools.slice(0, 9)
|
||||
|
||||
const filteredPools = useMemo(
|
||||
() => aggregatedPools.filter((pool) => matchesPoolQuery(pool, poolQuery)),
|
||||
[aggregatedPools, poolQuery],
|
||||
)
|
||||
|
||||
const poolPageCount = Math.max(1, Math.ceil(filteredPools.length / POOLS_PAGE_SIZE))
|
||||
|
||||
const paginatedPools = useMemo(() => {
|
||||
const safePage = Math.min(Math.max(poolPage, 1), poolPageCount)
|
||||
const start = (safePage - 1) * POOLS_PAGE_SIZE
|
||||
return filteredPools.slice(start, start + POOLS_PAGE_SIZE)
|
||||
}, [filteredPools, poolPage, poolPageCount])
|
||||
|
||||
const filteredFeaturedTokens = useMemo(() => {
|
||||
const normalizedQuery = tokenQuery.trim().toLowerCase()
|
||||
if (!normalizedQuery) return featuredTokens
|
||||
return featuredTokens.filter((token) => {
|
||||
const haystack = [token.symbol, token.name, token.address].filter(Boolean).join(' ').toLowerCase()
|
||||
return haystack.includes(normalizedQuery)
|
||||
})
|
||||
}, [featuredTokens, tokenQuery])
|
||||
|
||||
useEffect(() => {
|
||||
setPoolPage(1)
|
||||
}, [poolQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (poolPage > poolPageCount) {
|
||||
setPoolPage(poolPageCount)
|
||||
}
|
||||
}, [poolPage, poolPageCount])
|
||||
|
||||
const poolTabs = useMemo<SectionTab<PoolsPageTab>[]>(
|
||||
() => [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'pools', label: 'Pool cards', count: aggregatedPools.length },
|
||||
{ id: 'tokens', label: 'By token', count: featuredTokens.length },
|
||||
],
|
||||
[aggregatedPools.length, featuredTokens.length],
|
||||
)
|
||||
|
||||
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>
|
||||
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Live Pool Inventory"
|
||||
title="Pools"
|
||||
description="Live pool inventory from mission-control endpoints, cross-checked against the route matrix."
|
||||
actions={[
|
||||
{ href: '/liquidity', label: 'Liquidity' },
|
||||
{ href: '/routes', label: 'Routes' },
|
||||
]}
|
||||
/>
|
||||
<DisclosureSection title="Token list surface note" defaultOpen={false} alwaysCollapsible className="mb-3">
|
||||
<TokenListSurfaceNote className="text-sm text-gray-600 dark:text-gray-400" />
|
||||
</DisclosureSection>
|
||||
|
||||
<OperationsSurfaceNav />
|
||||
|
||||
@@ -114,7 +184,27 @@ export default function PoolsOperationsPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-3">
|
||||
<CompactMetricBar
|
||||
metrics={[
|
||||
{ label: 'Pools', value: formatNumber(aggregatedPools.length) },
|
||||
{ label: 'Route-backed', value: formatNumber(routeBackedPoolAddresses.length) },
|
||||
{ label: 'Featured', value: formatNumber(featuredTokens.length) },
|
||||
{ label: 'With pools', value: formatNumber(tokenPoolRecords.filter((record) => record.pools.length > 0).length) },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SectionTabs
|
||||
tabs={poolTabs}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
idPrefix="pools-page"
|
||||
ariaLabel="Pools sections"
|
||||
className="-mx-4 mb-4 px-4"
|
||||
/>
|
||||
|
||||
<TabPanel idPrefix="pools-page" tabId="overview" activeTab={activeTab}>
|
||||
<DisclosureSection title="Pool inventory details" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<div className="mb-4 grid gap-3 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
|
||||
@@ -147,14 +237,58 @@ export default function PoolsOperationsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</DisclosureSection>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Pool operation shortcuts" className="mb-4">
|
||||
<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>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="pools-page" tabId="pools" activeTab={activeTab}>
|
||||
<div className="mb-6">
|
||||
<Card title="Live Pool Cards">
|
||||
<ListFilterBar
|
||||
query={poolQuery}
|
||||
onQueryChange={setPoolQuery}
|
||||
placeholder="Filter pools by pair, DEX, address, or source token"
|
||||
resultCount={filteredPools.length}
|
||||
totalCount={aggregatedPools.length}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{topPools.map((pool) => (
|
||||
<div
|
||||
{paginatedPools.map((pool) => (
|
||||
<Link
|
||||
key={pool.address}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
href={`/pools/${pool.address}`}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 transition hover:border-primary-400 hover:bg-primary-50/40 dark:border-gray-700 dark:bg-gray-900/40 dark:hover:border-primary-700"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')}
|
||||
@@ -168,76 +302,90 @@ export default function PoolsOperationsPage() {
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Seen from {pool.sourceSymbols.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{topPools.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No live pools available right now.</p>
|
||||
{paginatedPools.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 lg:col-span-3">
|
||||
{aggregatedPools.length === 0 ? 'No live pools available right now.' : 'No pools match your filter.'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<PaginationControls
|
||||
page={poolPage}
|
||||
pageCount={poolPageCount}
|
||||
onPageChange={setPoolPage}
|
||||
label="Pools"
|
||||
ariaLabel="Pool cards pagination"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<TabPanel idPrefix="pools-page" tabId="tokens" activeTab={activeTab}>
|
||||
<div>
|
||||
<Card title="Featured Token Pool Counts">
|
||||
<div className="space-y-4">
|
||||
{featuredTokens.map((token) => {
|
||||
<ListFilterBar
|
||||
query={tokenQuery}
|
||||
onQueryChange={setTokenQuery}
|
||||
placeholder="Filter featured tokens"
|
||||
resultCount={filteredFeaturedTokens.length}
|
||||
totalCount={featuredTokens.length}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{filteredFeaturedTokens.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No featured tokens match your filter.</p>
|
||||
) : (
|
||||
filteredFeaturedTokens.map((token) => {
|
||||
const record = tokenPoolRecords.find((entry) => entry.symbol === token.symbol)
|
||||
return (
|
||||
<div
|
||||
<DisclosureSection
|
||||
key={token.address}
|
||||
title={`${token.symbol} · ${formatNumber(record?.pools.length || 0)} pools`}
|
||||
defaultOpen={token.symbol === 'cUSDT' || token.symbol === 'cUSDC'}
|
||||
alwaysCollapsible
|
||||
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 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">
|
||||
{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 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>
|
||||
<Link
|
||||
href={`/tokens/${token.address}`}
|
||||
className="text-sm font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
Open token →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{record && record.pools.length > 0 ? (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{record.pools.map((pool) => (
|
||||
<Link
|
||||
key={pool.address}
|
||||
href={`/pools/${pool.address}`}
|
||||
className="rounded-xl border border-gray-200 bg-white/80 p-3 transition hover:border-primary-400 dark:border-gray-700 dark:bg-black/10"
|
||||
>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{pool.dex || 'Unknown DEX'} · TVL {formatCurrency(pool.tvl)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">No live pools returned for this token yet.</p>
|
||||
)}
|
||||
</DisclosureSection>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Pool operation 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>
|
||||
</TabPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,12 +12,15 @@ import {
|
||||
import { missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure'
|
||||
import CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import SectionTabs, { TabPanel } from '@/components/common/SectionTabs'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
import OperationsSurfaceNav from './OperationsSurfaceNav'
|
||||
import OperationsActionGrid from './OperationsActionGrid'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
|
||||
interface RoutesMonitoringPageProps {
|
||||
initialRouteMatrix?: RouteMatrixResponse | null
|
||||
@@ -29,18 +32,6 @@ interface RoutesMonitoringPageProps {
|
||||
|
||||
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
|
||||
@@ -110,6 +101,7 @@ export default function RoutesMonitoringPage({
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const [routesTab, setRoutesTab] = useState<'overview' | 'live' | 'backlog'>('overview')
|
||||
const page = explorerFeaturePages.routes
|
||||
|
||||
useEffect(() => {
|
||||
@@ -205,25 +197,27 @@ export default function RoutesMonitoringPage({
|
||||
)
|
||||
|
||||
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">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<div className="mb-4 max-w-4xl">
|
||||
<div className="mb-2 inline-flex rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
{page.eyebrow}
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
<h1 className="mb-2 text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{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>
|
||||
<DisclosureSection title="Routes scope note" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<Card className="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>
|
||||
</DisclosureSection>
|
||||
) : null}
|
||||
|
||||
<OperationsSurfaceNav />
|
||||
@@ -234,215 +228,178 @@ export default function RoutesMonitoringPage({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Routes Freshness Context" />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel="Route availability reflects the current public route matrix and the same explorer freshness model used on the core explorer pages"
|
||||
/>
|
||||
<ExplorerFreshnessDisclosure
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
title="Freshness & subsystems"
|
||||
activityTitle="Routes recency"
|
||||
scopeLabel="Route matrix freshness on this page."
|
||||
>
|
||||
<SubsystemPosturePanel
|
||||
className="mt-3"
|
||||
subsystems={bridgeStatus?.data?.subsystems}
|
||||
title="Route Subsystem Posture"
|
||||
title="Route subsystem posture"
|
||||
preferredKeys={['rpc_head', 'tx_index', 'stats_summary', 'freshness_queries']}
|
||||
scopeLabel="Route inventory is read against the same backend freshness signals used to explain whether route posture is limited by head visibility, transaction indexing, or stale summary data."
|
||||
scopeLabel="Route inventory subsystem signals."
|
||||
/>
|
||||
</div>
|
||||
</ExplorerFreshnessDisclosure>
|
||||
|
||||
<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>
|
||||
<CompactMetricBar
|
||||
metrics={[
|
||||
{ label: 'Swaps', value: String(routeMatrix?.counts?.liveSwapRoutes ?? liveRoutes.filter((route) => route.routeType === 'swap').length) },
|
||||
{ label: 'Bridges', value: String(routeMatrix?.counts?.liveBridgeRoutes ?? liveRoutes.filter((route) => route.routeType === 'bridge').length) },
|
||||
{ label: 'Networks', value: String(networks.length) },
|
||||
{ label: 'Pools', value: String(pools.length) },
|
||||
]}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<SectionTabs
|
||||
idPrefix="routes"
|
||||
ariaLabel="Route views"
|
||||
tabs={[
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'live', label: 'Live routes', count: topRoutes.length },
|
||||
{ id: 'backlog', label: 'Backlog', count: plannedRoutes.length },
|
||||
]}
|
||||
activeTab={routesTab}
|
||||
onChange={setRoutesTab}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<TabPanel idPrefix="routes" tabId="overview" activeTab={routesTab}>
|
||||
<DisclosureSection title="Matrix detail" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card title="Route matrix summary">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Generated</div>
|
||||
<div className="mt-1 text-base font-semibold text-gray-900 dark:text-white">
|
||||
<ClientRelativeTime value={routeMatrix?.generatedAt} suffix=" ago" />
|
||||
</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 className="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
Matrix version {routeMatrix?.version || 'unknown'}
|
||||
</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 className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Updated</div>
|
||||
<div className="mt-1 text-base font-semibold text-gray-900 dark:text-white">
|
||||
<ClientRelativeTime value={routeMatrix?.updated} suffix=" ago" />
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
{familyCount} partner families in live routes
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Protocols: {protocolList(route)}
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Live routes</div>
|
||||
<div className="mt-1 text-base font-semibold text-gray-900 dark:text-white">
|
||||
{routeMatrix?.counts?.filteredLiveRoutes ?? liveRoutes.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Planned / blocked</div>
|
||||
<div className="mt-1 text-base font-semibold text-gray-900 dark:text-white">
|
||||
{routeMatrix?.counts?.blockedOrPlannedRoutes ?? plannedRoutes.length}
|
||||
</div>
|
||||
</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>
|
||||
</Card>
|
||||
<Card title="Highlighted networks">
|
||||
<div className="space-y-2">
|
||||
{highlightedNetworks.map((network) => (
|
||||
<div
|
||||
key={`${network.chainIdDecimal}-${network.chainName}`}
|
||||
className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{network.chainName || 'Unknown chain'}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
Chain ID {network.chainIdDecimal ?? 'Unknown'} · {network.shortName || 'n/a'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</DisclosureSection>
|
||||
</TabPanel>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Planned Route Backlog">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{plannedRoutes.slice(0, 6).map((route) => (
|
||||
<TabPanel idPrefix="routes" tabId="live" activeTab={routesTab}>
|
||||
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card title="Live route snapshot">
|
||||
<div className="space-y-3">
|
||||
{topRoutes.map((route) => (
|
||||
<div
|
||||
key={route.routeId}
|
||||
className="rounded-xl border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{route.label || route.routeId}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
{routeAssetPair(route)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary-50 px-2 py-0.5 text-[10px] 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-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
{`${route.fromChainId} -> ${route.toChainId} · ${route.hopCount ?? 0} hop${(route.hopCount ?? 0) === 1 ? '' : 's'}`}
|
||||
{' · '}
|
||||
{protocolList(route)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="cUSDT pools">
|
||||
<div className="space-y-3">
|
||||
{pools.slice(0, 6).map((pool) => (
|
||||
<div
|
||||
key={pool.address}
|
||||
className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
{pool.dex || 'Unknown DEX'} · TVL {formatUsd(pool.tvl)} · {compactAddress(pool.address)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="routes" tabId="backlog" activeTab={routesTab}>
|
||||
<Card title="Planned route backlog">
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{plannedRoutes.slice(0, 8).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"
|
||||
className="rounded-xl border border-amber-200 bg-amber-50/70 p-3 dark:border-amber-900/50 dark:bg-amber-950/20"
|
||||
>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{route.routeId}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div className="mt-1 text-xs 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">
|
||||
<div className="mt-2 text-xs leading-5 text-gray-600 dark:text-gray-400">
|
||||
{route.reason || 'Pending additional deployment or routing work.'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<OperationsActionGrid actions={page.actions} />
|
||||
<OperationsActionGrid actions={page.actions} className="mt-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ import { tokensApi } from '@/services/api/tokens'
|
||||
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 CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import OperationsPageShell, {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
} from './OperationsPageShell'
|
||||
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
|
||||
|
||||
@@ -38,6 +40,7 @@ export default function SystemOperationsPage({
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'topology' | 'networks'>('topology')
|
||||
const page = explorerFeaturePages.system
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,9 +93,12 @@ export default function SystemOperationsPage({
|
||||
|
||||
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]
|
||||
const systemTabs = useMemo<SectionTab<'topology' | 'networks'>[]>(
|
||||
() => [
|
||||
{ id: 'topology', label: 'Topology' },
|
||||
{ id: 'networks', label: 'Networks', count: networksConfig?.chains?.length ?? 0 },
|
||||
],
|
||||
[networksConfig?.chains?.length],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -103,33 +109,28 @@ export default function SystemOperationsPage({
|
||||
</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>
|
||||
<TokenListSurfaceNote className="mb-6 text-xs text-gray-500 dark:text-gray-400" />
|
||||
<CompactMetricBar
|
||||
metrics={[
|
||||
{ label: 'Networks', value: formatNumber(networksConfig?.chains?.length) },
|
||||
{ label: 'Relays', value: formatNumber(relays ? Object.keys(relays).length : 0) },
|
||||
{ label: 'Tokens', value: formatNumber((tokenList?.tokens || []).length) },
|
||||
{ label: 'RPC methods', value: formatNumber(capabilities?.http?.supportedMethods?.length) },
|
||||
]}
|
||||
/>
|
||||
<DisclosureSection title="Token list surface note" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<TokenListSurfaceNote className="text-xs text-gray-500 dark:text-gray-400" />
|
||||
</DisclosureSection>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<SectionTabs
|
||||
tabs={systemTabs}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
idPrefix="system-ops"
|
||||
ariaLabel="System inventory sections"
|
||||
className="-mx-4 mb-4 px-4"
|
||||
/>
|
||||
|
||||
<TabPanel idPrefix="system-ops" tabId="topology" activeTab={activeTab}>
|
||||
<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">
|
||||
@@ -147,7 +148,7 @@ export default function SystemOperationsPage({
|
||||
{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
|
||||
Updated <ClientRelativeTime value={routeMatrix?.updated} suffix=" ago" /> · {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">
|
||||
@@ -181,7 +182,9 @@ export default function SystemOperationsPage({
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="system-ops" tabId="networks" activeTab={activeTab}>
|
||||
<Card title="Network Inventory">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{(networksConfig?.chains || []).map((chain) => (
|
||||
@@ -202,7 +205,7 @@ export default function SystemOperationsPage({
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</OperationsPageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
type MissionControlRelayPayload,
|
||||
} from '@/services/api/missionControl'
|
||||
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
|
||||
import CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import OperationsPageShell, {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
interface WethOperationsPageProps {
|
||||
@@ -33,14 +34,6 @@ function relaySnapshot(relay: MissionControlRelayPayload | undefined) {
|
||||
return relay?.url_probe?.body || relay?.file_snapshot
|
||||
}
|
||||
|
||||
function relaySummary(snapshot: ReturnType<typeof relaySnapshot>) {
|
||||
if (!snapshot) return 'destination unknown'
|
||||
if (snapshot.status === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
|
||||
return `Delivery paused · queue ${formatNumber(snapshot.queue?.size ?? 0)}`
|
||||
}
|
||||
return `Queue ${formatNumber(snapshot.queue?.size ?? 0)} · ${snapshot.destination?.chain_name || 'destination unknown'}`
|
||||
}
|
||||
|
||||
export default function WethOperationsPage({
|
||||
initialBridgeStatus = null,
|
||||
initialPlannerCapabilities = null,
|
||||
@@ -50,6 +43,7 @@ export default function WethOperationsPage({
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'lanes' | 'providers'>('lanes')
|
||||
const page = explorerFeaturePages.weth
|
||||
|
||||
useEffect(() => {
|
||||
@@ -101,14 +95,24 @@ export default function WethOperationsPage({
|
||||
[plannerCapabilities]
|
||||
)
|
||||
|
||||
const wethTabs = useMemo<SectionTab<'lanes' | 'providers'>[]>(
|
||||
() => [
|
||||
{ id: 'lanes', label: 'Bridge lanes', count: 2 },
|
||||
{ id: 'providers', label: 'Providers', count: wethProviders.length },
|
||||
],
|
||||
[wethProviders.length],
|
||||
)
|
||||
|
||||
return (
|
||||
<OperationsPageShell page={page}>
|
||||
<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">
|
||||
These WETH references are bridge-lane and public-network representation surfaces, not a claim that Ethereum mainnet WETH contracts are native Chain 138 assets.
|
||||
Use this page to review wrapped-asset lane posture, counterpart contracts, and operational dependencies.
|
||||
</p>
|
||||
</Card>
|
||||
<DisclosureSection title="WETH scope note" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<Card className="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">
|
||||
These WETH references are bridge-lane and public-network representation surfaces, not a claim that Ethereum mainnet WETH contracts are native Chain 138 assets.
|
||||
Use this page to review wrapped-asset lane posture, counterpart contracts, and operational dependencies.
|
||||
</p>
|
||||
</Card>
|
||||
</DisclosureSection>
|
||||
|
||||
{loadingError ? (
|
||||
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
||||
@@ -116,37 +120,26 @@ export default function WethOperationsPage({
|
||||
</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={relaySummary(mainnetWeth)}
|
||||
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={relaySummary(mainnetCw)}
|
||||
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>
|
||||
<CompactMetricBar
|
||||
metrics={[
|
||||
{ label: 'WETH lane', value: mainnetWeth?.status || 'unknown' },
|
||||
{ label: 'cW lane', value: mainnetCw?.status || 'unknown' },
|
||||
{ label: 'Providers', value: formatNumber(wethProviders.length) },
|
||||
{ label: 'Plan', value: internalPlan?.plannerResponse?.decision || 'unknown' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Mainnet Bridge Lanes">
|
||||
<SectionTabs
|
||||
tabs={wethTabs}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
idPrefix="weth-ops"
|
||||
ariaLabel="WETH operations sections"
|
||||
className="-mx-4 mb-4 px-4"
|
||||
/>
|
||||
|
||||
<TabPanel idPrefix="weth-ops" tabId="lanes" activeTab={activeTab}>
|
||||
<Card title="Mainnet Bridge Lanes" className="mb-4">
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: 'Mainnet WETH', snapshot: mainnetWeth },
|
||||
@@ -166,13 +159,15 @@ export default function WethOperationsPage({
|
||||
<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)}
|
||||
Queue {formatNumber(snapshot?.queue?.size ?? 0)} · last poll <ClientRelativeTime value={snapshot?.last_source_poll?.at} suffix=" ago" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="weth-ops" tabId="providers" activeTab={activeTab}>
|
||||
<Card title="WETH Route-Ready Providers">
|
||||
<div className="space-y-4">
|
||||
{wethProviders.map((provider) => {
|
||||
@@ -207,7 +202,7 @@ export default function WethOperationsPage({
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</OperationsPageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives/Card'
|
||||
import { Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { blocksApi, type Block } from '@/services/api/blocks'
|
||||
import {
|
||||
@@ -17,13 +17,15 @@ import {
|
||||
} from '@/services/api/missionControl'
|
||||
import { loadDashboardData } from '@/utils/dashboard'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import { formatRelativeAge, formatTimestamp, formatWeiAsEth } from '@/utils/format'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import { formatInteger, formatTimestamp, formatWeiAsEth } from '@/utils/format'
|
||||
import { transactionsApi, type Transaction } from '@/services/api/transactions'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel'
|
||||
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
||||
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
|
||||
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
|
||||
@@ -37,6 +39,12 @@ import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
|
||||
|
||||
type HomeStats = ExplorerStats
|
||||
|
||||
const HOME_VISIBLE_TRANSACTIONS = 3
|
||||
const HOME_VISIBLE_BLOCKS = 5
|
||||
const HOME_PRICE_FEED_LIMIT = 4
|
||||
|
||||
type HomeActivityTab = 'transactions' | 'blocks'
|
||||
|
||||
interface HomePageProps {
|
||||
initialStats?: HomeStats | null
|
||||
initialRecentBlocks?: Block[]
|
||||
@@ -99,7 +107,7 @@ function formatGasPriceGwei(value: number) {
|
||||
return `${value.toFixed(3)} gwei`
|
||||
}
|
||||
|
||||
function compactStatNote(guided: string, expert: string, mode: 'guided' | 'expert') {
|
||||
function compactStatNote(guided: ReactNode, expert: ReactNode, mode: 'guided' | 'expert') {
|
||||
return mode === 'guided' ? guided : expert
|
||||
}
|
||||
|
||||
@@ -133,6 +141,7 @@ export default function Home({
|
||||
const [missionExpanded, setMissionExpanded] = useState(false)
|
||||
const [relayExpanded, setRelayExpanded] = useState(false)
|
||||
const [statsDetailsExpanded, setStatsDetailsExpanded] = useState(false)
|
||||
const [homeActivityTab, setHomeActivityTab] = useState<HomeActivityTab>('transactions')
|
||||
const [relayPage, setRelayPage] = useState(1)
|
||||
const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>(
|
||||
initialRelaySummary || initialBridgeStatus ? 'fallback' : 'connecting'
|
||||
@@ -148,7 +157,7 @@ export default function Home({
|
||||
const response = await blocksApi.list({
|
||||
chain_id: chainId,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
page_size: HOME_VISIBLE_BLOCKS,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
@@ -376,7 +385,6 @@ export default function Home({
|
||||
}
|
||||
return parts.join(' · ')
|
||||
})()
|
||||
const snapshotAgeLabel = checkedAt ? formatRelativeAge(checkedAt) : 'Unknown'
|
||||
const chainVisibilityState =
|
||||
chainStatus?.head_age_sec != null
|
||||
? chainStatus.head_age_sec <= 30
|
||||
@@ -418,9 +426,11 @@ export default function Home({
|
||||
...(bridgeStatus?.data?.sampling?.issues || {}),
|
||||
...(stats?.sampling?.issues || {}),
|
||||
})
|
||||
const latestTransactionAgeLabel = activityContext.latest_transaction_timestamp
|
||||
? formatRelativeAge(activityContext.latest_transaction_timestamp)
|
||||
: 'Unknown'
|
||||
const latestTransactionAgeLabel = activityContext.latest_transaction_timestamp ? (
|
||||
<ClientRelativeTime value={activityContext.latest_transaction_timestamp} suffix=" ago" />
|
||||
) : (
|
||||
'Unknown'
|
||||
)
|
||||
const latestTransactionFreshness =
|
||||
activityContext.latest_transaction_age_seconds == null
|
||||
? 'Latest transaction freshness is unavailable.'
|
||||
@@ -445,7 +455,7 @@ export default function Home({
|
||||
)
|
||||
const transactionsTodaySummary = formatObservabilityValue(
|
||||
transactionsToday,
|
||||
(value) => value.toLocaleString(),
|
||||
(value) => formatInteger(value),
|
||||
)
|
||||
const networkUtilizationSummary =
|
||||
networkUtilization == null
|
||||
@@ -456,31 +466,34 @@ export default function Home({
|
||||
const missionCollapsedSummary = relaySummary
|
||||
? `${missionHeadline} · ${relayOperationalCount} operational`
|
||||
: `${missionHeadline}${chainStatus?.status ? ` · chain 138 ${chainStatus.status}` : ''}`
|
||||
const primaryMetricCards = [
|
||||
const primaryMetricCards: Array<{ label: string; value: string; note: ReactNode }> = [
|
||||
{
|
||||
label: 'Latest Block',
|
||||
value: latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable',
|
||||
value: latestBlock != null ? formatInteger(latestBlock) : 'Unavailable',
|
||||
note: activityContext.latest_block_timestamp
|
||||
? compactStatNote(
|
||||
`Head freshness ${formatRelativeAge(activityContext.latest_block_timestamp)}${blockCompleteness ? ` · ${blockCompleteness}` : ''}`,
|
||||
formatRelativeAge(activityContext.latest_block_timestamp),
|
||||
<>
|
||||
Head freshness <ClientRelativeTime value={activityContext.latest_block_timestamp} suffix=" ago" />
|
||||
{blockCompleteness ? ` · ${blockCompleteness}` : ''}
|
||||
</>,
|
||||
<ClientRelativeTime value={activityContext.latest_block_timestamp} suffix=" ago" />,
|
||||
mode,
|
||||
)
|
||||
: compactStatNote('Head freshness unavailable.', 'Unavailable', mode),
|
||||
},
|
||||
{
|
||||
label: 'Total Blocks',
|
||||
value: stats ? stats.total_blocks.toLocaleString() : 'Unavailable',
|
||||
value: stats ? formatInteger(stats.total_blocks) : 'Unavailable',
|
||||
note: compactStatNote('Visible public explorer block count.', 'Explorer block count', mode),
|
||||
},
|
||||
{
|
||||
label: 'Total Transactions',
|
||||
value: stats ? stats.total_transactions.toLocaleString() : 'Unavailable',
|
||||
value: stats ? formatInteger(stats.total_transactions) : 'Unavailable',
|
||||
note: compactStatNote('Visible indexed explorer transaction count.', 'Indexed tx count', mode),
|
||||
},
|
||||
{
|
||||
label: 'Total Addresses',
|
||||
value: stats ? stats.total_addresses.toLocaleString() : 'Unavailable',
|
||||
value: stats ? formatInteger(stats.total_addresses) : 'Unavailable',
|
||||
note: compactStatNote('Current public explorer address count.', 'Address count', mode),
|
||||
},
|
||||
]
|
||||
@@ -506,7 +519,7 @@ export default function Home({
|
||||
note: compactStatNote(networkUtilizationSummary.note, networkUtilization != null ? 'Latest stats sample' : 'Unavailable', mode),
|
||||
},
|
||||
]
|
||||
const activityMetricCards = [
|
||||
const activityMetricCards: Array<{ label: string; value: string; note: ReactNode; detail: string }> = [
|
||||
{
|
||||
label: 'Latest Transaction',
|
||||
value: activityContext.latest_transaction_block_number != null ? `#${activityContext.latest_transaction_block_number}` : 'Unknown',
|
||||
@@ -516,7 +529,11 @@ export default function Home({
|
||||
{
|
||||
label: 'Last Non-Empty Block',
|
||||
value: activityContext.last_non_empty_block_number != null ? `#${activityContext.last_non_empty_block_number}` : 'Unknown',
|
||||
note: formatRelativeAge(activityContext.last_non_empty_block_timestamp),
|
||||
note: activityContext.last_non_empty_block_timestamp ? (
|
||||
<ClientRelativeTime value={activityContext.last_non_empty_block_timestamp} suffix=" ago" />
|
||||
) : (
|
||||
'Unknown'
|
||||
),
|
||||
detail:
|
||||
activityContext.latest_transaction_block_number != null &&
|
||||
activityContext.last_non_empty_block_number != null &&
|
||||
@@ -528,11 +545,11 @@ export default function Home({
|
||||
label: 'Block Gap',
|
||||
value:
|
||||
activityContext.block_gap_to_latest_transaction != null
|
||||
? activityContext.block_gap_to_latest_transaction.toLocaleString()
|
||||
? formatInteger(activityContext.block_gap_to_latest_transaction)
|
||||
: 'Unknown',
|
||||
note:
|
||||
activityContext.block_gap_to_latest_transaction != null
|
||||
? `${activityContext.block_gap_to_latest_transaction.toLocaleString()} blocks behind tip`
|
||||
? `${formatInteger(activityContext.block_gap_to_latest_transaction)} blocks behind tip`
|
||||
: 'Gap unavailable',
|
||||
detail: 'Difference between the current tip and the latest visible transaction block.',
|
||||
},
|
||||
@@ -548,8 +565,28 @@ export default function Home({
|
||||
}
|
||||
}, [relayPage, relayPageCount])
|
||||
|
||||
const visibleRecentTransactions = useMemo(
|
||||
() => recentTransactions.slice(0, HOME_VISIBLE_TRANSACTIONS),
|
||||
[recentTransactions],
|
||||
)
|
||||
const visibleRecentBlocks = useMemo(
|
||||
() => recentBlocks.slice(0, HOME_VISIBLE_BLOCKS),
|
||||
[recentBlocks],
|
||||
)
|
||||
const visibleFeaturedPrices = useMemo(
|
||||
() => featuredPrices.slice(0, HOME_PRICE_FEED_LIMIT),
|
||||
[featuredPrices],
|
||||
)
|
||||
const homeActivityTabs = useMemo<SectionTab<HomeActivityTab>[]>(
|
||||
() => [
|
||||
{ id: 'transactions', label: 'Transactions', count: visibleRecentTransactions.length },
|
||||
{ id: 'blocks', label: 'Blocks', count: visibleRecentBlocks.length },
|
||||
],
|
||||
[visibleRecentBlocks.length, visibleRecentTransactions.length],
|
||||
)
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<main className="container mx-auto px-4 py-4 sm:py-6">
|
||||
{(relaySummary || bridgeStatus) && (
|
||||
<Card
|
||||
className={`border shadow-sm ${relayToneClasses} ${missionExpanded ? 'mb-6' : 'mb-4 !p-2 sm:!p-2'}`}
|
||||
@@ -633,9 +670,15 @@ export default function Home({
|
||||
: 'Connecting'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm opacity-80">
|
||||
{`${statsGeneratedAt ? `Snapshot updated ${formatRelativeAge(statsGeneratedAt)}.` : `Snapshot updated ${snapshotAgeLabel}.`} ${
|
||||
missionMode?.reason ? missionMode.reason.replaceAll('_', ' ') : snapshotReason
|
||||
}`}
|
||||
Snapshot updated{' '}
|
||||
{statsGeneratedAt ? (
|
||||
<ClientRelativeTime value={statsGeneratedAt} suffix="." />
|
||||
) : checkedAt ? (
|
||||
<ClientRelativeTime value={checkedAt} suffix="." />
|
||||
) : (
|
||||
'Unknown.'
|
||||
)}{' '}
|
||||
{missionMode?.reason ? missionMode.reason.replaceAll('_', ' ') : snapshotReason}
|
||||
</div>
|
||||
<div className="mt-2 text-xs opacity-75">
|
||||
{snapshotScope}
|
||||
@@ -722,7 +765,15 @@ export default function Home({
|
||||
</div>
|
||||
<EntityBadge label={resolveRelaySeverityLabel(item.status, item.tone)} tone={resolveRelayBadgeTone(item.status, item.tone)} />
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 opacity-90">{item.text}</p>
|
||||
<p className="mt-3 text-sm leading-6 opacity-90">
|
||||
{item.text}
|
||||
{item.polledAt ? (
|
||||
<>
|
||||
{' '}
|
||||
· polled <ClientRelativeTime value={item.polledAt} suffix=" ago" />
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-2 text-xs opacity-75">
|
||||
{getLaneImpactNote(item.key, resolveRelaySeverityLabel(item.status, item.tone))}
|
||||
</p>
|
||||
@@ -776,7 +827,7 @@ export default function Home({
|
||||
|
||||
{(relaySummary || bridgeStatus || stats) && (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-gray-200 bg-white/80 px-4 py-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<EntityBadge label={`block ${latestBlock != null ? latestBlock.toLocaleString() : 'unknown'}`} tone="info" />
|
||||
<EntityBadge label={`block ${latestBlock != null ? formatInteger(latestBlock) : 'unknown'}`} tone="info" />
|
||||
{chainStatus?.status ? <EntityBadge label={`chain ${chainStatus.status}`} tone={chainStatus.status === 'operational' ? 'success' : 'warning'} /> : null}
|
||||
{relaySummary ? <EntityBadge label={`${relayOperationalCount} relays ok`} tone="success" /> : null}
|
||||
<EntityBadge label={relayFeedState === 'live' ? 'live feed' : relayFeedState === 'fallback' ? 'snapshot feed' : 'connecting'} tone={relayFeedState === 'live' ? 'success' : 'info'} />
|
||||
@@ -785,35 +836,27 @@ export default function Home({
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="mb-6 space-y-4">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">Network overview</p>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
|
||||
{primaryMetricCards.map((card) => (
|
||||
<Card key={card.label}>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
|
||||
</Card>
|
||||
<span key={card.label}>
|
||||
<span className="text-gray-500 dark:text-gray-400">{card.label}:</span>{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">{card.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MissionDeliveryModePanel className="mt-1" mode={missionMode} title="Homepage delivery mode" />
|
||||
<ActivityContextPanel
|
||||
context={activityContext}
|
||||
title="Freshness Interpretation"
|
||||
compact
|
||||
/>
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel={
|
||||
mode === 'guided'
|
||||
? 'Homepage status combines chain freshness, transaction visibility, and mission-control posture.'
|
||||
: 'Homepage freshness view aligns chain, transaction, and mission-control posture.'
|
||||
}
|
||||
/>
|
||||
<DisclosureSection title="Freshness & delivery" defaultOpen={false} alwaysCollapsible>
|
||||
<MissionDeliveryModePanel className="mt-1" mode={missionMode} title="Homepage delivery mode" />
|
||||
<ActivityContextPanel context={activityContext} title="Freshness interpretation" compact />
|
||||
<FreshnessTrustNote
|
||||
className="mt-2"
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel="Homepage chain, transaction, and mission-control posture."
|
||||
/>
|
||||
</DisclosureSection>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -873,168 +916,165 @@ export default function Home({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{featuredPrices.length > 0 ? (
|
||||
<div className="mb-8">
|
||||
<Card title="Live Price Feed">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{featuredPrices.map((token) => (
|
||||
<Link
|
||||
key={token.address}
|
||||
href={`/tokens/${token.address}`}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-800 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{visibleFeaturedPrices.length > 0 ? (
|
||||
<DisclosureSection title="Live price feed" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{visibleFeaturedPrices.map((token) => (
|
||||
<Link
|
||||
key={token.address}
|
||||
href={`/tokens/${token.address}`}
|
||||
className="rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2.5 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-800 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{token.symbol || token.name || 'Token'}
|
||||
</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{formatUsd(token.market?.priceUsd)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Visible liquidity: {formatUsd(token.market?.liquidityUsd)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{token.market?.lastUpdated ? `Updated ${formatRelativeAge(token.market.lastUpdated)}` : 'Update time unavailable'}
|
||||
</div>
|
||||
<MarketEvidenceNote lastUpdated={token.market?.lastUpdated} compact />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Liq {formatUsd(token.market?.liquidityUsd)}
|
||||
{token.market?.lastUpdated ? (
|
||||
<>
|
||||
{' '}
|
||||
· <ClientRelativeTime value={token.market.lastUpdated} suffix=" ago" />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</DisclosureSection>
|
||||
) : null}
|
||||
|
||||
{!stats && (
|
||||
<Card className="mb-8">
|
||||
<Card className="mb-4">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="mb-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card title="Recent Transactions">
|
||||
{recentTransactions.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Recent transactions are unavailable right now.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentTransactions.map((transaction) => (
|
||||
<div key={transaction.hash} 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">
|
||||
<div>
|
||||
<Link href={`/transactions/${transaction.hash}`} className="text-primary-600 hover:underline">
|
||||
{transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
|
||||
</Link>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Block #{transaction.block_number} · from{' '}
|
||||
<Address address={transaction.from_address} truncate showCopy={false} />
|
||||
{transaction.to_address ? (
|
||||
<>
|
||||
{' '}→ <Address address={transaction.to_address} truncate showCopy={false} />
|
||||
</>
|
||||
) : null}
|
||||
<div className="mb-4">
|
||||
<SectionTabs
|
||||
tabs={homeActivityTabs}
|
||||
activeTab={homeActivityTab}
|
||||
onChange={setHomeActivityTab}
|
||||
idPrefix="home-activity"
|
||||
ariaLabel="Recent chain activity"
|
||||
className="-mx-4 mb-3 px-4"
|
||||
/>
|
||||
|
||||
<TabPanel idPrefix="home-activity" tabId="transactions" activeTab={homeActivityTab}>
|
||||
<Card title="Recent transactions">
|
||||
{visibleRecentTransactions.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Recent transactions are unavailable right now.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{visibleRecentTransactions.map((transaction) => (
|
||||
<div key={transaction.hash} className="flex flex-col gap-1 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<Link href={`/transactions/${transaction.hash}`} className="text-primary-600 hover:underline">
|
||||
{transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
|
||||
</Link>
|
||||
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
Block #{transaction.block_number} · {formatWeiAsEth(transaction.value, 4)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 sm:text-right">
|
||||
{formatTimestamp(transaction.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
|
||||
<div>{formatWeiAsEth(transaction.value, 4)}</div>
|
||||
<div className="text-xs">{formatTimestamp(transaction.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
View all transactions →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Recent Blocks">
|
||||
{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">
|
||||
{shouldExplainEmptyHeadBlocks(recentBlocks, activityContext) ? (
|
||||
<p className="rounded-xl border border-amber-200 bg-amber-50/70 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
|
||||
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
|
||||
</p>
|
||||
) : null}
|
||||
{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">
|
||||
<div>
|
||||
<Link href={`/blocks/${block.number}`} className="text-primary-600 hover:underline">
|
||||
Block #{block.number}
|
||||
</Link>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Mined by{' '}
|
||||
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
|
||||
{block.miner.slice(0, 10)}...{block.miner.slice(-6)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
|
||||
<div>{block.transaction_count} transactions</div>
|
||||
<div className="text-xs">{formatTimestamp(block.timestamp)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Link href="/blocks" className="text-primary-600 hover:underline">
|
||||
View all blocks →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Link href="/transactions" className="text-sm text-primary-600 hover:underline">
|
||||
All transactions →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="home-activity" tabId="blocks" activeTab={homeActivityTab}>
|
||||
<Card title="Recent blocks">
|
||||
{visibleRecentBlocks.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">
|
||||
{shouldExplainEmptyHeadBlocks(visibleRecentBlocks, activityContext) ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
|
||||
Recent head blocks are empty; use the latest transaction block for visible activity.
|
||||
</p>
|
||||
) : null}
|
||||
{visibleRecentBlocks.map((block) => (
|
||||
<div key={block.number} className="flex flex-col gap-1 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<Link href={`/blocks/${block.number}`} className="text-primary-600 hover:underline">
|
||||
Block #{block.number}
|
||||
</Link>
|
||||
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{block.transaction_count} txs ·{' '}
|
||||
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
|
||||
{block.miner.slice(0, 8)}…{block.miner.slice(-4)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 sm:text-right">
|
||||
{formatTimestamp(block.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Link href="/blocks" className="text-sm text-primary-600 hover:underline">
|
||||
All blocks →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</div>
|
||||
|
||||
<Card title="Institutional quick paths" className="mt-8">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Canonical discovery, compliance, and liquidity surfaces for institutional users — mesh tokens, official
|
||||
protocols, curated pools, and public JSON APIs.
|
||||
<DisclosureSection title="Institutional quick paths" defaultOpen={false} alwaysCollapsible className="mt-4">
|
||||
<p className="mb-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Discovery, compliance, and liquidity surfaces for institutional users.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Link href="/search?q=cWUSDC" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Mesh search</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">c* ↔ cW* across chains</div>
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<Link href="/search?q=cWUSDC" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
|
||||
Mesh search
|
||||
</Link>
|
||||
<Link href="/protocols" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Official protocols</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Upstream contracts + guardrails</div>
|
||||
<Link href="/protocols" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
|
||||
Protocols
|
||||
</Link>
|
||||
<Link href="/pools" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Pool registry</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Live DODO PMM + UniV2 TVL</div>
|
||||
<Link href="/pools" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
|
||||
Pools
|
||||
</Link>
|
||||
<Link href="/wallet" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Wallet & MetaMask</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">13-chain token import</div>
|
||||
<Link href="/wallet" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
|
||||
Wallet
|
||||
</Link>
|
||||
<Link href="/liquidity" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Liquidity tools</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">LP policy — bridge underlying, not LP</div>
|
||||
<Link href="/liquidity" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
|
||||
Liquidity
|
||||
</Link>
|
||||
<Link href="/token-aggregation/api/v1/report/official-protocols" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Public JSON APIs</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Protocols, pools, routes</div>
|
||||
<Link href="/token-aggregation/api/v1/report/official-protocols" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
|
||||
Public APIs
|
||||
</Link>
|
||||
<Link href="/search" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Search</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Address, tx, mesh, pools</div>
|
||||
<Link href="/search" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
|
||||
Search
|
||||
</Link>
|
||||
<Link href="/docs" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Documentation</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">GRU, APIs, operations</div>
|
||||
<Link href="/docs" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
|
||||
Docs
|
||||
</Link>
|
||||
<Link href="/bridge" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Bridge</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Relay posture + CCIP lanes</div>
|
||||
<Link href="/bridge" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
|
||||
Bridge
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</DisclosureSection>
|
||||
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import SectionTabs, { TabPanel } from '@/components/common/SectionTabs'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
|
||||
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
|
||||
@@ -11,7 +13,7 @@ import {
|
||||
fetchPoolRegistry,
|
||||
type CuratedPoolRegistryEntry,
|
||||
} from '@/services/api/liquidityPositions'
|
||||
import { useUiMode } from '@/components/common/UiModeContext'
|
||||
import { formatBigIntWhole, formatTimestamp } from '@/utils/format'
|
||||
|
||||
function formatUsd(value: number | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return 'Unavailable'
|
||||
@@ -36,8 +38,8 @@ function formatReserve(raw: string | undefined, symbol: string): string {
|
||||
.slice(0, 4)
|
||||
.replace(/0+$/, '')
|
||||
return fractionText
|
||||
? `${whole.toLocaleString()}.${fractionText} ${symbol}`
|
||||
: `${whole.toLocaleString()} ${symbol}`
|
||||
? `${formatBigIntWhole(whole)}.${fractionText} ${symbol}`
|
||||
: `${formatBigIntWhole(whole)} ${symbol}`
|
||||
} catch {
|
||||
return 'Unavailable'
|
||||
}
|
||||
@@ -71,9 +73,9 @@ interface PoolDetailPageProps {
|
||||
}
|
||||
|
||||
export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
|
||||
const { mode } = useUiMode()
|
||||
const [pools, setPools] = useState<CuratedPoolRegistryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [poolTab, setPoolTab] = useState<'summary' | 'lp'>('summary')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
@@ -101,20 +103,16 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
|
||||
const asymmetryNote = pool ? stableReserveAsymmetryNote(pool) : null
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<OperationsSurfaceNav />
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Curated pool registry"
|
||||
title={loading ? 'Loading pool…' : pool ? pairLabel : 'Pool not in registry'}
|
||||
description={
|
||||
mode === 'guided'
|
||||
? 'Chain-local liquidity pool from the curated DODO PMM + UniV2 registry. LP receipt tokens are not bridgeable — bridge underlying c*/cW* instead.'
|
||||
: 'Curated pool detail · LP is chain-local only.'
|
||||
}
|
||||
description="DODO PMM + UniV2 registry · LP receipts are chain-local only."
|
||||
actions={[
|
||||
{ href: '/liquidity', label: 'Liquidity tools' },
|
||||
{ href: '/liquidity', label: 'Liquidity' },
|
||||
{ href: '/pools', label: 'All pools' },
|
||||
{ href: '/wallet', label: 'Scan LP positions' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -142,21 +140,36 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
|
||||
) : null}
|
||||
|
||||
{pool ? (
|
||||
<div className="space-y-6">
|
||||
<>
|
||||
{asymmetryNote ? (
|
||||
<Card title="Reserve asymmetry notice">
|
||||
<p
|
||||
className={
|
||||
asymmetryNote.severity === 'warning'
|
||||
? 'text-sm text-amber-800 dark:text-amber-200'
|
||||
: 'text-sm text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
>
|
||||
{asymmetryNote.message}
|
||||
</p>
|
||||
</Card>
|
||||
<DisclosureSection title="Reserve asymmetry notice" defaultOpen={false} alwaysCollapsible className="mb-4">
|
||||
<Card>
|
||||
<p
|
||||
className={
|
||||
asymmetryNote.severity === 'warning'
|
||||
? 'text-sm text-amber-800 dark:text-amber-200'
|
||||
: 'text-sm text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
>
|
||||
{asymmetryNote.message}
|
||||
</p>
|
||||
</Card>
|
||||
</DisclosureSection>
|
||||
) : null}
|
||||
|
||||
<SectionTabs
|
||||
idPrefix="pool-detail"
|
||||
ariaLabel="Pool detail sections"
|
||||
tabs={[
|
||||
{ id: 'summary', label: 'Summary' },
|
||||
{ id: 'lp', label: 'Your LP' },
|
||||
]}
|
||||
activeTab={poolTab}
|
||||
onChange={setPoolTab}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<TabPanel idPrefix="pool-detail" tabId="summary" activeTab={poolTab}>
|
||||
<Card title="Pool summary">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge label={pool.venue.replace('_', ' ')} tone="info" />
|
||||
@@ -229,7 +242,7 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
|
||||
{pool.liquiditySource ?? 'curated registry'}
|
||||
{pool.liquidityAsOf ? (
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
As of {new Date(pool.liquidityAsOf).toLocaleString()}
|
||||
As of {formatTimestamp(pool.liquidityAsOf)}
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
@@ -242,14 +255,17 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="pool-detail" tabId="lp" activeTab={poolTab}>
|
||||
<LpPositionPanel
|
||||
chainId={pool.chainId}
|
||||
title="Your LP in this pool"
|
||||
compact
|
||||
hintAddresses={[pool.poolAddress, pool.lpTokenAddress]}
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import ListFilterBar from '@/components/common/ListFilterBar'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import { selectWalletFeaturedTokens } from '@/utils/featuredTokens'
|
||||
@@ -157,6 +161,17 @@ interface AddToMetaMaskProps {
|
||||
initialCapabilitiesMeta?: FetchMetadata | null
|
||||
}
|
||||
|
||||
const FEATURED_TOKENS_PAGE_SIZE = 5
|
||||
|
||||
type AddToMetaMaskTab = 'networks' | 'tokens' | 'other-chains' | 'advanced'
|
||||
|
||||
const addToMetaMaskTabs: SectionTab<AddToMetaMaskTab>[] = [
|
||||
{ id: 'networks', label: 'Networks' },
|
||||
{ id: 'tokens', label: 'Tokens' },
|
||||
{ id: 'other-chains', label: 'Other chains' },
|
||||
{ id: 'advanced', label: 'API & RPC' },
|
||||
]
|
||||
|
||||
function chainParamsForWallet(chain: WalletChain) {
|
||||
return toWalletAddEthereumChainParams(chain, {
|
||||
preferSingleRpc: typeof window !== 'undefined' && isMobileWalletContext(),
|
||||
@@ -393,6 +408,9 @@ export function AddToMetaMask({
|
||||
const [fundedListingError, setFundedListingError] = useState<string | null>(null)
|
||||
const [pendingWatchFlow, setPendingWatchFlow] = useState<PendingWatchFlow | null>(null)
|
||||
const [providerTick, setProviderTick] = useState(0)
|
||||
const [activeTab, setActiveTab] = useState<AddToMetaMaskTab>('networks')
|
||||
const [tokenQuery, setTokenQuery] = useState('')
|
||||
const [tokenPage, setTokenPage] = useState(1)
|
||||
|
||||
const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext()
|
||||
|
||||
@@ -560,6 +578,35 @@ export function AddToMetaMask({
|
||||
[catalogTokens, curatedTokens],
|
||||
)
|
||||
|
||||
const filteredFeaturedTokens = useMemo(() => {
|
||||
const normalizedQuery = tokenQuery.trim().toLowerCase()
|
||||
if (!normalizedQuery) return featuredTokens
|
||||
return featuredTokens.filter((token) => {
|
||||
const haystack = [token.symbol, token.name, token.address, ...(token.tags || [])]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return haystack.includes(normalizedQuery)
|
||||
})
|
||||
}, [featuredTokens, tokenQuery])
|
||||
|
||||
const featuredTokenPageCount = Math.max(1, Math.ceil(filteredFeaturedTokens.length / FEATURED_TOKENS_PAGE_SIZE))
|
||||
|
||||
const paginatedFeaturedTokens = useMemo(() => {
|
||||
const safePage = Math.min(Math.max(tokenPage, 1), featuredTokenPageCount)
|
||||
const start = (safePage - 1) * FEATURED_TOKENS_PAGE_SIZE
|
||||
return filteredFeaturedTokens.slice(start, start + FEATURED_TOKENS_PAGE_SIZE)
|
||||
}, [filteredFeaturedTokens, featuredTokenPageCount, tokenPage])
|
||||
|
||||
useEffect(() => {
|
||||
setTokenPage(1)
|
||||
}, [tokenQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (tokenPage > featuredTokenPageCount) {
|
||||
setTokenPage(featuredTokenPageCount)
|
||||
}
|
||||
}, [featuredTokenPageCount, tokenPage])
|
||||
|
||||
const watchAssetTokens = useMemo(() => {
|
||||
const endpointTokens = dedupeWalletWatchTokens(
|
||||
(metamaskConfig?.watchAssets || [])
|
||||
@@ -964,214 +1011,96 @@ export function AddToMetaMask({
|
||||
: 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">
|
||||
<div className="space-y-3 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
|
||||
<h2 className="text-lg font-semibold">Add to MetaMask</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
The wallet tools now read the same explorer-served network catalog and token list that MetaMask can consume.
|
||||
That keeps chain metadata, token metadata, and optional extensions aligned with the live explorer API instead of
|
||||
relying on stale frontend-only defaults. MetaMask does not run built-in token detection on custom networks such
|
||||
as Chain 138, so this page uses EIP-747 wallet_watchAsset prompts from the live MetaMask payload to add token
|
||||
metadata directly to the wallet.
|
||||
Add Chain 138 and token metadata from explorer-served catalogs via EIP-747 wallet prompts.
|
||||
</p>
|
||||
|
||||
<MobileWalletContextBanner hasProvider={hasWalletProvider} />
|
||||
<SectionTabs
|
||||
tabs={addToMetaMaskTabs.map((tab) =>
|
||||
tab.id === 'tokens' ? { ...tab, count: featuredTokens.length } : tab,
|
||||
)}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
idPrefix="add-to-metamask"
|
||||
ariaLabel="Wallet setup sections"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void switchOrAddChain(chains.chain138).then((ok) => {
|
||||
if (ok) setStatus('Switched to DeFi Oracle Meta Mainnet. Native ETH and imported tokens show on this network.')
|
||||
})
|
||||
}
|
||||
className={`rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Switch to Chain 138
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(chains.chain138)}
|
||||
className={`rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add Chain 138
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(chains.ethereum)}
|
||||
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add Ethereum Mainnet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(chains.allMainnet)}
|
||||
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add ALL Mainnet
|
||||
</button>
|
||||
</div>
|
||||
<TabPanel idPrefix="add-to-metamask" tabId="networks" activeTab={activeTab}>
|
||||
<MobileWalletContextBanner hasProvider={hasWalletProvider} />
|
||||
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50/50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Optional Chain 138 Open Snap</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
This is <span className="font-medium text-gray-800 dark:text-gray-200">not required</span> for the production
|
||||
wallet flow above. The normal production path is to add Chain 138, then add tokens through EIP-747
|
||||
wallet_watchAsset prompts. The optional Snap uses{' '}
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">only open Snap permissions</span> (minimal
|
||||
privileged APIs in the Snap itself).{' '}
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">Stable MetaMask</span> still only installs npm
|
||||
Snaps that appear on MetaMask's install allowlist; if install fails with "not on the allowlist", that is
|
||||
an external MetaMask review gate rather than an explorer/network failure. Use{' '}
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">MetaMask Flask</span> for development or apply
|
||||
for allowlisting before using this with Stable MetaMask. The package on npm is{' '}
|
||||
<code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">{CHAIN138_OPEN_SNAP_ID}</code>
|
||||
— publish from the repo with <code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">scripts/deployment/publish-chain138-open-snap.sh</code> after{' '}
|
||||
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">npm login</code>.
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void switchOrAddChain(chains.chain138).then((ok) => {
|
||||
if (ok) setStatus('Switched to DeFi Oracle Meta Mainnet. Native ETH and imported tokens show on this network.')
|
||||
})
|
||||
}
|
||||
className={`rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Switch to Chain 138
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(chains.chain138)}
|
||||
className={`rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add Chain 138
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(chains.ethereum)}
|
||||
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add Ethereum Mainnet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(chains.allMainnet)}
|
||||
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add ALL Mainnet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DisclosureSection title="Optional Chain 138 Open Snap" alwaysCollapsible className="mt-3">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Not required for production wallet setup. Use MetaMask Flask or an allowlisted Stable build. Package:{' '}
|
||||
<code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">{CHAIN138_OPEN_SNAP_ID}</code>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void installOpenSnap()}
|
||||
className="mt-3 rounded bg-amber-700 px-4 py-2 text-sm font-medium text-white hover:bg-amber-800"
|
||||
>
|
||||
Install Snap (Flask or allowlisted Stable)
|
||||
</button>
|
||||
</DisclosureSection>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="add-to-metamask" tabId="other-chains" activeTab={activeTab}>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Import canonical tokens on other supported networks. Each chain switches your wallet first, then runs sequential
|
||||
EIP-747 prompts.
|
||||
{mobileWalletContext ? ' On mobile, use Continue between prompt batches.' : null}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void installOpenSnap()}
|
||||
className="mt-3 rounded bg-amber-700 px-4 py-2 text-sm font-medium text-white hover:bg-amber-800"
|
||||
>
|
||||
Install Snap (Flask or allowlisted Stable)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Explorer-served MetaMask metadata</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}</p>
|
||||
<p>Chain 138 token entries: {tokenCount138}</p>
|
||||
<p>EIP-747 watchAsset entries: {watchAssetTokens.length}</p>
|
||||
<p>Networks source: {networksMeta?.source || 'unknown'}</p>
|
||||
<p>Token list source: {tokenListMeta?.source || 'unknown'}</p>
|
||||
<p>MetaMask payload source: {metamaskConfigMeta?.source || 'unknown'}</p>
|
||||
{metadataKeywordString ? <p>Keywords: {metadataKeywordString}</p> : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Networks config URL</p>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{networksUrl}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => copyText(networksUrl, 'networks config 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={networksUrl} 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>
|
||||
</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">{displayedCapabilitiesUrl}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<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={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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">EIP-747 MetaMask payload URL</p>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{metamaskConfigUrl}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => copyText(metamaskConfigUrl, 'MetaMask payload 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={metamaskConfigUrl} 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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token list URL</p>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{tokenListUrl}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => copyText(tokenListUrl, 'token list 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={tokenListUrl} 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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<MultiChainWalletImport />
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="add-to-metamask" tabId="tokens" activeTab={activeTab}>
|
||||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Chain 138 RPC capabilities</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
This capability matrix documents what public RPC methods wallets can rely on today, what tracing the explorer
|
||||
can use, and where MetaMask will still fall back because the public node does not expose every optional fee method.
|
||||
</p>
|
||||
<div className="mt-4 space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
RPC endpoint:{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{capabilities?.rpcUrl || 'using published explorer fallback'}
|
||||
</span>
|
||||
<DisclosureSection title="Import help" alwaysCollapsible className="mb-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Bulk add switches to DeFi Oracle Meta Mainnet first. Use{' '}
|
||||
<span className="font-medium">Add tokens with balance only</span> to skip zero-balance catalog entries.
|
||||
MetaMask keeps one row per token symbol on a network.
|
||||
</p>
|
||||
<p>
|
||||
Capabilities source:{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{capabilitiesMeta?.source || 'unknown'}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
HTTP methods: {supportedHTTPMethods.length > 0 ? supportedHTTPMethods.join(', ') : 'metadata unavailable'}
|
||||
</p>
|
||||
<p>
|
||||
Missing wallet-facing methods:{' '}
|
||||
{unsupportedHTTPMethods.length > 0 ? unsupportedHTTPMethods.join(', ') : 'none listed'}
|
||||
</p>
|
||||
<p>
|
||||
Trace methods: {supportedTraceMethods.length > 0 ? supportedTraceMethods.join(', ') : 'metadata unavailable'}
|
||||
</p>
|
||||
{capabilities?.walletSupport?.notes?.map((note) => (
|
||||
<p key={note} className="text-xs">
|
||||
{note}
|
||||
</p>
|
||||
))}
|
||||
{capabilities?.http?.notes?.map((note) => (
|
||||
<p key={note} className="text-xs">
|
||||
{note}
|
||||
</p>
|
||||
))}
|
||||
{capabilitiesMeta?.lastModified ? (
|
||||
<p className="text-xs">
|
||||
Last modified: {formatStableTimestamp(capabilitiesMeta.lastModified)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Featured Chain 138 tokens</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
These tokens come from the explorer MetaMask payload and use wallet_watchAsset so the wallet gets the same
|
||||
symbol, decimals, image, and optional token metadata that the explorer publishes. MetaMask requires a user
|
||||
approval for each token, so the bulk actions below run as a guided sequence of wallet prompts. Bulk add
|
||||
switches to DeFi Oracle Meta Mainnet first — tokens imported while on another network will not show
|
||||
balances on Chain 138. Native ETH is not added via this flow; it appears when you are on Chain 138 with a
|
||||
working RPC. Use <span className="font-medium">Add tokens with balance only</span> to skip zero-balance and
|
||||
undeployed catalog entries.
|
||||
{mobileWalletContext
|
||||
? ' On mobile, imports run two wallet prompts per tap — use Continue until finished.'
|
||||
: null}
|
||||
</p>
|
||||
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50/80 p-3 text-sm leading-6 text-amber-950 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
|
||||
MetaMask keeps <span className="font-medium">one row per token symbol</span> on a network. If a balance
|
||||
disappears after import, remove duplicate custom tokens (especially second <code className="text-xs">cUSDC</code>{' '}
|
||||
/ <code className="text-xs">cUSDT</code> rows or old gas placeholders like{' '}
|
||||
{CHAIN138_PLACEHOLDER_GAS_SYMBOLS.slice(0, 3).join(', ')}), stay on{' '}
|
||||
<span className="font-medium">DeFi Oracle Meta Mainnet</span>, then tap{' '}
|
||||
<span className="font-medium">Add tokens with balance only</span> or{' '}
|
||||
<span className="font-medium">Add featured tokens</span>.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
</DisclosureSection>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pendingWatchFlow ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1231,74 +1160,193 @@ export function AddToMetaMask({
|
||||
loading={fundedListingLoading}
|
||||
error={fundedListingError}
|
||||
/>
|
||||
<ListFilterBar
|
||||
className="mt-4"
|
||||
query={tokenQuery}
|
||||
onQueryChange={setTokenQuery}
|
||||
placeholder="Filter featured tokens by symbol, name, or address"
|
||||
resultCount={filteredFeaturedTokens.length}
|
||||
totalCount={featuredTokens.length}
|
||||
/>
|
||||
<div className="mt-4 space-y-3">
|
||||
{featuredTokens.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Featured token metadata is not available right now.</p>
|
||||
) : featuredTokens.map((token) => (
|
||||
<div key={token.address} className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{token.symbol} <span className="font-normal text-gray-500 dark:text-gray-400">({token.name})</span>
|
||||
) : filteredFeaturedTokens.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No featured tokens match your filter.</p>
|
||||
) : (
|
||||
paginatedFeaturedTokens.map((token) => (
|
||||
<div key={token.address} className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{token.symbol} <span className="font-normal text-gray-500 dark:text-gray-400">({token.name})</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{token.address}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Decimals: {token.decimals}
|
||||
{token.tags?.length ? ` • Tags: ${token.tags.join(', ')}` : ''}
|
||||
</div>
|
||||
{typeof token.extensions?.unitDescription === 'string' ? (
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{token.extensions.unitDescription}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{token.address}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Decimals: {token.decimals}
|
||||
{token.tags?.length ? ` • Tags: ${token.tags.join(', ')}` : ''}
|
||||
</div>
|
||||
{typeof token.extensions?.unitDescription === 'string' ? (
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{token.extensions.unitDescription}</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => watchToken(token)}
|
||||
className={`rounded bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add {token.symbol}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => watchToken(token)}
|
||||
className={`rounded bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add {token.symbol}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<PaginationControls
|
||||
page={tokenPage}
|
||||
pageCount={featuredTokenPageCount}
|
||||
onPageChange={setTokenPage}
|
||||
label="Featured tokens"
|
||||
ariaLabel="Featured token pagination"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Ethereum Mainnet cWUSDC</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
This refreshes the Mainnet cWUSDC custom asset metadata with the DBIS-hosted image URL. MetaMask fiat price
|
||||
display still depends on MetaMask and upstream asset/price providers accepting the Mainnet listing.
|
||||
</p>
|
||||
<div className="mt-4 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<DisclosureSection title="Ethereum Mainnet cWUSDC" alwaysCollapsible className="mt-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{MAINNET_CWUSDC_TOKEN.symbol}{' '}
|
||||
<span className="font-normal text-gray-500 dark:text-gray-400">({MAINNET_CWUSDC_TOKEN.name})</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{MAINNET_CWUSDC_TOKEN.address}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshMainnetCwusdc()}
|
||||
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Refresh Mainnet cWUSDC
|
||||
</button>
|
||||
</div>
|
||||
</DisclosureSection>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="add-to-metamask" tabId="advanced" activeTab={activeTab}>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Explorer-served MetaMask metadata</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}</p>
|
||||
<p>Chain 138 token entries: {tokenCount138}</p>
|
||||
<p>EIP-747 watchAsset entries: {watchAssetTokens.length}</p>
|
||||
<p>Networks source: {networksMeta?.source || 'unknown'}</p>
|
||||
<p>Token list source: {tokenListMeta?.source || 'unknown'}</p>
|
||||
<p>MetaMask payload source: {metamaskConfigMeta?.source || 'unknown'}</p>
|
||||
{metadataKeywordString ? <p>Keywords: {metadataKeywordString}</p> : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{MAINNET_CWUSDC_TOKEN.symbol}{' '}
|
||||
<span className="font-normal text-gray-500 dark:text-gray-400">({MAINNET_CWUSDC_TOKEN.name})</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{MAINNET_CWUSDC_TOKEN.address}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Ethereum Mainnet • Decimals: {MAINNET_CWUSDC_TOKEN.decimals}
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Networks config URL</p>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{networksUrl}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => copyText(networksUrl, 'networks config 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={networksUrl} 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>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshMainnetCwusdc()}
|
||||
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Refresh Mainnet cWUSDC
|
||||
</button>
|
||||
<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">{displayedCapabilitiesUrl}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<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={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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">EIP-747 MetaMask payload URL</p>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{metamaskConfigUrl}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => copyText(metamaskConfigUrl, 'MetaMask payload 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={metamaskConfigUrl} 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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token list URL</p>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{tokenListUrl}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => copyText(tokenListUrl, 'token list 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={tokenListUrl} 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Chain 138 RPC capabilities</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
This capability matrix documents what public RPC methods wallets can rely on today, what tracing the explorer
|
||||
can use, and where MetaMask will still fall back because the public node does not expose every optional fee method.
|
||||
</p>
|
||||
<div className="mt-4 space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
RPC endpoint:{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{capabilities?.rpcUrl || 'using published explorer fallback'}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
Capabilities source:{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{capabilitiesMeta?.source || 'unknown'}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
HTTP methods: {supportedHTTPMethods.length > 0 ? supportedHTTPMethods.join(', ') : 'metadata unavailable'}
|
||||
</p>
|
||||
<p>
|
||||
Missing wallet-facing methods:{' '}
|
||||
{unsupportedHTTPMethods.length > 0 ? unsupportedHTTPMethods.join(', ') : 'none listed'}
|
||||
</p>
|
||||
<p>
|
||||
Trace methods: {supportedTraceMethods.length > 0 ? supportedTraceMethods.join(', ') : 'metadata unavailable'}
|
||||
</p>
|
||||
{capabilities?.walletSupport?.notes?.map((note) => (
|
||||
<p key={note} className="text-xs">
|
||||
{note}
|
||||
</p>
|
||||
))}
|
||||
{capabilities?.http?.notes?.map((note) => (
|
||||
<p key={note} className="text-xs">
|
||||
{note}
|
||||
</p>
|
||||
))}
|
||||
{capabilitiesMeta?.lastModified ? (
|
||||
<p className="text-xs">
|
||||
Last modified: {formatStableTimestamp(capabilitiesMeta.lastModified)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
|
||||
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
|
||||
|
||||
<MultiChainWalletImport />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
201
frontend/src/components/wallet/AddTokenToWalletButton.tsx
Normal file
201
frontend/src/components/wallet/AddTokenToWalletButton.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { WalletChain } from '@/components/wallet/AddToMetaMask'
|
||||
import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient'
|
||||
import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain'
|
||||
import {
|
||||
isMobileWalletContext,
|
||||
MOBILE_WALLET_BUTTON_CLASS,
|
||||
resolveWalletEthereumProvider,
|
||||
} from '@/utils/walletProviderEnv'
|
||||
import { isWalletWatchEligibleAddress } from '@/utils/walletWatchEligible'
|
||||
import { watchTokenInWallet } from '@/utils/walletWatchToken'
|
||||
|
||||
const FALLBACK_CHAIN_138: WalletChain = {
|
||||
chainId: '0x8a',
|
||||
chainIdDecimal: 138,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org'],
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'],
|
||||
iconUrls: [
|
||||
'https://explorer.d-bis.org/api/v1/report/logo/chain-138',
|
||||
'https://explorer.d-bis.org/token-icons/chain-138.png',
|
||||
'https://explorer.d-bis.org/favicon.ico',
|
||||
],
|
||||
}
|
||||
|
||||
type AddTokenToWalletButtonProps = {
|
||||
address: string
|
||||
symbol: string
|
||||
decimals: number
|
||||
name?: string
|
||||
logoURI?: string
|
||||
chainId?: number
|
||||
variant?: 'primary' | 'secondary'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function AddTokenToWalletButton({
|
||||
address,
|
||||
symbol,
|
||||
decimals,
|
||||
name,
|
||||
logoURI,
|
||||
chainId = 138,
|
||||
variant = 'primary',
|
||||
className = '',
|
||||
}: AddTokenToWalletButtonProps) {
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [chainNetwork, setChainNetwork] = useState<WalletChain | null>(null)
|
||||
const [providerTick, setProviderTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
const refresh = () => setProviderTick((value) => value + 1)
|
||||
window.addEventListener('ethereum#initialized', refresh)
|
||||
window.addEventListener('focus', refresh)
|
||||
return () => {
|
||||
window.removeEventListener('ethereum#initialized', refresh)
|
||||
window.removeEventListener('focus', refresh)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const apiBase = window.location.origin.replace(/\/+$/, '')
|
||||
void fetch(`${apiBase}/api/v1/config/metamask?chainId=${chainId}`, { cache: 'no-store' })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((payload) => {
|
||||
if (!active) return
|
||||
if (payload?.addEthereumChain) {
|
||||
setChainNetwork(payload.addEthereumChain as WalletChain)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setChainNetwork(null)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [chainId])
|
||||
|
||||
const network = useMemo(() => {
|
||||
if (chainId === 138) return chainNetwork || FALLBACK_CHAIN_138
|
||||
return chainNetwork
|
||||
}, [chainId, chainNetwork])
|
||||
|
||||
const resolveEthereum = () => {
|
||||
void providerTick
|
||||
return resolveWalletEthereumProvider(getActiveWalletConnectProvider())
|
||||
}
|
||||
|
||||
const addChainOnly = async () => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
if (!network) {
|
||||
setError('Network metadata is unavailable right now.')
|
||||
return
|
||||
}
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('No wallet provider found. Connect MetaMask, open this page in a wallet browser, or use WalletConnect on /wallet.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ethereum.request({
|
||||
method: 'wallet_addEthereumChain',
|
||||
params: [
|
||||
toWalletAddEthereumChainParams(network, {
|
||||
preferSingleRpc: isMobileWalletContext(),
|
||||
}),
|
||||
],
|
||||
})
|
||||
setStatus(`Added ${network.chainName}.`)
|
||||
} catch (e) {
|
||||
const err = e as { code?: number; message?: string }
|
||||
if (err.code === 4902) {
|
||||
setStatus(`Added ${network.chainName}.`)
|
||||
return
|
||||
}
|
||||
setError(err.message || `Failed to add ${network.chainName}.`)
|
||||
}
|
||||
}
|
||||
|
||||
const addToken = async () => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
|
||||
if (!isWalletWatchEligibleAddress(address)) {
|
||||
setError(`${symbol} uses a roadmap placeholder address on Chain 138. Use a live canonical token from the token index instead.`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!network) {
|
||||
setError('Network metadata is unavailable right now.')
|
||||
return
|
||||
}
|
||||
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('No wallet provider found. Connect MetaMask, open this page in a wallet browser, or use WalletConnect on /wallet.')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await watchTokenInWallet(
|
||||
ethereum,
|
||||
{
|
||||
chainId,
|
||||
address,
|
||||
symbol,
|
||||
decimals,
|
||||
logoURI,
|
||||
},
|
||||
network,
|
||||
)
|
||||
|
||||
if (!result.ok) {
|
||||
setError(result.error || 'Could not add token to wallet.')
|
||||
return
|
||||
}
|
||||
|
||||
setStatus(
|
||||
result.added
|
||||
? `Added ${symbol} on ${network.chainName}. Stay on that network to see balances.`
|
||||
: `${symbol} request was dismissed.`,
|
||||
)
|
||||
}
|
||||
|
||||
const buttonClass =
|
||||
variant === 'primary'
|
||||
? 'rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50'
|
||||
: 'rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-100 dark:hover:bg-gray-900'
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{chainId === 138 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void addChainOnly()}
|
||||
className={`${buttonClass} ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add Chain 138
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void addToken()}
|
||||
className={`${buttonClass} ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
aria-label={name ? `Add ${name} to wallet` : `Add ${symbol} to wallet`}
|
||||
>
|
||||
Add {symbol} to wallet
|
||||
</button>
|
||||
</div>
|
||||
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
|
||||
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import ListFilterBar from '@/components/common/ListFilterBar'
|
||||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||||
import type { TokenListToken, WalletChain } from '@/components/wallet/AddToMetaMask'
|
||||
import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient'
|
||||
@@ -85,12 +87,16 @@ function chainParamsForWallet(chain: WalletChain) {
|
||||
})
|
||||
}
|
||||
|
||||
const PRIMARY_IMPORT_CHAIN_IDS = new Set([138, 1])
|
||||
|
||||
export default function MultiChainWalletImport() {
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState<{ current: number; total: number; chainId: number } | null>(null)
|
||||
const [chains, setChains] = useState<ChainImportState[]>([])
|
||||
const [pendingFlow, setPendingFlow] = useState<PendingMultiChainFlow | null>(null)
|
||||
const [chainQuery, setChainQuery] = useState('')
|
||||
const [showAllChains, setShowAllChains] = useState(false)
|
||||
const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext()
|
||||
|
||||
const resolveEthereum = () => resolveWalletEthereumProvider(getActiveWalletConnectProvider())
|
||||
@@ -157,6 +163,22 @@ export default function MultiChainWalletImport() {
|
||||
)
|
||||
}, [chains])
|
||||
|
||||
const filteredChains = useMemo(() => {
|
||||
const normalizedQuery = chainQuery.trim().toLowerCase()
|
||||
if (!normalizedQuery) return chains
|
||||
return chains.filter((row) => {
|
||||
const label = chainLabel(row.chainId).toLowerCase()
|
||||
return label.includes(normalizedQuery) || String(row.chainId).includes(normalizedQuery)
|
||||
})
|
||||
}, [chainQuery, chains])
|
||||
|
||||
const visibleChains = useMemo(() => {
|
||||
if (chainQuery.trim() || showAllChains) return filteredChains
|
||||
return filteredChains.filter((row) => PRIMARY_IMPORT_CHAIN_IDS.has(row.chainId))
|
||||
}, [chainQuery, filteredChains, showAllChains])
|
||||
|
||||
const hiddenChainCount = Math.max(0, filteredChains.length - visibleChains.length)
|
||||
|
||||
const switchOrAddChain = async (network: WalletChain) => {
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
@@ -258,39 +280,46 @@ export default function MultiChainWalletImport() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
|
||||
<h2 className="text-lg font-semibold">Multi-chain token import</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Add canonical tokens on other supported networks. Each chain switches your wallet first, then runs sequential
|
||||
EIP-747 <code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">wallet_watchAsset</code> prompts.
|
||||
{mobileWalletContext ? ' On mobile, two prompts run per tap — use Continue on the chain card.' : null}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<ListFilterBar
|
||||
query={chainQuery}
|
||||
onQueryChange={setChainQuery}
|
||||
placeholder="Filter chains by name or chain ID"
|
||||
resultCount={visibleChains.length}
|
||||
totalCount={chains.length}
|
||||
/>
|
||||
|
||||
{pendingFlow ? (
|
||||
{!chainQuery.trim() && !showAllChains && hiddenChainCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void continuePendingFlow()}
|
||||
className={`rounded bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
onClick={() => setShowAllChains(true)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-200"
|
||||
>
|
||||
Continue {chainLabel(pendingFlow.chainId)} ({pendingFlow.totalAdded} added, {pendingFlow.tokens.length - pendingFlow.nextIndex} left)
|
||||
Show {hiddenChainCount} more chain{hiddenChainCount === 1 ? '' : 's'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{chains.map((row) => {
|
||||
<div className="space-y-2">
|
||||
{visibleChains.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No chains match your filter.</p>
|
||||
) : (
|
||||
visibleChains.map((row) => {
|
||||
const featured = featuredByChain.get(row.chainId) ?? []
|
||||
return (
|
||||
<div key={row.chainId} className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{chainLabel(row.chainId)}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{row.loading
|
||||
? 'Loading token metadata…'
|
||||
: row.error
|
||||
? row.error
|
||||
: `${row.tokens.length} canonical tokens · ${featured.length} featured`}
|
||||
</div>
|
||||
<DisclosureSection
|
||||
key={row.chainId}
|
||||
title={`${chainLabel(row.chainId)} · ${row.loading ? '…' : row.tokens.length} tokens`}
|
||||
defaultOpen={row.chainId === 138}
|
||||
alwaysCollapsible
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{row.loading
|
||||
? 'Loading token metadata…'
|
||||
: row.error
|
||||
? row.error
|
||||
: `${featured.length} featured · ${row.tokens.length} canonical`}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -320,15 +349,26 @@ export default function MultiChainWalletImport() {
|
||||
</div>
|
||||
</div>
|
||||
{progress?.chainId === row.chainId ? (
|
||||
<div className="mt-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
Prompt {progress.current} of {progress.total}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DisclosureSection>
|
||||
)
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingFlow ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void continuePendingFlow()}
|
||||
className={`rounded bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Continue {chainLabel(pendingFlow.chainId)} ({pendingFlow.totalAdded} added, {pendingFlow.tokens.length - pendingFlow.nextIndex} left)
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
|
||||
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import WalletConnectPostureNote from '@/components/wallet/WalletConnectPostureNo
|
||||
import { connectAndAuthenticateWalletConnect, getActiveWalletConnectSessionId, isWalletConnectClientReady, loadWalletConnectConfig } from '@/services/wallet/walletConnectClient'
|
||||
import { registerWalletConnectSession } from '@/services/api/walletConnect'
|
||||
import Link from 'next/link'
|
||||
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
||||
import { useUiMode } from '@/components/common/UiModeContext'
|
||||
import { accessApi, type WalletAccessSession } from '@/services/api/access'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import {
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
} from '@/services/api/addresses'
|
||||
import { WALLET_SNAPSHOT_REFRESH_MS } from '@/utils/featuredTokens'
|
||||
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
|
||||
import { formatRelativeAge, formatTokenAmount } from '@/utils/format'
|
||||
import { formatInteger, formatTimestamp, formatTokenAmount } from '@/utils/format'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import {
|
||||
isWatchlistEntry,
|
||||
readWatchlistFromStorage,
|
||||
@@ -31,6 +32,10 @@ import {
|
||||
writeWatchlistToStorage,
|
||||
} from '@/utils/watchlist'
|
||||
import { MOBILE_WALLET_BUTTON_CLASS } from '@/utils/walletProviderEnv'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
|
||||
interface WalletPageProps {
|
||||
initialNetworks?: NetworksCatalog | null
|
||||
@@ -47,6 +52,8 @@ function shortAddress(value?: string | null): string {
|
||||
return `${value.slice(0, 6)}...${value.slice(-4)}`
|
||||
}
|
||||
|
||||
type WalletPageTab = 'connect' | 'snapshot' | 'setup'
|
||||
|
||||
export default function WalletPage(props: WalletPageProps) {
|
||||
const { mode } = useUiMode()
|
||||
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
|
||||
@@ -60,6 +67,30 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
const [recentAddressTransactions, setRecentAddressTransactions] = useState<TransactionSummary[]>([])
|
||||
const [tokenBalances, setTokenBalances] = useState<AddressTokenBalance[]>([])
|
||||
const [tokenTransfers, setTokenTransfers] = useState<AddressTokenTransfer[]>([])
|
||||
const [activeTab, setActiveTab] = useState<WalletPageTab>('connect')
|
||||
|
||||
const walletTabs = useMemo<SectionTab<WalletPageTab>[]>(() => {
|
||||
const tabs: SectionTab<WalletPageTab>[] = [
|
||||
{ id: 'connect', label: 'Connect' },
|
||||
{ id: 'setup', label: 'Wallet setup' },
|
||||
]
|
||||
if (walletSession) {
|
||||
tabs.splice(1, 0, { id: 'snapshot', label: 'My wallet' })
|
||||
}
|
||||
return tabs
|
||||
}, [walletSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'snapshot' && !walletSession) {
|
||||
setActiveTab('connect')
|
||||
}
|
||||
}, [activeTab, walletSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (walletSession?.address) {
|
||||
setActiveTab((current) => (current === 'connect' ? 'snapshot' : current))
|
||||
}
|
||||
}, [walletSession?.address])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
@@ -154,7 +185,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
addressesApi.getSafe(138, address),
|
||||
addressesApi.getTransactionsSafe(138, address, 1, 3),
|
||||
addressesApi.getTokenBalancesSafe(address),
|
||||
addressesApi.getTokenTransfersSafe(address, 1, 4),
|
||||
addressesApi.getTokenTransfersSafe(address, 1, 2),
|
||||
])
|
||||
|
||||
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
|
||||
@@ -176,7 +207,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
.slice(0, 4)
|
||||
.slice(0, 3)
|
||||
: [],
|
||||
)
|
||||
setTokenTransfers(transfersResponse.ok ? transfersResponse.data : [])
|
||||
@@ -222,15 +253,36 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
}, [loadWalletSnapshot, walletSession?.address])
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-4 text-2xl font-bold sm:text-3xl">Wallet Tools</h1>
|
||||
<p className="mb-6 text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base">
|
||||
{mode === 'guided'
|
||||
? 'Use the explorer-served network catalog, token list, and capability metadata to connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets.'
|
||||
: 'Use explorer-served network and token metadata to connect Chain 138 and Ethereum Mainnet wallets.'}
|
||||
</p>
|
||||
<WalletConnectPostureNote />
|
||||
<div className="mb-6 rounded-2xl border border-sky-200 bg-sky-50/60 p-5 dark:border-sky-900/40 dark:bg-sky-950/20">
|
||||
<main className="container mx-auto px-4 py-5 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Chain 138"
|
||||
title="Wallet Tools"
|
||||
description={
|
||||
mode === 'guided'
|
||||
? 'Connect Chain 138 and Ethereum Mainnet, import tokens, and review your connected wallet from one place.'
|
||||
: 'Explorer-served network and token metadata for Chain 138 wallet setup.'
|
||||
}
|
||||
actions={[
|
||||
{ href: '/liquidity', label: 'Liquidity' },
|
||||
{ href: '/routes', label: 'Routes' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SectionTabs
|
||||
tabs={walletTabs}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
idPrefix="wallet-page"
|
||||
ariaLabel="Wallet tools sections"
|
||||
className="-mx-4 mb-4 px-4"
|
||||
/>
|
||||
|
||||
<TabPanel idPrefix="wallet-page" tabId="connect" activeTab={activeTab}>
|
||||
<DisclosureSection title="WalletConnect status" alwaysCollapsible className="mb-3">
|
||||
<WalletConnectPostureNote />
|
||||
</DisclosureSection>
|
||||
<div className="rounded-2xl border border-sky-200 bg-sky-50/60 p-4 dark:border-sky-900/40 dark:bg-sky-950/20">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">Wallet session</div>
|
||||
@@ -261,7 +313,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
</div>
|
||||
{walletSession?.expiresAt ? (
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Session expires {new Date(walletSession.expiresAt).toLocaleString()}
|
||||
Session expires {formatTimestamp(walletSession.expiresAt)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -355,9 +407,12 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="wallet-page" tabId="snapshot" activeTab={activeTab}>
|
||||
{walletSession ? (
|
||||
<div className="mt-4 rounded-2xl border border-white/60 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
<div className="rounded-2xl border border-sky-200 bg-sky-50/60 p-5 dark:border-sky-900/40 dark:bg-sky-950/20">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
@@ -374,32 +429,15 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Transactions</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{addressInfo ? addressInfo.transaction_count.toLocaleString() : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token Holdings</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{addressInfo ? addressInfo.token_count.toLocaleString() : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Address Type</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{addressInfo ? (addressInfo.is_contract ? 'Contract' : 'EOA') : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent Indexed Tx</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{recentAddressTransactions[0] ? `#${recentAddressTransactions[0].block_number}` : 'None visible'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CompactMetricBar
|
||||
className="mt-4"
|
||||
metrics={[
|
||||
{ label: 'Tx', value: addressInfo ? formatInteger(addressInfo.transaction_count) : '—' },
|
||||
{ label: 'Tokens', value: addressInfo ? formatInteger(addressInfo.token_count) : '—' },
|
||||
{ label: 'Type', value: addressInfo ? (addressInfo.is_contract ? 'Contract' : 'EOA') : '—' },
|
||||
{ label: 'Latest block', value: recentAddressTransactions[0] ? `#${recentAddressTransactions[0].block_number}` : '—' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-3">
|
||||
{recentAddressTransactions.length === 0 ? (
|
||||
@@ -407,7 +445,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
No recent indexed transactions are currently visible for this connected wallet.
|
||||
</div>
|
||||
) : (
|
||||
recentAddressTransactions.map((transaction) => (
|
||||
recentAddressTransactions.slice(0, 3).map((transaction) => (
|
||||
<Link
|
||||
key={transaction.hash}
|
||||
href={`/transactions/${transaction.hash}`}
|
||||
@@ -417,7 +455,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
{transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
|
||||
</div>
|
||||
<div className="mt-1 text-gray-600 dark:text-gray-400">
|
||||
Block #{transaction.block_number.toLocaleString()}
|
||||
Block #{formatInteger(transaction.block_number)}
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
@@ -448,7 +486,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
No indexed token balances are currently visible for this wallet.
|
||||
</div>
|
||||
) : (
|
||||
tokenBalances.map((balance) => (
|
||||
tokenBalances.slice(0, 5).map((balance) => (
|
||||
<Link
|
||||
key={balance.token_address}
|
||||
href={`/tokens/${balance.token_address}`}
|
||||
@@ -489,7 +527,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
No recent token transfers are currently visible for this connected wallet.
|
||||
</div>
|
||||
) : (
|
||||
tokenTransfers.map((transfer) => {
|
||||
tokenTransfers.slice(0, 5).map((transfer) => {
|
||||
const incoming = transfer.to_address.toLowerCase() === walletSession.address.toLowerCase()
|
||||
const counterparty = incoming ? transfer.from_address : transfer.to_address
|
||||
return (
|
||||
@@ -506,7 +544,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
{formatTokenAmount(transfer.value, transfer.token_decimals, transfer.token_symbol, 6)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Counterparty: {counterparty.slice(0, 6)}...{counterparty.slice(-4)} · {formatRelativeAge(transfer.timestamp)}
|
||||
Counterparty: {counterparty.slice(0, 6)}...{counterparty.slice(-4)} · <ClientRelativeTime value={transfer.timestamp} suffix=" ago" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
@@ -535,29 +573,23 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<AddToMetaMask {...props} />
|
||||
<div className="mt-6 rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
<Explain>
|
||||
<>
|
||||
Need swap and liquidity discovery too? Visit the{' '}
|
||||
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
|
||||
Liquidity
|
||||
</Link>{' '}
|
||||
page for live Chain 138 pools, route matrix links, partner payload templates, and the internal fallback execution plan endpoints.
|
||||
</>
|
||||
</Explain>
|
||||
{mode === 'expert' ? (
|
||||
<>
|
||||
Liquidity and planner posture lives on the{' '}
|
||||
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
|
||||
Liquidity
|
||||
</Link>{' '}
|
||||
surface.
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
Connect a wallet on the Connect tab to see balances, transfers, and LP positions here.
|
||||
</div>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="wallet-page" tabId="setup" activeTab={activeTab}>
|
||||
<AddToMetaMask {...props} />
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Need swap and liquidity discovery?{' '}
|
||||
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
|
||||
Open Liquidity
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</TabPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
60
frontend/src/data/footerNav.ts
Normal file
60
frontend/src/data/footerNav.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export type FooterNavLink = {
|
||||
href: string
|
||||
label: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
export type FooterNavGroup = {
|
||||
id: string
|
||||
title: string
|
||||
links: FooterNavLink[]
|
||||
}
|
||||
|
||||
export const footerNavGroups: FooterNavGroup[] = [
|
||||
{
|
||||
id: 'explore',
|
||||
title: 'Explore',
|
||||
links: [
|
||||
{ href: '/search', label: 'Search' },
|
||||
{ href: '/blocks', label: 'Blocks' },
|
||||
{ href: '/transactions', label: 'Transactions' },
|
||||
{ href: '/tokens', label: 'Tokens' },
|
||||
{ href: '/addresses', label: 'Addresses' },
|
||||
{ href: '/watchlist', label: 'Watchlist' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
title: 'Data & tools',
|
||||
links: [
|
||||
{ href: '/docs', label: 'Documentation' },
|
||||
{ href: '/wallet', label: 'Wallet tools' },
|
||||
{ href: '/access', label: 'Account access' },
|
||||
{ href: '/analytics', label: 'Analytics' },
|
||||
{ href: '/weth', label: 'WETH' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
title: 'Operations',
|
||||
links: [
|
||||
{ href: '/operations', label: 'Operations hub' },
|
||||
{ href: '/bridge', label: 'Bridge' },
|
||||
{ href: '/routes', label: 'Routes' },
|
||||
{ href: '/liquidity', label: 'Liquidity' },
|
||||
{ href: '/pools', label: 'Pools' },
|
||||
{ href: '/protocols', label: 'Protocols' },
|
||||
{ href: '/operator', label: 'Operator' },
|
||||
{ href: '/system', label: 'System' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'legal',
|
||||
title: 'Legal',
|
||||
links: [
|
||||
{ href: '/privacy.html', label: 'Privacy Policy', external: true },
|
||||
{ href: '/terms.html', label: 'Terms of Service', external: true },
|
||||
{ href: '/acknowledgments.html', label: 'Acknowledgments', external: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { GetServerSideProps } from 'next'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
} from '@/services/api/contracts'
|
||||
import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format'
|
||||
import { isEnsName, resolveEnsAddress } from '@/utils/ens'
|
||||
import { resolveAddressFromRegistryEns } from '@/utils/web3IdentityRegistry'
|
||||
import { resolveAddressFromRegistryEns, getKnownDisplayName } from '@/utils/web3IdentityRegistry'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import PostureBadge from '@/components/common/PostureBadge'
|
||||
@@ -32,7 +31,7 @@ import {
|
||||
} from '@/utils/watchlist'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { useDetailTabQuery } from '@/utils/useDetailTabQuery'
|
||||
|
||||
const ADDRESS_DETAIL_TABS_ID = 'address-detail'
|
||||
@@ -75,10 +74,14 @@ function isContractWriteExecutionEnabled() {
|
||||
return value === '1' || value === 'true' || value === 'yes'
|
||||
}
|
||||
|
||||
export default function AddressDetailPage() {
|
||||
export default function AddressDetailPage({ serverRegistryLabel = null }: { serverRegistryLabel?: string | null }) {
|
||||
const router = useRouter()
|
||||
const address = typeof router.query.address === 'string' ? router.query.address : ''
|
||||
const isValidAddressParam = address !== '' && isValidAddress(address)
|
||||
const registryDisplayName = useMemo(
|
||||
() => (isValidAddressParam ? getKnownDisplayName(address) : undefined),
|
||||
[address, isValidAddressParam],
|
||||
)
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const contractWriteExecutionEnabled = isContractWriteExecutionEnabled()
|
||||
|
||||
@@ -703,19 +706,23 @@ export default function AddressDetailPage() {
|
||||
const resolvedAddressInfo = addressInfo as AddressInfo
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Address Detail"
|
||||
title={addressInfo?.label || 'Address'}
|
||||
description="Inspect a Chain 138 address, move into related transactions, and save important counterparties into the shared explorer watchlist."
|
||||
title={addressInfo?.label || registryDisplayName || serverRegistryLabel || 'Address'}
|
||||
description={
|
||||
registryDisplayName || serverRegistryLabel
|
||||
? `${registryDisplayName || serverRegistryLabel} · Chain 138 balances, activity, and contract tools.`
|
||||
: 'Chain 138 address detail, balances, transfers, and transactions.'
|
||||
}
|
||||
actions={[
|
||||
{ href: '/addresses', label: 'All addresses' },
|
||||
{ href: '/watchlist', label: 'Open watchlist' },
|
||||
{ href: '/search', label: 'Search explorer' },
|
||||
{ href: '/watchlist', label: 'Watchlist' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<div className="mb-4 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/addresses" className="text-primary-600 hover:underline">
|
||||
Back to addresses
|
||||
</Link>
|
||||
@@ -849,10 +856,7 @@ export default function AddressDetailPage() {
|
||||
ariaLabel="Address details"
|
||||
/>
|
||||
|
||||
<div
|
||||
{...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'contract', activeTab)}
|
||||
className={resolvedAddressInfo.is_contract ? undefined : 'hidden'}
|
||||
>
|
||||
<TabPanel idPrefix={ADDRESS_DETAIL_TABS_ID} tabId="contract" activeTab={activeTab}>
|
||||
{resolvedAddressInfo.is_contract ? (
|
||||
<Card title="Contract Profile" className="mb-6">
|
||||
<dl className="space-y-4">
|
||||
@@ -1097,9 +1101,9 @@ export default function AddressDetailPage() {
|
||||
) : null}
|
||||
|
||||
{gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div {...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'balances', activeTab)}>
|
||||
<TabPanel idPrefix={ADDRESS_DETAIL_TABS_ID} tabId="balances" activeTab={activeTab}>
|
||||
<Card title="Token Balances" className="mb-6">
|
||||
{gruBalanceCount > 0 ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -1128,9 +1132,9 @@ export default function AddressDetailPage() {
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div {...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'transfers', activeTab)}>
|
||||
<TabPanel idPrefix={ADDRESS_DETAIL_TABS_ID} tabId="transfers" activeTab={activeTab}>
|
||||
<Card title="Recent Token Transfers" className="mb-6">
|
||||
{gruTransferCount > 0 ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -1162,9 +1166,9 @@ export default function AddressDetailPage() {
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div {...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'transactions', activeTab)}>
|
||||
<TabPanel idPrefix={ADDRESS_DETAIL_TABS_ID} tabId="transactions" activeTab={activeTab}>
|
||||
<Card title="Transactions">
|
||||
{activityLoading ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading recent transactions...</p>
|
||||
@@ -1185,9 +1189,18 @@ export default function AddressDetailPage() {
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<{ serverRegistryLabel: string | null }> = async (context) => {
|
||||
const address = typeof context.params?.address === 'string' ? context.params.address : ''
|
||||
return {
|
||||
props: {
|
||||
serverRegistryLabel: getKnownDisplayName(address) ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import { transactionsApi, Transaction } from '@/services/api/transactions'
|
||||
import { readWatchlistFromStorage } from '@/utils/watchlist'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { normalizeTransaction } from '@/services/api/blockscout'
|
||||
import { statsApi } from '@/services/api/stats'
|
||||
import { missionControlApi } from '@/services/api/missionControl'
|
||||
import { isEnsName, resolveEnsAddress } from '@/utils/ens'
|
||||
import { resolveAddressFromRegistryEns } from '@/utils/web3IdentityRegistry'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
|
||||
@@ -34,20 +35,6 @@ interface AddressesPageProps {
|
||||
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||
}
|
||||
|
||||
function serializeRecentTransactions(transactions: Transaction[]): Transaction[] {
|
||||
return JSON.parse(
|
||||
JSON.stringify(
|
||||
transactions.map((transaction) => ({
|
||||
hash: transaction.hash,
|
||||
block_number: transaction.block_number,
|
||||
from_address: transaction.from_address,
|
||||
to_address: transaction.to_address ?? null,
|
||||
created_at: transaction.created_at,
|
||||
})),
|
||||
),
|
||||
) as Transaction[]
|
||||
}
|
||||
|
||||
export default function AddressesPage({
|
||||
initialRecentTransactions,
|
||||
initialLatestBlocks,
|
||||
@@ -58,11 +45,14 @@ export default function AddressesPage({
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const [query, setQuery] = useState('')
|
||||
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
|
||||
const [latestBlocks, setLatestBlocks] = useState(initialLatestBlocks)
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [watchlist, setWatchlist] = useState<string[]>([])
|
||||
const activityContext = useMemo(
|
||||
() =>
|
||||
summarizeChainActivity({
|
||||
blocks: initialLatestBlocks.map((block) => ({
|
||||
blocks: latestBlocks.map((block) => ({
|
||||
chain_id: chainId,
|
||||
number: block.number,
|
||||
hash: '',
|
||||
@@ -73,12 +63,12 @@ export default function AddressesPage({
|
||||
gas_limit: 0,
|
||||
})),
|
||||
transactions: recentTransactions,
|
||||
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
|
||||
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
|
||||
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
||||
diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null,
|
||||
latestBlockNumber: latestBlocks[0]?.number ?? null,
|
||||
latestBlockTimestamp: latestBlocks[0]?.timestamp ?? null,
|
||||
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
||||
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
|
||||
}),
|
||||
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, recentTransactions],
|
||||
[chainId, bridgeStatus, latestBlocks, stats, recentTransactions],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -104,6 +94,47 @@ export default function AddressesPage({
|
||||
}
|
||||
}, [chainId, initialRecentTransactions])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStats && initialBridgeStatus && initialLatestBlocks.length > 0) {
|
||||
setStats(initialStats)
|
||||
setBridgeStatus(initialBridgeStatus)
|
||||
setLatestBlocks(initialLatestBlocks)
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
|
||||
Promise.allSettled([
|
||||
statsApi.get().catch(() => null),
|
||||
missionControlApi.getBridgeStatus().catch(() => null),
|
||||
fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>(
|
||||
'/api/v2/blocks?page=1&page_size=3',
|
||||
).catch(() => null),
|
||||
]).then(([statsResult, bridgeResult, blocksResult]) => {
|
||||
if (!active) return
|
||||
if (statsResult.status === 'fulfilled' && statsResult.value) {
|
||||
setStats(statsResult.value)
|
||||
}
|
||||
if (bridgeResult.status === 'fulfilled' && bridgeResult.value) {
|
||||
setBridgeStatus(bridgeResult.value)
|
||||
}
|
||||
if (blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items)) {
|
||||
setLatestBlocks(
|
||||
blocksResult.value.items
|
||||
.map((item) => ({
|
||||
number: Number(item.height || 0),
|
||||
timestamp: item.timestamp || '',
|
||||
}))
|
||||
.filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [initialBridgeStatus, initialLatestBlocks, initialStats])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
@@ -127,7 +158,7 @@ export default function AddressesPage({
|
||||
if (seen.has(normalized)) continue
|
||||
seen.add(normalized)
|
||||
addresses.push(candidate)
|
||||
if (addresses.length >= 12) return addresses
|
||||
if (addresses.length >= 8) return addresses
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +166,15 @@ export default function AddressesPage({
|
||||
}, [recentTransactions])
|
||||
|
||||
const [opening, setOpening] = useState(false)
|
||||
const [listTab, setListTab] = useState<'watchlist' | 'recent'>('watchlist')
|
||||
|
||||
const addressListTabs = useMemo<SectionTab<'watchlist' | 'recent'>[]>(
|
||||
() => [
|
||||
{ id: 'watchlist', label: 'Watchlist', count: watchlist.length },
|
||||
{ id: 'recent', label: 'Recently active', count: activeAddresses.length },
|
||||
],
|
||||
[activeAddresses.length, watchlist.length],
|
||||
)
|
||||
|
||||
const handleOpenAddress = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -162,80 +202,83 @@ export default function AddressesPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Address Discovery"
|
||||
title="Addresses"
|
||||
description="Open any Chain 138 address directly, revisit saved watchlist entries, or branch into recent activity discovered from indexed transactions."
|
||||
description="Open any Chain 138 address, resolve ENS names, or browse watchlist and recent activity."
|
||||
actions={[
|
||||
{ href: '/watchlist', label: 'Open watchlist' },
|
||||
{ href: '/transactions', label: 'Recent transactions' },
|
||||
{ href: '/search', label: 'Search explorer' },
|
||||
{ href: '/watchlist', label: 'Watchlist' },
|
||||
{ href: '/search', label: 'Search' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Recent Address Activity Context" />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={initialStats}
|
||||
bridgeStatus={initialBridgeStatus}
|
||||
scopeLabel="Recently active addresses are derived from the latest visible indexed transactions."
|
||||
/>
|
||||
</div>
|
||||
<ExplorerFreshnessDisclosure
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
activityTitle="Recent activity"
|
||||
scopeLabel="Recently active addresses come from indexed transactions."
|
||||
/>
|
||||
|
||||
<Card className="mb-6" title="Open An Address">
|
||||
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
|
||||
<Card className="mb-4" title="Open address">
|
||||
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="0x... or name.eth"
|
||||
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="0x… or name.eth"
|
||||
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canOpenAddressQuery(query) || opening}
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{opening ? 'Resolving…' : 'Open address'}
|
||||
{opening ? 'Resolving…' : 'Open'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Open any Chain 138 address or resolve a mainnet `.eth` name, then jump into your saved watchlist below.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card title="Saved Watchlist">
|
||||
<SectionTabs
|
||||
tabs={addressListTabs}
|
||||
activeTab={listTab}
|
||||
onChange={setListTab}
|
||||
idPrefix="addresses-list"
|
||||
ariaLabel="Address lists"
|
||||
className="-mx-4 mb-3 px-4"
|
||||
/>
|
||||
|
||||
<TabPanel idPrefix="addresses-list" tabId="watchlist" activeTab={listTab}>
|
||||
<Card title="Saved watchlist">
|
||||
{watchlist.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No saved addresses yet. Address detail pages let you add entries to the shared explorer watchlist.
|
||||
No saved addresses yet. Add entries from address detail pages.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{watchlist.map((entry) => (
|
||||
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
|
||||
<Address address={entry} showCopy={false} />
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-2">
|
||||
<Link href="/watchlist" className="text-sm text-primary-600 hover:underline">
|
||||
Open the full watchlist →
|
||||
</Link>
|
||||
</div>
|
||||
<Link href="/watchlist" className="inline-block pt-2 text-sm text-primary-600 hover:underline">
|
||||
Full watchlist →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<Card title="Recently Active Addresses">
|
||||
<TabPanel idPrefix="addresses-list" tabId="recent" activeTab={listTab}>
|
||||
<Card title="Recently active">
|
||||
{activeAddresses.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Recent address activity is unavailable in the latest visible transaction sample. You can still open an address directly above.
|
||||
No recent address activity in the visible transaction sample.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{activeAddresses.map((entry) => (
|
||||
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
|
||||
<Address address={entry} showCopy={false} />
|
||||
@@ -244,37 +287,16 @@ export default function AddressesPage({
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<AddressesPageProps> = async () => {
|
||||
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([
|
||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null),
|
||||
fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null),
|
||||
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
|
||||
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
||||
])
|
||||
const initialRecentTransactions = Array.isArray(transactionsResult?.items)
|
||||
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
|
||||
: []
|
||||
const initialLatestBlocks = Array.isArray(blocksResult?.items)
|
||||
? blocksResult.items
|
||||
.map((item) => ({
|
||||
number: Number(item.height || 0),
|
||||
timestamp: item.timestamp || '',
|
||||
}))
|
||||
.filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp)
|
||||
: []
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialRecentTransactions: serializeRecentTransactions(initialRecentTransactions),
|
||||
initialLatestBlocks,
|
||||
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
|
||||
initialBridgeStatus: bridgeResult,
|
||||
},
|
||||
}
|
||||
}
|
||||
export const getServerSideProps: GetServerSideProps<AddressesPageProps> = async () => ({
|
||||
props: {
|
||||
initialRecentTransactions: [],
|
||||
initialLatestBlocks: [],
|
||||
initialStats: null,
|
||||
initialBridgeStatus: null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import type { GetServerSideProps } from 'next'
|
||||
import AnalyticsOperationsPage from '@/components/explorer/AnalyticsOperationsPage'
|
||||
import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout'
|
||||
import {
|
||||
normalizeTransactionTrend,
|
||||
summarizeRecentTransactions,
|
||||
type ExplorerRecentActivitySnapshot,
|
||||
type ExplorerStats,
|
||||
type ExplorerTransactionTrendPoint,
|
||||
} from '@/services/api/stats'
|
||||
import type { ExplorerRecentActivitySnapshot, ExplorerStats, ExplorerTransactionTrendPoint } from '@/services/api/stats'
|
||||
import type { Block } from '@/services/api/blocks'
|
||||
import type { Transaction } from '@/services/api/transactions'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
|
||||
|
||||
interface AnalyticsPageProps {
|
||||
initialStats: ExplorerStats | null
|
||||
@@ -23,79 +14,17 @@ interface AnalyticsPageProps {
|
||||
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||
}
|
||||
|
||||
function serializeBlocks(blocks: Block[]): Block[] {
|
||||
return JSON.parse(
|
||||
JSON.stringify(
|
||||
blocks.map((block) => ({
|
||||
chain_id: block.chain_id,
|
||||
number: block.number,
|
||||
hash: block.hash,
|
||||
timestamp: block.timestamp,
|
||||
miner: block.miner,
|
||||
gas_used: block.gas_used,
|
||||
gas_limit: block.gas_limit,
|
||||
transaction_count: block.transaction_count,
|
||||
})),
|
||||
),
|
||||
) as Block[]
|
||||
}
|
||||
|
||||
function serializeTransactions(transactions: Transaction[]): Transaction[] {
|
||||
return JSON.parse(
|
||||
JSON.stringify(
|
||||
transactions.map((transaction) => ({
|
||||
hash: transaction.hash,
|
||||
block_number: transaction.block_number,
|
||||
from_address: transaction.from_address,
|
||||
to_address: transaction.to_address ?? null,
|
||||
value: transaction.value,
|
||||
status: transaction.status ?? null,
|
||||
contract_address: transaction.contract_address ?? null,
|
||||
fee: transaction.fee ?? null,
|
||||
})),
|
||||
),
|
||||
) as Transaction[]
|
||||
}
|
||||
|
||||
export default function AnalyticsPage(props: AnalyticsPageProps) {
|
||||
return <AnalyticsOperationsPage {...props} />
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<AnalyticsPageProps> = async () => {
|
||||
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const [truthContextResult, trendResult, activityResult, blocksResult, transactionsResult] = await Promise.allSettled([
|
||||
fetchExplorerTruthContext(),
|
||||
fetchPublicJson('/api/v2/stats/charts/transactions'),
|
||||
fetchPublicJson('/api/v2/main-page/transactions'),
|
||||
fetchPublicJson('/api/v2/blocks?page=1&page_size=5'),
|
||||
fetchPublicJson('/api/v2/transactions?page=1&page_size=5'),
|
||||
])
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialStats: truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialStats : null,
|
||||
initialTransactionTrend:
|
||||
trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value as never) : [],
|
||||
initialActivitySnapshot:
|
||||
activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value as never) : null,
|
||||
initialBlocks:
|
||||
blocksResult.status === 'fulfilled' && Array.isArray((blocksResult.value as { items?: unknown[] })?.items)
|
||||
? serializeBlocks(
|
||||
((blocksResult.value as { items?: unknown[] }).items || []).map((item) =>
|
||||
normalizeBlock(item as never, chainId),
|
||||
),
|
||||
)
|
||||
: [],
|
||||
initialTransactions:
|
||||
transactionsResult.status === 'fulfilled' && Array.isArray((transactionsResult.value as { items?: unknown[] })?.items)
|
||||
? serializeTransactions(
|
||||
((transactionsResult.value as { items?: unknown[] }).items || []).map((item) =>
|
||||
normalizeTransaction(item as never, chainId),
|
||||
),
|
||||
)
|
||||
: [],
|
||||
initialBridgeStatus:
|
||||
truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialBridgeStatus : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
export const getServerSideProps: GetServerSideProps<AnalyticsPageProps> = async () => ({
|
||||
props: {
|
||||
initialStats: null,
|
||||
initialTransactionTrend: [],
|
||||
initialActivitySnapshot: null,
|
||||
initialBlocks: [],
|
||||
initialTransactions: [],
|
||||
initialBridgeStatus: null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,8 +6,10 @@ import { blocksApi, Block } from '@/services/api/blocks'
|
||||
import { Card, Address, Table } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
import CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import { formatTimestamp, formatWeiAsEth } from '@/utils/format'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { formatInteger, formatTimestamp, formatWeiAsEth } from '@/utils/format'
|
||||
import { type Transaction } from '@/services/api/transactions'
|
||||
import { useBlockTransactions } from '@/hooks/useBlockTransactions'
|
||||
|
||||
@@ -20,6 +22,7 @@ export default function BlockDetailPage() {
|
||||
|
||||
const [block, setBlock] = useState<Block | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'summary' | 'transactions'>('summary')
|
||||
|
||||
const {
|
||||
transactions: blockTransactions,
|
||||
@@ -123,40 +126,38 @@ export default function BlockDetailPage() {
|
||||
? 'No indexed block transactions were returned for this page yet.'
|
||||
: 'This block does not contain any indexed transactions.'
|
||||
|
||||
const blockIntroActions = useMemo(() => {
|
||||
const actions = [
|
||||
{ href: '/blocks', label: 'All blocks' },
|
||||
{ href: '/transactions', label: 'Transactions' },
|
||||
]
|
||||
if (block && block.number > 0) {
|
||||
actions.push({ href: `/blocks/${block.number - 1}`, label: 'Previous' })
|
||||
}
|
||||
if (block) {
|
||||
actions.push({ href: `/blocks/${block.number + 1}`, label: 'Next' })
|
||||
}
|
||||
return actions
|
||||
}, [block])
|
||||
|
||||
const blockTabs = useMemo<SectionTab<'summary' | 'transactions'>[]>(
|
||||
() => [
|
||||
{ id: 'summary', label: 'Summary' },
|
||||
{ id: 'transactions', label: 'Transactions', count: block?.transaction_count ?? 0 },
|
||||
],
|
||||
[block?.transaction_count],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Block Detail"
|
||||
title={block ? `Block #${block.number}` : 'Block'}
|
||||
description="Inspect a single Chain 138 block, then move into its related miner address, adjacent block numbers, or broader explorer search flows."
|
||||
actions={[
|
||||
{ href: '/blocks', label: 'All blocks' },
|
||||
{ href: '/transactions', label: 'Recent transactions' },
|
||||
{ href: '/search', label: 'Search explorer' },
|
||||
]}
|
||||
description="Chain 138 block detail, miner, adjacent blocks, and transactions."
|
||||
actions={blockIntroActions}
|
||||
/>
|
||||
|
||||
<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 && block.number > 0 ? (
|
||||
<Link href={`/blocks/${block.number - 1}`} className="text-primary-600 hover:underline">
|
||||
Previous block
|
||||
</Link>
|
||||
) : null}
|
||||
{block && (
|
||||
<Link href={`/blocks/${block.number + 1}`} className="text-primary-600 hover:underline">
|
||||
Next block
|
||||
</Link>
|
||||
)}
|
||||
{block?.transaction_count ? (
|
||||
<a href="#block-transactions" className="text-primary-600 hover:underline">
|
||||
Open block transactions
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!router.isReady || loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading block...</p>
|
||||
@@ -186,6 +187,26 @@ export default function BlockDetailPage() {
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<CompactMetricBar
|
||||
metrics={[
|
||||
{ label: 'Transactions', value: formatInteger(block.transaction_count) },
|
||||
{ label: 'Gas used', value: `${gasUtilization ?? 0}%` },
|
||||
{ label: 'Gas limit', value: formatInteger(block.gas_limit) },
|
||||
{ label: 'Miner', value: `${block.miner.slice(0, 6)}…${block.miner.slice(-4)}` },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SectionTabs
|
||||
tabs={blockTabs}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
idPrefix="block-detail"
|
||||
ariaLabel="Block detail sections"
|
||||
className="-mx-4 mb-4 px-4"
|
||||
/>
|
||||
|
||||
<TabPanel idPrefix="block-detail" tabId="summary" activeTab={activeTab}>
|
||||
<Card title="Block Information">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Hash">
|
||||
@@ -200,12 +221,16 @@ export default function BlockDetailPage() {
|
||||
</Link>
|
||||
</DetailRow>
|
||||
<DetailRow label="Transactions">
|
||||
<a href="#block-transactions" className="text-primary-600 hover:underline">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('transactions')}
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
{block.transaction_count}
|
||||
</a>
|
||||
</button>
|
||||
</DetailRow>
|
||||
<DetailRow label="Gas Used">
|
||||
{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}
|
||||
{formatInteger(block.gas_used)} / {formatInteger(block.gas_limit)}
|
||||
</DetailRow>
|
||||
{gasUtilization != null && (
|
||||
<DetailRow label="Gas Utilization">
|
||||
@@ -214,13 +239,13 @@ export default function BlockDetailPage() {
|
||||
)}
|
||||
</dl>
|
||||
</Card>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{block && (
|
||||
<Card title="Block Transactions" className="mt-6">
|
||||
<TabPanel idPrefix="block-detail" tabId="transactions" activeTab={activeTab}>
|
||||
<Card title="Block Transactions">
|
||||
<div id="block-transactions" className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
This section shows the exact indexed transaction set for block #{block.number.toLocaleString()}, independent of generic explorer search.
|
||||
Indexed transaction set for block #{formatInteger(block.number)}.
|
||||
</p>
|
||||
{transactionsLoading ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading block transactions...</p>
|
||||
@@ -261,6 +286,8 @@ export default function BlockDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import type { GetServerSideProps } from 'next'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { blocksApi, Block } from '@/services/api/blocks'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure'
|
||||
import { formatTimestamp } from '@/utils/format'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout'
|
||||
import type { Transaction } from '@/services/api/transactions'
|
||||
import { transactionsApi } from '@/services/api/transactions'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import { useListPageQuery } from '@/utils/useListPageQuery'
|
||||
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
|
||||
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
|
||||
|
||||
interface BlocksPageProps {
|
||||
initialBlocks: Block[]
|
||||
@@ -49,9 +49,11 @@ export default function BlocksPage({
|
||||
initialStats,
|
||||
initialBridgeStatus,
|
||||
}: BlocksPageProps) {
|
||||
const pageSize = 20
|
||||
const pageSize = 15
|
||||
const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
|
||||
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [loading, setLoading] = useState(initialBlocks.length === 0)
|
||||
const { page, setPage } = useListPageQuery()
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
@@ -108,8 +110,35 @@ export default function BlocksPage({
|
||||
}
|
||||
}, [chainId, initialRecentTransactions])
|
||||
|
||||
const showPagination = page > 1 || blocks.length > 0
|
||||
const canGoNext = blocks.length === pageSize
|
||||
useEffect(() => {
|
||||
if (initialStats && initialBridgeStatus) {
|
||||
setStats(initialStats)
|
||||
setBridgeStatus(initialBridgeStatus)
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
fetchExplorerTruthContext()
|
||||
.then((context) => {
|
||||
if (!active) return
|
||||
setStats((current) => current ?? context.initialStats)
|
||||
setBridgeStatus((current) => current ?? context.initialBridgeStatus)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [initialStats, initialBridgeStatus])
|
||||
|
||||
const paginatedBlocks = useMemo(() => {
|
||||
const start = (page - 1) * pageSize
|
||||
return blocks.slice(start, start + pageSize)
|
||||
}, [blocks, page, pageSize])
|
||||
|
||||
const showPagination = blocks.length > pageSize || page > 1
|
||||
const canGoNext = page * pageSize < blocks.length
|
||||
|
||||
const activityContext = useMemo(
|
||||
() =>
|
||||
summarizeChainActivity({
|
||||
@@ -117,95 +146,79 @@ export default function BlocksPage({
|
||||
transactions: recentTransactions,
|
||||
latestBlockNumber: blocks[0]?.number ?? null,
|
||||
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
|
||||
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
||||
diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null,
|
||||
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
||||
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
|
||||
}),
|
||||
[blocks, initialBridgeStatus, initialStats, recentTransactions],
|
||||
[blocks, bridgeStatus, stats, recentTransactions],
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: 'Block',
|
||||
accessor: (block: Block) => (
|
||||
<Link href={`/blocks/${block.number}`} className="font-medium text-primary-600 hover:underline">
|
||||
#{block.number}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Age',
|
||||
accessor: (block: Block) => (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{formatTimestamp(block.timestamp)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Miner',
|
||||
accessor: (block: Block) => (
|
||||
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
|
||||
<Address address={block.miner} truncate showCopy={false} />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Txs',
|
||||
accessor: (block: Block) => block.transaction_count,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Chain Activity"
|
||||
title="Blocks"
|
||||
description="Browse recent Chain 138 blocks, then pivot into transactions, addresses, and indexed search without falling into a dead end."
|
||||
description="Recent Chain 138 blocks with miner links and transaction counts."
|
||||
actions={[
|
||||
{ href: '/transactions', label: 'Open transactions' },
|
||||
{ href: '/addresses', label: 'Browse addresses' },
|
||||
{ href: '/search', label: 'Search explorer' },
|
||||
{ href: '/transactions', label: 'Transactions' },
|
||||
{ href: '/search', label: 'Search' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Block Production Context" />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={initialStats}
|
||||
bridgeStatus={initialBridgeStatus}
|
||||
scopeLabel="This page focuses on recent visible head blocks."
|
||||
/>
|
||||
</div>
|
||||
<ExplorerFreshnessDisclosure
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
activityTitle="Block production"
|
||||
scopeLabel="Recent visible head blocks on this page."
|
||||
/>
|
||||
|
||||
{shouldExplainEmptyHeadBlocks(blocks, activityContext) ? (
|
||||
<p className="mb-4 rounded-lg border border-amber-200 bg-amber-50/70 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
|
||||
Recent head blocks are empty; use the latest transaction block for visible activity.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading blocks...</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{shouldExplainEmptyHeadBlocks(blocks, activityContext) ? (
|
||||
<Card className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/40 dark:bg-amber-950/20">
|
||||
<p className="text-sm text-amber-900 dark:text-amber-100">
|
||||
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
{blocks.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Recent blocks are unavailable right now.</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
Open recent transactions →
|
||||
</Link>
|
||||
<Link href="/search" className="text-primary-600 hover:underline">
|
||||
Search by block number →
|
||||
</Link>
|
||||
</div>
|
||||
</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 className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Miner:{' '}
|
||||
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
|
||||
<Address address={block.miner} truncate showCopy={false} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left sm:text-right">
|
||||
<div className="text-sm">
|
||||
{formatTimestamp(block.timestamp)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{block.transaction_count} transactions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={paginatedBlocks}
|
||||
emptyMessage="Recent blocks are unavailable right now."
|
||||
keyExtractor={(block) => String(block.number)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPagination ? (
|
||||
@@ -219,48 +232,22 @@ export default function BlocksPage({
|
||||
className="mt-6"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 grid gap-4 lg:grid-cols-2">
|
||||
<Card title="Keep Exploring">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Need a different entry point? Open transaction flow, search directly by block number, or jump into recently active addresses.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
Transactions →
|
||||
</Link>
|
||||
<Link href="/addresses" className="text-primary-600 hover:underline">
|
||||
Addresses →
|
||||
</Link>
|
||||
<Link href="/search" className="text-primary-600 hover:underline">
|
||||
Search →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<BlocksPageProps> = async () => {
|
||||
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const [blocksResult, transactionsResult, statsResult, bridgeResult] = await Promise.all([
|
||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null),
|
||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5').catch(() => null),
|
||||
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
|
||||
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
||||
])
|
||||
const blocksResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=15').catch(() => null)
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialBlocks: Array.isArray(blocksResult?.items)
|
||||
? blocksResult.items.map((item) => normalizeBlock(item as never, chainId))
|
||||
: [],
|
||||
initialRecentTransactions: Array.isArray(transactionsResult?.items)
|
||||
? serializeTransactions(transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId)))
|
||||
: [],
|
||||
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
|
||||
initialBridgeStatus: bridgeResult,
|
||||
initialRecentTransactions: [],
|
||||
initialStats: null,
|
||||
initialBridgeStatus: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ import EntityBadge from '@/components/common/EntityBadge'
|
||||
|
||||
export default function GruDocsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Explorer Documentation"
|
||||
title="GRU Guide"
|
||||
description="A user-facing summary of the GRU standards, bridge posture, public-network representations, and x402 readiness model, with concrete places to inspect those signals on live token, address, and search pages."
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
|
||||
const docsCards = [
|
||||
{
|
||||
@@ -55,97 +56,54 @@ const policyLinks = [
|
||||
|
||||
export default function DocsIndexPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Explorer Documentation"
|
||||
title="Documentation"
|
||||
description="Use the explorer’s public guides, methodology notes, and adjacent operational references from one canonical docs surface."
|
||||
description="Public guides, methodology notes, and operational references for Chain 138."
|
||||
actions={[
|
||||
{ href: '/docs/gru', label: 'GRU guide' },
|
||||
{ href: '/docs/transaction-review', label: 'Review matrix' },
|
||||
{ href: '/operations', label: 'Operations hub' },
|
||||
{ href: '/operations', label: 'Operations' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<Card title="Explorer Guides">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{docsCards.map((item) => (
|
||||
<div key={item.href} 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">{item.title}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">{item.description}</p>
|
||||
<div className="mt-4">
|
||||
<Link href={item.href} className="text-primary-600 hover:underline">
|
||||
Open guide →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card title="Verify These Guides Live">
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
The docs are meant to prove the explorer, not merely describe it. Each guide below links back into live Chain 138 pages where the
|
||||
documented signals can be inspected directly.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/search?q=cUSDT" className="text-primary-600 hover:underline">
|
||||
Search cUSDT →
|
||||
</Link>
|
||||
<Link href="/tokens/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22" className="text-primary-600 hover:underline">
|
||||
Open cUSDT token page →
|
||||
</Link>
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
Browse recent transactions →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Operator & Domains">
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
DBIS Explorer is the public Chain 138 explorer operated by DBIS. Primary public access is served at
|
||||
<code> explorer.d-bis.org</code>; <code> blockscout.defi-oracle.io</code> is the Blockscout companion domain.
|
||||
</p>
|
||||
<p>
|
||||
These domains are part of the same explorer and companion-tooling surface, including the Snap install path at
|
||||
<code> /snap/</code>. Support and policy notices are handled through
|
||||
<a href="mailto:support@d-bis.org" className="ml-1 text-primary-600 hover:underline">support@d-bis.org</a>.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Policies & Static Notes">
|
||||
<div className="space-y-3 text-sm">
|
||||
{policyLinks.map((item) => (
|
||||
<div key={item.href}>
|
||||
<a href={item.href} className="text-primary-600 hover:underline">
|
||||
{item.label} →
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Need Help?">
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
The public explorer docs cover GRU posture, transaction review scoring, liquidity access, and navigation into the broader Chain 138 surfaces.
|
||||
</p>
|
||||
<p>
|
||||
Support: <a href="mailto:support@d-bis.org" className="text-primary-600 hover:underline">support@d-bis.org</a>
|
||||
</p>
|
||||
<p>
|
||||
Command center: <Link href="/topology" className="text-primary-600 hover:underline">open visual map →</Link>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Explorer guides" className="mb-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{docsCards.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="block rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-3 hover:border-primary-300 dark:border-gray-700 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-sm font-semibold text-primary-600">{item.title}</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-gray-600 dark:text-gray-400">{item.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<DisclosureSection title="Policies, domains & support" defaultOpen={false} alwaysCollapsible>
|
||||
<div className="space-y-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
Primary domain <code>explorer.d-bis.org</code>; Blockscout companion <code>blockscout.defi-oracle.io</code>.
|
||||
Support: <a href="mailto:support@d-bis.org" className="text-primary-600 hover:underline">support@d-bis.org</a>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{policyLinks.map((item) => (
|
||||
<a key={item.href} href={item.href} className="text-primary-600 hover:underline">
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
<Link href="/search?q=cUSDT" className="text-primary-600 hover:underline">
|
||||
Verify cUSDT live
|
||||
</Link>
|
||||
<Link href="/topology" className="text-primary-600 hover:underline">
|
||||
Command center map
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</DisclosureSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import { postureGlossaryTerms } from '@/data/postureGlossary'
|
||||
|
||||
export default function PostureGlossaryDocsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Explorer Documentation"
|
||||
title="Posture glossary"
|
||||
description="First-read explanations for institutional posture badges shown on token, address, transaction, and search surfaces."
|
||||
|
||||
@@ -7,8 +7,9 @@ import { explorerPublicApiLinks } from '@/data/explorerOperations'
|
||||
|
||||
export default function PublicApiAccessDocsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Explorer Documentation"
|
||||
title="Public API access"
|
||||
description="How integrators use read-only explorer APIs today, how managed RPC keys work on /access, and the planned path if public rate limits require API keys."
|
||||
|
||||
@@ -15,8 +15,9 @@ const FACTORS = [
|
||||
|
||||
export default function TransactionComplianceDocsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Explorer Documentation"
|
||||
title="Transaction Evidence Matrix"
|
||||
description="A practical explorer-side heuristic for scoring how well a transaction is evidenced, decoded, and aligned with GRU, x402, and ISO-20022 posture."
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import type { GetServerSideProps } from 'next'
|
||||
import HomePage from '@/components/home/HomePage'
|
||||
import { normalizeBlock } from '@/services/api/blockscout'
|
||||
import {
|
||||
normalizeTransactionTrend,
|
||||
summarizeRecentTransactions,
|
||||
type ExplorerRecentActivitySnapshot,
|
||||
type ExplorerStats,
|
||||
type ExplorerTransactionTrendPoint,
|
||||
} from '@/services/api/stats'
|
||||
import {
|
||||
summarizeMissionControlRelay,
|
||||
type MissionControlBridgeStatusResponse,
|
||||
type MissionControlRelaySummary,
|
||||
} from '@/services/api/missionControl'
|
||||
import type { Block } from '@/services/api/blocks'
|
||||
import type { Transaction } from '@/services/api/transactions'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
|
||||
import { normalizeTransaction } from '@/services/api/blockscout'
|
||||
|
||||
interface IndexPageProps {
|
||||
initialStats: ExplorerStats | null
|
||||
@@ -33,67 +26,14 @@ export default function IndexPage(props: IndexPageProps) {
|
||||
return <HomePage {...props} />
|
||||
}
|
||||
|
||||
function serializeTransactions(transactions: Transaction[]): Transaction[] {
|
||||
return JSON.parse(
|
||||
JSON.stringify(
|
||||
transactions.map((transaction) => ({
|
||||
hash: transaction.hash,
|
||||
block_number: transaction.block_number,
|
||||
from_address: transaction.from_address,
|
||||
to_address: transaction.to_address ?? null,
|
||||
value: transaction.value,
|
||||
status: transaction.status ?? null,
|
||||
contract_address: transaction.contract_address ?? null,
|
||||
fee: transaction.fee ?? null,
|
||||
created_at: transaction.created_at,
|
||||
})),
|
||||
),
|
||||
) as Transaction[]
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () => {
|
||||
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
|
||||
const [truthContextResult, blocksResult, transactionsResult, trendResult, activityResult] = await Promise.allSettled([
|
||||
fetchExplorerTruthContext(),
|
||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=10'),
|
||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5'),
|
||||
fetchPublicJson<{ chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }> }>(
|
||||
'/api/v2/stats/charts/transactions'
|
||||
),
|
||||
fetchPublicJson<
|
||||
Array<{
|
||||
status?: string | null
|
||||
transaction_types?: string[] | null
|
||||
gas_used?: number | string | null
|
||||
fee?: { value?: string | number | null } | string | null
|
||||
}>
|
||||
>('/api/v2/main-page/transactions'),
|
||||
])
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialStats: truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialStats : null,
|
||||
initialRecentBlocks:
|
||||
blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items)
|
||||
? blocksResult.value.items.map((item) => normalizeBlock(item as never, chainId))
|
||||
: [],
|
||||
initialRecentTransactions:
|
||||
transactionsResult.status === 'fulfilled' && Array.isArray(transactionsResult.value?.items)
|
||||
? serializeTransactions(
|
||||
transactionsResult.value.items.map((item) => normalizeTransaction(item as never, chainId)),
|
||||
)
|
||||
: [],
|
||||
initialTransactionTrend:
|
||||
trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value) : [],
|
||||
initialActivitySnapshot:
|
||||
activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value) : null,
|
||||
initialBridgeStatus:
|
||||
truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialBridgeStatus : null,
|
||||
initialRelaySummary:
|
||||
truthContextResult.status === 'fulfilled' && truthContextResult.value.initialBridgeStatus
|
||||
? summarizeMissionControlRelay(truthContextResult.value.initialBridgeStatus as never)
|
||||
: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () => ({
|
||||
props: {
|
||||
initialStats: null,
|
||||
initialRecentBlocks: [],
|
||||
initialRecentTransactions: [],
|
||||
initialTransactionTrend: [],
|
||||
initialActivitySnapshot: null,
|
||||
initialBridgeStatus: null,
|
||||
initialRelaySummary: null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,9 +5,6 @@ import type { InternalExecutionPlanResponse, PlannerCapabilitiesResponse } from
|
||||
import type { MissionControlLiquidityPool, RouteMatrixResponse } from '@/services/api/routes'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
|
||||
import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces'
|
||||
|
||||
interface TokenPoolRecord {
|
||||
symbol: string
|
||||
@@ -24,70 +21,18 @@ interface LiquidityPageProps {
|
||||
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||
}
|
||||
|
||||
const featuredTokenSymbols = new Set(['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT'])
|
||||
|
||||
async function fetchPublicPostJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(`https://blockscout.defi-oracle.io${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export default function LiquidityPage(props: LiquidityPageProps) {
|
||||
return <LiquidityOperationsPage {...props} />
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<LiquidityPageProps> = async () => {
|
||||
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult, truthContext] =
|
||||
await Promise.all([
|
||||
loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
|
||||
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
||||
fetchPublicJson<PlannerCapabilitiesResponse>('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch(
|
||||
() => null,
|
||||
),
|
||||
fetchPublicPostJson<InternalExecutionPlanResponse>('/token-aggregation/api/v2/routes/internal-execution-plan', {
|
||||
sourceChainId: 138,
|
||||
tokenIn: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
tokenOut: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
|
||||
amountIn: '100000000000000000',
|
||||
}).catch(() => null),
|
||||
fetchExplorerTruthContext(),
|
||||
])
|
||||
|
||||
const featuredTokens = (tokenListResult?.tokens || []).filter(
|
||||
(token) => token.chainId === 138 && typeof token.symbol === 'string' && featuredTokenSymbols.has(token.symbol),
|
||||
)
|
||||
|
||||
const tokenPoolsResults = await Promise.all(
|
||||
featuredTokens.map(async (token) => {
|
||||
const response = await fetchPublicJson<{ pools?: MissionControlLiquidityPool[]; data?: { pools?: MissionControlLiquidityPool[] } }>(
|
||||
`/explorer-api/v1/mission-control/liquidity/token/${token.address}/pools`,
|
||||
).catch(() => null)
|
||||
const pools = Array.isArray(response?.pools)
|
||||
? response.pools
|
||||
: Array.isArray(response?.data?.pools)
|
||||
? response.data.pools
|
||||
: []
|
||||
return { symbol: token.symbol || token.address || 'unknown', pools }
|
||||
}),
|
||||
).catch(() => [] as TokenPoolRecord[])
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialTokenList: tokenListResult,
|
||||
initialRouteMatrix: routeMatrixResult,
|
||||
initialPlannerCapabilities: plannerCapabilitiesResult,
|
||||
initialInternalPlan: internalPlanResult,
|
||||
initialTokenPoolRecords: tokenPoolsResults,
|
||||
initialStats: truthContext.initialStats,
|
||||
initialBridgeStatus: truthContext.initialBridgeStatus,
|
||||
},
|
||||
}
|
||||
}
|
||||
export const getServerSideProps: GetServerSideProps<LiquidityPageProps> = async () => ({
|
||||
props: {
|
||||
initialTokenList: null,
|
||||
initialRouteMatrix: null,
|
||||
initialPlannerCapabilities: null,
|
||||
initialInternalPlan: null,
|
||||
initialTokenPoolRecords: [],
|
||||
initialStats: null,
|
||||
initialBridgeStatus: null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { GetServerSideProps } from 'next'
|
||||
import OperatorOperationsPage from '@/components/explorer/OperatorOperationsPage'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import type { InternalExecutionPlanResponse, PlannerCapabilitiesResponse } from '@/services/api/planner'
|
||||
import type { RouteMatrixResponse } from '@/services/api/routes'
|
||||
@@ -16,21 +15,11 @@ export default function OperatorPage(props: OperatorPageProps) {
|
||||
return <OperatorOperationsPage {...props} />
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<OperatorPageProps> = async () => {
|
||||
const [bridgeStatus, routeMatrix, plannerCapabilities] = await Promise.all([
|
||||
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
||||
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
||||
fetchPublicJson<PlannerCapabilitiesResponse>('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch(
|
||||
() => null,
|
||||
),
|
||||
])
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialBridgeStatus: bridgeStatus,
|
||||
initialRouteMatrix: routeMatrix,
|
||||
initialPlannerCapabilities: plannerCapabilities,
|
||||
initialInternalPlan: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
export const getServerSideProps: GetServerSideProps<OperatorPageProps> = async () => ({
|
||||
props: {
|
||||
initialBridgeStatus: null,
|
||||
initialRouteMatrix: null,
|
||||
initialPlannerCapabilities: null,
|
||||
initialInternalPlan: null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,9 +5,12 @@ import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import SectionTabs, { TabPanel } from '@/components/common/SectionTabs'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
|
||||
import { fetchOfficialProtocols, type OfficialProtocolRow, type ProtocolVerificationReport } from '@/services/api/officialProtocols'
|
||||
import { formatTimestamp } from '@/utils/format'
|
||||
|
||||
export default function ProtocolDetailPage() {
|
||||
const router = useRouter()
|
||||
@@ -17,6 +20,7 @@ export default function ProtocolDetailPage() {
|
||||
const [verification, setVerification] = useState<ProtocolVerificationReport | null>(null)
|
||||
const [registryMeta, setRegistryMeta] = useState<{ schemaVersion?: string; updated?: string; generatedAt?: string }>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [protocolTab, setProtocolTab] = useState<'overview' | 'contracts'>('overview')
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady || !protocolId) return
|
||||
@@ -42,20 +46,20 @@ export default function ProtocolDetailPage() {
|
||||
}, [protocolId, router.isReady])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<OperationsSurfaceNav />
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Official protocol"
|
||||
title={protocol?.id ?? (protocolId || 'Protocol')}
|
||||
description={
|
||||
protocol?.upstreamRepo
|
||||
? `Deployed from official upstream. ${protocol.contracts.length} on-chain addresses on Chain 138.`
|
||||
: 'Chain 138 official protocol contract catalog.'
|
||||
? `Official upstream · ${protocol.contracts.length} Chain 138 contracts.`
|
||||
: 'Chain 138 official protocol catalog.'
|
||||
}
|
||||
actions={[
|
||||
{ href: '/protocols', label: 'All protocols' },
|
||||
{ href: '/liquidity', label: 'Liquidity' },
|
||||
{ href: `/token-aggregation/api/v1/report/official-protocols/${protocolId}`, label: 'JSON' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -74,9 +78,22 @@ export default function ProtocolDetailPage() {
|
||||
) : null}
|
||||
|
||||
{protocol ? (
|
||||
<div className="space-y-6">
|
||||
<>
|
||||
<SectionTabs
|
||||
idPrefix="protocol-detail"
|
||||
ariaLabel="Protocol detail sections"
|
||||
tabs={[
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'contracts', label: 'Contracts', count: protocol.contracts.length },
|
||||
]}
|
||||
activeTab={protocolTab}
|
||||
onChange={setProtocolTab}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<TabPanel idPrefix="protocol-detail" tabId="overview" activeTab={protocolTab}>
|
||||
{verification ? (
|
||||
<Card title="On-chain verification">
|
||||
<Card title="On-chain verification" className="mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge
|
||||
label={verification.allVerified ? 'all contracts deployed' : 'review required'}
|
||||
@@ -88,7 +105,7 @@ export default function ProtocolDetailPage() {
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Live bytecode probe via Chain 138 RPC at {new Date(verification.verifiedAt).toLocaleString()}.
|
||||
Live bytecode probe via Chain 138 RPC at {formatTimestamp(verification.verifiedAt)}.
|
||||
{registryMeta.updated ? ` Registry updated ${registryMeta.updated}.` : ''}
|
||||
</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
@@ -135,12 +152,28 @@ export default function ProtocolDetailPage() {
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{forbidden.length > 0 ? (
|
||||
<DisclosureSection title="Forbidden in production" defaultOpen={false} alwaysCollapsible className="mt-4">
|
||||
<Card>
|
||||
<ul className="list-disc space-y-2 pl-5 text-sm text-gray-700 dark:text-gray-300">
|
||||
{forbidden.slice(0, 5).map((row) => (
|
||||
<li key={row.id}>
|
||||
<span className="font-medium">{row.id}</span> — {row.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</DisclosureSection>
|
||||
) : null}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="protocol-detail" tabId="contracts" activeTab={protocolTab}>
|
||||
<Card title="Contracts">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{protocol.contracts.map((contract) => (
|
||||
<div key={contract.name} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
<div key={contract.name} className="rounded-xl border border-gray-200 px-3 py-2.5 dark:border-gray-700">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{contract.name}</div>
|
||||
<div className="mt-2">
|
||||
<div className="mt-1">
|
||||
<Address address={contract.address} truncate showCopy />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
@@ -155,19 +188,8 @@ export default function ProtocolDetailPage() {
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{forbidden.length > 0 ? (
|
||||
<Card title="Forbidden in production">
|
||||
<ul className="list-disc space-y-2 pl-5 text-sm text-gray-700 dark:text-gray-300">
|
||||
{forbidden.slice(0, 5).map((row) => (
|
||||
<li key={row.id}>
|
||||
<span className="font-medium">{row.id}</span> — {row.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</TabPanel>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
|
||||
import { fetchOfficialProtocols, type OfficialProtocolsResponse } from '@/services/api/officialProtocols'
|
||||
@@ -27,15 +28,15 @@ export default function ProtocolsIndexPage() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<OperationsSurfaceNav />
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Chain 138 infrastructure"
|
||||
title="Official protocol contracts"
|
||||
description="Production DODO, UniV2/V3, CCIP, Multicall3, and integration contracts deployed from official upstream sources — with forbidden pilot patterns called out."
|
||||
description="Production DODO, UniV2/V3, CCIP, Multicall3, and integration contracts from official upstream sources."
|
||||
actions={[
|
||||
{ href: '/liquidity', label: 'Liquidity' },
|
||||
{ href: '/docs', label: 'Explorer docs' },
|
||||
{ href: '/token-aggregation/api/v1/report/official-protocols', label: 'JSON API' },
|
||||
]}
|
||||
/>
|
||||
@@ -55,17 +56,17 @@ export default function ProtocolsIndexPage() {
|
||||
) : null}
|
||||
|
||||
{report ? (
|
||||
<div className="space-y-6">
|
||||
<Card title="Production guardrails">
|
||||
<div className="space-y-4">
|
||||
<DisclosureSection title="Production guardrails" defaultOpen={false} alwaysCollapsible>
|
||||
<ul className="list-disc space-y-2 pl-5 text-sm text-gray-700 dark:text-gray-300">
|
||||
{report.guardrails.slice(0, 4).map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</DisclosureSection>
|
||||
|
||||
<Card title={`Protocols (${report.count})`}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{report.protocols.map((protocol) => (
|
||||
<Link
|
||||
key={protocol.id}
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
type WrappedTransportRegistryRow,
|
||||
} from '@/utils/search'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { fetchTokenListForSurface } from '@/services/api/tokenListSurfaces'
|
||||
import { useUiMode } from '@/components/common/UiModeContext'
|
||||
@@ -50,6 +54,9 @@ import { searchCuratedPools, type PoolSearchRow } from '@/utils/poolSearch'
|
||||
import { fetchPoolRegistry, type CuratedPoolRegistryEntry } from '@/services/api/liquidityPositions'
|
||||
|
||||
type SearchFilterMode = 'all' | 'gru' | 'x402' | 'wrapped'
|
||||
type SearchResultsTab = 'indexed' | 'mesh' | 'pools' | 'wrapped'
|
||||
|
||||
const SEARCH_INDEXED_PAGE_SIZE = 4
|
||||
|
||||
interface SearchPageProps {
|
||||
initialQuery: string
|
||||
@@ -89,6 +96,8 @@ export default function SearchPage({
|
||||
const [meshLoading, setMeshLoading] = useState(false)
|
||||
const [polygonMapper, setPolygonMapper] = useState<PolygonMapperResponse | null>(null)
|
||||
const [poolRegistryRows, setPoolRegistryRows] = useState<CuratedPoolRegistryEntry[]>([])
|
||||
const [indexedPage, setIndexedPage] = useState(1)
|
||||
const [activeSearchTab, setActiveSearchTab] = useState<SearchResultsTab>('indexed')
|
||||
|
||||
const runSearch = async (rawQuery: string) => {
|
||||
const trimmedQuery = rawQuery.trim()
|
||||
@@ -329,6 +338,62 @@ export default function SearchPage({
|
||||
{ label: 'Other', items: groupedResults.other },
|
||||
]
|
||||
|
||||
const indexedPageCount = Math.max(1, Math.ceil(filteredResults.length / SEARCH_INDEXED_PAGE_SIZE))
|
||||
const paginatedFilteredResults = useMemo(() => {
|
||||
const safePage = Math.min(Math.max(indexedPage, 1), indexedPageCount)
|
||||
const start = (safePage - 1) * SEARCH_INDEXED_PAGE_SIZE
|
||||
return filteredResults.slice(start, start + SEARCH_INDEXED_PAGE_SIZE)
|
||||
}, [filteredResults, indexedPage, indexedPageCount])
|
||||
const paginatedGroupedResults = useMemo(
|
||||
() => ({
|
||||
tokens: paginatedFilteredResults.filter((result) => result.type === 'token'),
|
||||
addresses: paginatedFilteredResults.filter((result) => result.type === 'address'),
|
||||
transactions: paginatedFilteredResults.filter((result) => result.type === 'transaction'),
|
||||
blocks: paginatedFilteredResults.filter((result) => result.type === 'block'),
|
||||
other: paginatedFilteredResults.filter((result) => !['token', 'address', 'transaction', 'block'].includes(result.type)),
|
||||
}),
|
||||
[paginatedFilteredResults],
|
||||
)
|
||||
const paginatedResultSections = useMemo(
|
||||
() => [
|
||||
{ label: 'Tokens', items: paginatedGroupedResults.tokens },
|
||||
{ label: 'Addresses', items: paginatedGroupedResults.addresses },
|
||||
{ label: 'Transactions', items: paginatedGroupedResults.transactions },
|
||||
{ label: 'Blocks', items: paginatedGroupedResults.blocks },
|
||||
{ label: 'Other', items: paginatedGroupedResults.other },
|
||||
],
|
||||
[paginatedGroupedResults],
|
||||
)
|
||||
const searchTabs = useMemo<SectionTab<SearchResultsTab>[]>(() => {
|
||||
const tabs: SectionTab<SearchResultsTab>[] = []
|
||||
if (filteredResults.length > 0) tabs.push({ id: 'indexed', label: 'Indexed', count: filteredResults.length })
|
||||
if (meshSeed && (meshLoading || meshRows.length > 0)) tabs.push({ id: 'mesh', label: 'Mesh', count: meshRows.length })
|
||||
if (poolMatches.length > 0 && hasSearched) tabs.push({ id: 'pools', label: 'Pools', count: poolMatches.length })
|
||||
if (filteredWrappedMatches.length > 0 && meshRows.length === 0 && !meshLoading) {
|
||||
tabs.push({ id: 'wrapped', label: 'Wrapped', count: filteredWrappedMatches.length })
|
||||
}
|
||||
return tabs
|
||||
}, [
|
||||
filteredResults.length,
|
||||
filteredWrappedMatches.length,
|
||||
hasSearched,
|
||||
meshLoading,
|
||||
meshRows.length,
|
||||
meshSeed,
|
||||
poolMatches.length,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
setIndexedPage(1)
|
||||
}, [trimmedQuery, filterMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTabs.length === 0) return
|
||||
if (!searchTabs.some((tab) => tab.id === activeSearchTab)) {
|
||||
setActiveSearchTab(searchTabs[0].id)
|
||||
}
|
||||
}, [activeSearchTab, searchTabs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!meshSeed || !hasSearched) {
|
||||
setMeshRows([])
|
||||
@@ -413,21 +478,19 @@ export default function SearchPage({
|
||||
}, [filteredResults])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Explorer Lookup"
|
||||
title="Search"
|
||||
description={
|
||||
mode === 'guided'
|
||||
? 'Search by address, transaction hash, block number, token symbol, or cW*/c* wrapped transport symbols. Mesh counterparts resolve across chains via token-mapping; Chain 138 uses indexed search.'
|
||||
: 'Search tx / addr / block / token / mesh counterparts (token-mapping + cW registry).'
|
||||
? 'Search by address, transaction, block, token, or mesh/cW* symbols.'
|
||||
: 'Search tx / addr / block / token / mesh.'
|
||||
}
|
||||
actions={[
|
||||
{ href: '/tokens', label: 'Token shortcuts' },
|
||||
{ href: '/protocols', label: 'Protocol contracts' },
|
||||
{ href: '/pools', label: 'Pool registry' },
|
||||
{ href: '/addresses', label: 'Browse addresses' },
|
||||
{ href: '/watchlist', label: 'Open watchlist' },
|
||||
{ href: '/tokens', label: 'Tokens' },
|
||||
{ href: '/pools', label: 'Pools' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -470,6 +533,25 @@ export default function SearchPage({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && hasSearched && searchTabs.length > 0 ? (
|
||||
<>
|
||||
<CompactMetricBar
|
||||
metrics={[
|
||||
{ label: 'Indexed', value: String(filteredResults.length) },
|
||||
{ label: 'Mesh', value: String(meshRows.length) },
|
||||
{ label: 'Pools', value: String(poolMatches.length) },
|
||||
{ label: 'Wrapped', value: String(filteredWrappedMatches.length) },
|
||||
]}
|
||||
/>
|
||||
<SectionTabs
|
||||
tabs={searchTabs}
|
||||
activeTab={activeSearchTab}
|
||||
onChange={setActiveSearchTab}
|
||||
idPrefix="search-results"
|
||||
ariaLabel="Search result sections"
|
||||
className="-mx-4 mb-4 px-4"
|
||||
/>
|
||||
<TabPanel idPrefix="search-results" tabId="indexed" activeTab={activeSearchTab}>
|
||||
{!loading && tokenTarget && !meshSeed && (
|
||||
<Card className="mb-6" title="Direct Token Match">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -523,174 +605,6 @@ export default function SearchPage({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && poolMatches.length > 0 && hasSearched && (
|
||||
<Card title="Curated liquidity pools">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Matches from the DODO PMM + UniV2 pool registry on Chain 138. LP receipt tokens stay chain-local — bridge
|
||||
underlying c*/cW* instead.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{poolMatches.slice(0, 12).map((row) => (
|
||||
<Link
|
||||
key={`${row.poolAddress}-${row.matchReason}`}
|
||||
href={`/pools/${row.poolAddress}`}
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={row.pairLabel} tone="info" />
|
||||
<EntityBadge label={row.venue.replace('_', ' ')} tone="neutral" />
|
||||
<EntityBadge label={row.matchReason} tone="success" className="normal-case tracking-normal" />
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Pool <Address address={row.poolAddress} truncate showCopy={false} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && meshSeed && (meshLoading || meshRows.length > 0) && (
|
||||
<Card title="Mesh counterparts">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{mode === 'guided'
|
||||
? `Resolved ${meshSeed.hubSymbol} hub on Chain 138 and mapped ${meshSeed.wrappedSymbol} addresses across configured transport pairs via token-mapping.`
|
||||
: `${meshSeed.hubSymbol} ↔ ${meshSeed.wrappedSymbol} mesh (${meshSeed.matchReason}).`}
|
||||
</p>
|
||||
{polygonMapper?.officialPolygonTokenList?.upstreamRepo ? (
|
||||
<p className="mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
Polygon (137) rows align with{' '}
|
||||
<a
|
||||
href={polygonMapper.officialPolygonTokenList.submissionDoc || polygonMapper.officialPolygonTokenList.upstreamRepo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
official Polygon Token Mapper
|
||||
</a>
|
||||
{polygonMapper.match?.polygonAddress
|
||||
? ` — matched ${polygonMapper.match.wrappedSymbol} at ${polygonMapper.match.polygonAddress.slice(0, 10)}…`
|
||||
: ''}
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
{meshLoading ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Resolving mesh addresses…</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{meshRows.map((row) => {
|
||||
const href = externalChainExplorerUrl(row.chainId, row.address)
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={row.symbol} tone="info" />
|
||||
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
|
||||
<EntityBadge label={row.chainName} tone="warning" />
|
||||
<EntityBadge
|
||||
label={row.role === 'hub' ? 'hub (138)' : row.mappedVia === 'token-mapping' ? 'mapped' : 'registry'}
|
||||
tone={row.role === 'hub' ? 'success' : 'info'}
|
||||
className="normal-case tracking-normal"
|
||||
/>
|
||||
{row.chainId === 137 && polygonMapper ? (
|
||||
<EntityBadge label="Polygon mapper" tone="warning" className="normal-case tracking-normal" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Address address={row.address} truncate showCopy />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
if (!href) {
|
||||
return (
|
||||
<div key={`mesh-${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (row.chainId === 138) {
|
||||
return (
|
||||
<Link
|
||||
key={`mesh-${row.chainId}-${row.address}`}
|
||||
href={href}
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={`mesh-${row.chainId}-${row.address}`}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && filteredWrappedMatches.length > 0 && meshRows.length === 0 && !meshLoading && (
|
||||
<Card title="Wrapped transport tokens (cross-network)">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Matches from the live cW* registry across public networks. Chain 138 tokens open here; other chains link to native explorers.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{filteredWrappedMatches.slice(0, 24).map((row) => {
|
||||
const href = externalChainExplorerUrl(row.chainId, row.address)
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={row.symbol} tone="info" />
|
||||
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
|
||||
<EntityBadge label={row.chainName} tone="warning" />
|
||||
<EntityBadge label="cW registry" tone="success" />
|
||||
<EntityBadge label={row.matchReason} tone="info" className="normal-case tracking-normal" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Address address={row.address} truncate showCopy />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
if (!href) {
|
||||
return (
|
||||
<div key={`${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (row.chainId === 138) {
|
||||
return (
|
||||
<Link
|
||||
key={`${row.chainId}-${row.address}`}
|
||||
href={href}
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={`${row.chainId}-${row.address}`}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !tokenTarget && directTarget && !deferAddressJump && (
|
||||
<Card className="mb-6" title="Direct Match">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -724,7 +638,7 @@ export default function SearchPage({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
{filteredResults.length > 0 && (
|
||||
<Card title="Indexed search results">
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -751,7 +665,7 @@ export default function SearchPage({
|
||||
GRU guide →
|
||||
</Link>
|
||||
</div>
|
||||
{resultSections.map((section) =>
|
||||
{paginatedResultSections.map((section) =>
|
||||
section.items.length > 0 ? (
|
||||
<div key={section.label}>
|
||||
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">
|
||||
@@ -829,19 +743,197 @@ export default function SearchPage({
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
<PaginationControls
|
||||
page={indexedPage}
|
||||
pageCount={indexedPageCount}
|
||||
onPageChange={setIndexedPage}
|
||||
label="Indexed results"
|
||||
ariaLabel="Indexed search pagination"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && hasSearched && hasSupplementalMatches && filteredResults.length === 0 && !error ? (
|
||||
<Card className="mb-6" title="Registry matches">
|
||||
<Card className="mb-4" title="Registry matches">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Blockscout indexed search returned no hits for{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">{trimmedQuery}</span>, but curated mesh,
|
||||
pool, or wrapped-token registry matches are shown below.
|
||||
pool, or wrapped-token registry matches are shown in the other tabs.
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="search-results" tabId="pools" activeTab={activeSearchTab}>
|
||||
<Card title="Curated liquidity pools">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Matches from the DODO PMM + UniV2 pool registry on Chain 138. LP receipt tokens stay chain-local — bridge
|
||||
underlying c*/cW* instead.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{poolMatches.slice(0, 6).map((row) => (
|
||||
<Link
|
||||
key={`${row.poolAddress}-${row.matchReason}`}
|
||||
href={`/pools/${row.poolAddress}`}
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={row.pairLabel} tone="info" />
|
||||
<EntityBadge label={row.venue.replace('_', ' ')} tone="neutral" />
|
||||
<EntityBadge label={row.matchReason} tone="success" className="normal-case tracking-normal" />
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Pool <Address address={row.poolAddress} truncate showCopy={false} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="search-results" tabId="mesh" activeTab={activeSearchTab}>
|
||||
<Card title="Mesh counterparts">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{mode === 'guided'
|
||||
? `Resolved ${meshSeed?.hubSymbol} hub on Chain 138 and mapped ${meshSeed?.wrappedSymbol} addresses across configured transport pairs via token-mapping.`
|
||||
: `${meshSeed?.hubSymbol} ↔ ${meshSeed?.wrappedSymbol} mesh (${meshSeed?.matchReason}).`}
|
||||
</p>
|
||||
{polygonMapper?.officialPolygonTokenList?.upstreamRepo ? (
|
||||
<p className="mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
Polygon (137) rows align with{' '}
|
||||
<a
|
||||
href={polygonMapper.officialPolygonTokenList.submissionDoc || polygonMapper.officialPolygonTokenList.upstreamRepo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
official Polygon Token Mapper
|
||||
</a>
|
||||
{polygonMapper.match?.polygonAddress
|
||||
? ` — matched ${polygonMapper.match.wrappedSymbol} at ${polygonMapper.match.polygonAddress.slice(0, 10)}…`
|
||||
: ''}
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
{meshLoading ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Resolving mesh addresses…</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{meshRows.map((row) => {
|
||||
const href = externalChainExplorerUrl(row.chainId, row.address)
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={row.symbol} tone="info" />
|
||||
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
|
||||
<EntityBadge label={row.chainName} tone="warning" />
|
||||
<EntityBadge
|
||||
label={row.role === 'hub' ? 'hub (138)' : row.mappedVia === 'token-mapping' ? 'mapped' : 'registry'}
|
||||
tone={row.role === 'hub' ? 'success' : 'info'}
|
||||
className="normal-case tracking-normal"
|
||||
/>
|
||||
{row.chainId === 137 && polygonMapper ? (
|
||||
<EntityBadge label="Polygon mapper" tone="warning" className="normal-case tracking-normal" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Address address={row.address} truncate showCopy />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
if (!href) {
|
||||
return (
|
||||
<div key={`mesh-${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (row.chainId === 138) {
|
||||
return (
|
||||
<Link
|
||||
key={`mesh-${row.chainId}-${row.address}`}
|
||||
href={href}
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={`mesh-${row.chainId}-${row.address}`}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel idPrefix="search-results" tabId="wrapped" activeTab={activeSearchTab}>
|
||||
<Card title="Wrapped transport tokens (cross-network)">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Matches from the live cW* registry across public networks. Chain 138 tokens open here; other chains link to native explorers.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{filteredWrappedMatches.slice(0, 8).map((row) => {
|
||||
const href = externalChainExplorerUrl(row.chainId, row.address)
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={row.symbol} tone="info" />
|
||||
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
|
||||
<EntityBadge label={row.chainName} tone="warning" />
|
||||
<EntityBadge label="cW registry" tone="success" />
|
||||
<EntityBadge label={row.matchReason} tone="info" className="normal-case tracking-normal" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Address address={row.address} truncate showCopy />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
if (!href) {
|
||||
return (
|
||||
<div key={`${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (row.chainId === 138) {
|
||||
return (
|
||||
<Link
|
||||
key={`${row.chainId}-${row.address}`}
|
||||
href={href}
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:underline dark:border-gray-700"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={`${row.chainId}-${row.address}`}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:underline dark:border-gray-700"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!loading && hasSearched && !error && filteredResults.length === 0 && !hasSupplementalMatches && (
|
||||
<Card title="No Results Found">
|
||||
@@ -865,6 +957,7 @@ export default function SearchPage({
|
||||
)}
|
||||
|
||||
{!loading && !hasSearched && (
|
||||
<DisclosureSection title="Popular starting points" defaultOpen={false} alwaysCollapsible>
|
||||
<Card title="Popular Starting Points">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
@@ -897,6 +990,7 @@ export default function SearchPage({
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</DisclosureSection>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -923,21 +1017,10 @@ export const getServerSideProps: GetServerSideProps<SearchPageProps> = async (co
|
||||
}
|
||||
}
|
||||
|
||||
const shouldFetchSearch =
|
||||
Boolean(initialQuery) &&
|
||||
!inferTokenSearchTarget(initialQuery, initialCuratedTokens) &&
|
||||
!inferDirectSearchTarget(initialQuery)
|
||||
|
||||
const searchResult = shouldFetchSearch
|
||||
? await fetchPublicJson<{ items?: RawExplorerSearchItem[] }>(
|
||||
`/api/v2/search?q=${encodeURIComponent(initialQuery)}`,
|
||||
).catch(() => null)
|
||||
: null
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialQuery,
|
||||
initialRawResults: Array.isArray(searchResult?.items) ? searchResult.items : [],
|
||||
initialRawResults: [],
|
||||
initialCuratedTokens,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { GetServerSideProps } from 'next'
|
||||
import SystemOperationsPage from '@/components/explorer/SystemOperationsPage'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces'
|
||||
import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse } from '@/services/api/config'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import type { RouteMatrixResponse } from '@/services/api/routes'
|
||||
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
|
||||
interface SystemPageProps {
|
||||
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||
@@ -20,24 +18,13 @@ export default function SystemPage(props: SystemPageProps) {
|
||||
return <SystemOperationsPage {...props} />
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<SystemPageProps> = async () => {
|
||||
const [bridgeStatus, networksConfig, tokenList, capabilities, routeMatrix, stats] = await Promise.all([
|
||||
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
||||
fetchPublicJson<NetworksConfigResponse>('/api/config/networks').catch(() => null),
|
||||
loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
|
||||
fetchPublicJson<CapabilitiesResponse>('/api/config/capabilities').catch(() => null),
|
||||
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
||||
fetchPublicJson('/api/v2/stats').then((value) => normalizeExplorerStats(value as never)).catch(() => null),
|
||||
])
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialBridgeStatus: bridgeStatus,
|
||||
initialNetworksConfig: networksConfig,
|
||||
initialTokenList: tokenList,
|
||||
initialCapabilities: capabilities,
|
||||
initialRouteMatrix: routeMatrix,
|
||||
initialStats: stats,
|
||||
},
|
||||
}
|
||||
}
|
||||
export const getServerSideProps: GetServerSideProps<SystemPageProps> = async () => ({
|
||||
props: {
|
||||
initialBridgeStatus: null,
|
||||
initialNetworksConfig: null,
|
||||
initialTokenList: null,
|
||||
initialCapabilities: null,
|
||||
initialRouteMatrix: null,
|
||||
initialStats: null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -14,12 +14,13 @@ import PostureBadge from '@/components/common/PostureBadge'
|
||||
import GruStandardsCard from '@/components/common/GruStandardsCard'
|
||||
import TokenSigningSurfaceCard from '@/components/common/TokenSigningSurfaceCard'
|
||||
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import AddTokenToWalletButton from '@/components/wallet/AddTokenToWalletButton'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { useDetailTabQuery } from '@/utils/useDetailTabQuery'
|
||||
|
||||
const TOKEN_DETAIL_TABS_ID = 'token-detail'
|
||||
import { formatTokenAmount, formatTimestamp } from '@/utils/format'
|
||||
import { formatInteger, formatTokenAmount, formatTimestamp } from '@/utils/format'
|
||||
import { useDisplayCurrency } from '@/components/common/DisplayCurrencyContext'
|
||||
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
|
||||
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
|
||||
@@ -165,7 +166,7 @@ export default function TokenDetailPage() {
|
||||
if (provenanceTags.includes('compliant')) signals.push('marked compliant')
|
||||
if (provenanceTags.includes('bridge')) signals.push('bridge-linked asset')
|
||||
if (liquiditySummary.poolCount > 0) signals.push(`${liquiditySummary.poolCount} related liquidity pool${liquiditySummary.poolCount === 1 ? '' : 's'}`)
|
||||
if ((token?.holders || 0) > 0) signals.push(`${token?.holders?.toLocaleString()} indexed holders`)
|
||||
if ((token?.holders || 0) > 0) signals.push(`${formatInteger(token?.holders ?? 0)} indexed holders`)
|
||||
return signals
|
||||
}, [liquiditySummary.poolCount, provenance?.listed, provenanceTags, token?.holders])
|
||||
|
||||
@@ -342,24 +343,24 @@ export default function TokenDetailPage() {
|
||||
},
|
||||
{
|
||||
header: 'TVL',
|
||||
accessor: (pool: MissionControlLiquidityPool) => pool.tvl != null ? `$${Math.round(pool.tvl).toLocaleString()}` : 'N/A',
|
||||
accessor: (pool: MissionControlLiquidityPool) => pool.tvl != null ? `$${formatInteger(Math.round(pool.tvl))}` : 'N/A',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Token Detail"
|
||||
title={token?.symbol || token?.name || provenance?.symbol || provenance?.name || 'Token'}
|
||||
description="Inspect token supply, holders, transfers, and liquidity context with the sort of composure one normally has to borrow from a better explorer."
|
||||
description="Supply, holders, transfers, standards, and liquidity on Chain 138."
|
||||
actions={[
|
||||
{ href: '/tokens', label: 'Token index' },
|
||||
{ href: '/liquidity', label: 'Liquidity access' },
|
||||
{ href: '/search', label: 'Explorer search' },
|
||||
{ href: '/liquidity', label: 'Liquidity' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<div className="mb-4 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/tokens" className="text-primary-600 hover:underline">
|
||||
Back to tokens
|
||||
</Link>
|
||||
@@ -402,6 +403,16 @@ export default function TokenDetailPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
<Card title="Token Overview">
|
||||
<div className="mb-4 border-b border-gray-200 pb-4 dark:border-gray-700">
|
||||
<AddTokenToWalletButton
|
||||
address={token.address}
|
||||
symbol={token.symbol || provenance?.symbol || 'TOKEN'}
|
||||
decimals={token.decimals}
|
||||
name={token.name || provenance?.name}
|
||||
logoURI={provenance?.logoURI || token.icon_url || undefined}
|
||||
chainId={138}
|
||||
/>
|
||||
</div>
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Name">{token.name || provenance?.name || 'Unknown'}</DetailRow>
|
||||
<DetailRow label="Symbol">{token.symbol || provenance?.symbol || 'Unknown'}</DetailRow>
|
||||
@@ -434,7 +445,7 @@ export default function TokenDetailPage() {
|
||||
</DetailRow>
|
||||
)}
|
||||
{token.holders != null && (
|
||||
<DetailRow label="Holders">{token.holders.toLocaleString()}</DetailRow>
|
||||
<DetailRow label="Holders">{formatInteger(token.holders)}</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Provenance" valueClassName="flex flex-wrap gap-2">
|
||||
{provenanceTags.length > 0 ? provenanceTags.map((tag) => (
|
||||
@@ -464,7 +475,7 @@ export default function TokenDetailPage() {
|
||||
ariaLabel="Token details"
|
||||
/>
|
||||
|
||||
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'intelligence', activeTab)}>
|
||||
<TabPanel idPrefix={TOKEN_DETAIL_TABS_ID} tabId="intelligence" activeTab={activeTab}>
|
||||
<Card title="Token Intelligence">
|
||||
<div className="grid gap-4 lg: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">
|
||||
@@ -486,7 +497,7 @@ export default function TokenDetailPage() {
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Liquidity & Distribution</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div>Related pools: {liquiditySummary.poolCount.toLocaleString()}</div>
|
||||
<div>Related pools: {formatInteger(liquiditySummary.poolCount)}</div>
|
||||
<div>Total visible TVL: {formatUsd(liquiditySummary.totalTvl)}</div>
|
||||
<div>Largest visible holder: {holderConcentration != null ? `${holderConcentration}% of supply` : 'Unavailable'}</div>
|
||||
</div>
|
||||
@@ -494,9 +505,9 @@ export default function TokenDetailPage() {
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Transfer Activity</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div>Recent transfer sample: {transferFlowSummary.sampleSize.toLocaleString()}</div>
|
||||
<div>Unique senders: {transferFlowSummary.uniqueSenders.toLocaleString()}</div>
|
||||
<div>Unique recipients: {transferFlowSummary.uniqueRecipients.toLocaleString()}</div>
|
||||
<div>Recent transfer sample: {formatInteger(transferFlowSummary.sampleSize)}</div>
|
||||
<div>Unique senders: {formatInteger(transferFlowSummary.uniqueSenders)}</div>
|
||||
<div>Unique recipients: {formatInteger(transferFlowSummary.uniqueRecipients)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
@@ -511,9 +522,9 @@ export default function TokenDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'standards', activeTab)}>
|
||||
<TabPanel idPrefix={TOKEN_DETAIL_TABS_ID} tabId="standards" activeTab={activeTab}>
|
||||
{gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
|
||||
|
||||
{gruExplorerMetadata ? (
|
||||
@@ -594,9 +605,9 @@ export default function TokenDetailPage() {
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'holders', activeTab)}>
|
||||
<TabPanel idPrefix={TOKEN_DETAIL_TABS_ID} tabId="holders" activeTab={activeTab}>
|
||||
<Card title="Top Holders">
|
||||
<Table
|
||||
layout="tabular"
|
||||
@@ -607,9 +618,9 @@ export default function TokenDetailPage() {
|
||||
/>
|
||||
<PaginationControls page={holderPage} pageCount={holderPageCount} onPageChange={setHolderPage} label="Holders" />
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'transfers', activeTab)}>
|
||||
<TabPanel idPrefix={TOKEN_DETAIL_TABS_ID} tabId="transfers" activeTab={activeTab}>
|
||||
<Card title="Recent Transfers">
|
||||
{gruExplorerMetadata ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -632,9 +643,9 @@ export default function TokenDetailPage() {
|
||||
/>
|
||||
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Transfers" />
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'liquidity', activeTab)}>
|
||||
<TabPanel idPrefix={TOKEN_DETAIL_TABS_ID} tabId="liquidity" activeTab={activeTab}>
|
||||
<Card title="Related Liquidity">
|
||||
<Table
|
||||
columns={poolColumns}
|
||||
@@ -644,7 +655,7 @@ export default function TokenDetailPage() {
|
||||
/>
|
||||
<PaginationControls page={poolPage} pageCount={poolPageCount} onPageChange={setPoolPage} label="Pools" />
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { GetStaticProps } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import ListFilterBar from '@/components/common/ListFilterBar'
|
||||
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { tokensApi, type IndexedTokenListItem } from '@/services/api/tokens'
|
||||
import type { TokenListToken } from '@/services/api/config'
|
||||
import {
|
||||
@@ -14,8 +17,9 @@ import {
|
||||
sortIndexedTokensCanonicalFirst,
|
||||
} from '@/utils/canonicalTokens'
|
||||
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
|
||||
import { fetchTokenListForSurface, TOKEN_LIST_SURFACE_LABELS } from '@/services/api/tokenListSurfaces'
|
||||
import { fetchTokenListForSurface } from '@/services/api/tokenListSurfaces'
|
||||
import { selectCuratedFeaturedTokens } from '@/utils/featuredTokens'
|
||||
import { formatInteger } from '@/utils/format'
|
||||
|
||||
const quickSearches = [
|
||||
{ label: 'cUSDT', description: 'Canonical compliant USD treasury / government bond liquidity and address results.' },
|
||||
@@ -26,11 +30,23 @@ const quickSearches = [
|
||||
{ label: 'USDT', description: 'Native-side USDT routes and address discovery.' },
|
||||
]
|
||||
|
||||
const CURATED_PAGE_SIZE = 6
|
||||
const INDEXED_PAGE_SIZE = 12
|
||||
|
||||
type TokensPageTab = 'curated' | 'indexed' | 'shortcuts'
|
||||
|
||||
function normalizeAddress(value: string) {
|
||||
const trimmed = value.trim()
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(trimmed) ? trimmed : ''
|
||||
}
|
||||
|
||||
function matchesTokenQuery(token: { symbol?: string | null; name?: string | null; address?: string | null }, query: string) {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
if (!normalizedQuery) return true
|
||||
const haystack = [token.symbol, token.name, token.address].filter(Boolean).join(' ').toLowerCase()
|
||||
return haystack.includes(normalizedQuery)
|
||||
}
|
||||
|
||||
interface TokensPageProps {
|
||||
initialCuratedTokens: TokenListToken[]
|
||||
}
|
||||
@@ -59,7 +75,11 @@ function tagPriority(tag: string) {
|
||||
|
||||
export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
|
||||
const router = useRouter()
|
||||
const [query, setQuery] = useState('')
|
||||
const [lookupQuery, setLookupQuery] = useState('')
|
||||
const [listQuery, setListQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<TokensPageTab>('curated')
|
||||
const [curatedPage, setCuratedPage] = useState(1)
|
||||
const [indexedPage, setIndexedPage] = useState(1)
|
||||
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>(initialCuratedTokens)
|
||||
const [indexedTokens, setIndexedTokens] = useState<IndexedTokenListItem[]>([])
|
||||
const [indexedLoading, setIndexedLoading] = useState(true)
|
||||
@@ -67,8 +87,8 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const normalized = normalizeAddress(query)
|
||||
router.push(normalized ? `/tokens/${normalized}` : `/search?q=${encodeURIComponent(query.trim())}`)
|
||||
const normalized = normalizeAddress(lookupQuery)
|
||||
router.push(normalized ? `/tokens/${normalized}` : `/search?q=${encodeURIComponent(lookupQuery.trim())}`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -104,26 +124,50 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
|
||||
[canonicalAddressSet, indexedTokens],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const filteredCuratedTokens = useMemo(
|
||||
() => featuredCuratedTokens.filter((token) => matchesTokenQuery(token, listQuery)),
|
||||
[featuredCuratedTokens, listQuery],
|
||||
)
|
||||
|
||||
const filteredIndexedTokens = useMemo(
|
||||
() => sortedIndexedTokens.filter((token) => matchesTokenQuery(token, listQuery)),
|
||||
[listQuery, sortedIndexedTokens],
|
||||
)
|
||||
|
||||
const curatedPageCount = Math.max(1, Math.ceil(filteredCuratedTokens.length / CURATED_PAGE_SIZE))
|
||||
const paginatedCuratedTokens = useMemo(() => {
|
||||
const safePage = Math.min(Math.max(curatedPage, 1), curatedPageCount)
|
||||
const start = (safePage - 1) * CURATED_PAGE_SIZE
|
||||
return filteredCuratedTokens.slice(start, start + CURATED_PAGE_SIZE)
|
||||
}, [curatedPage, curatedPageCount, filteredCuratedTokens])
|
||||
|
||||
const loadIndexedTokens = useCallback(async (page: number) => {
|
||||
setIndexedLoading(true)
|
||||
|
||||
tokensApi.listIndexedSafe(1, 50).then(({ ok, data }) => {
|
||||
if (!active) return
|
||||
try {
|
||||
const { ok, data } = await tokensApi.listIndexedSafe(page, INDEXED_PAGE_SIZE)
|
||||
setIndexedTokens(ok ? data : [])
|
||||
} catch {
|
||||
setIndexedTokens([])
|
||||
} finally {
|
||||
setIndexedLoading(false)
|
||||
}).catch(() => {
|
||||
if (active) {
|
||||
setIndexedTokens([])
|
||||
setIndexedLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadIndexedTokens(indexedPage)
|
||||
}, [indexedPage, loadIndexedTokens])
|
||||
|
||||
useEffect(() => {
|
||||
setCuratedPage(1)
|
||||
setIndexedPage(1)
|
||||
}, [listQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (curatedPage > curatedPageCount) {
|
||||
setCuratedPage(curatedPageCount)
|
||||
}
|
||||
}, [curatedPage, curatedPageCount])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
@@ -146,103 +190,148 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
|
||||
}
|
||||
}, [featuredCuratedTokens])
|
||||
|
||||
const tokenTabs = useMemo<SectionTab<TokensPageTab>[]>(
|
||||
() => [
|
||||
{ id: 'curated', label: 'Curated', count: featuredCuratedTokens.length },
|
||||
{ id: 'indexed', label: 'Indexed', count: sortedIndexedTokens.length },
|
||||
{ id: 'shortcuts', label: 'Quick searches', count: quickSearches.length },
|
||||
],
|
||||
[featuredCuratedTokens.length, sortedIndexedTokens.length],
|
||||
)
|
||||
|
||||
const indexedHasNextPage = indexedTokens.length === INDEXED_PAGE_SIZE
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="container mx-auto px-4 py-5 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Token Discovery"
|
||||
title="Tokens"
|
||||
description="Browse the canonical Chain 138 trading set, open token contracts directly, and review holders, transfers, liquidity, and provenance from the same institutional explorer surface."
|
||||
description="Browse canonical Chain 138 tokens, open contracts, and review holders, transfers, and liquidity."
|
||||
actions={[
|
||||
{ href: '/wallet', label: 'Wallet tools' },
|
||||
{ href: '/liquidity', label: 'Liquidity access' },
|
||||
{ href: '/search', label: 'Explorer search' },
|
||||
{ href: '/search', label: 'Search' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card className="mb-5" title="Find a token">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Token symbol, name, or contract address"
|
||||
className="min-h-10 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-950"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!query.trim()}
|
||||
className="min-h-10 rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
<SectionTabs
|
||||
tabs={tokenTabs}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
idPrefix="tokens-page"
|
||||
ariaLabel="Token list sections"
|
||||
className="-mx-4 mb-4 px-4"
|
||||
/>
|
||||
|
||||
<div className="mt-5">
|
||||
<Card title="Curated Chain 138 tokens">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{TOKEN_LIST_SURFACE_LABELS.catalog}. Showing {featuredCuratedTokens.length} featured tokens from the live report list.
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{featuredCuratedTokens
|
||||
.filter((token): token is TokenListToken & { address: string } => typeof token.address === 'string' && token.address.trim().length > 0)
|
||||
.map((token) => {
|
||||
const market = featuredMarkets[token.address.toLowerCase()]?.market
|
||||
return (
|
||||
<Link
|
||||
key={token.address}
|
||||
href={`/tokens/${token.address}`}
|
||||
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-950/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.symbol || token.name || 'Token'}</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm leading-5 text-gray-600 dark:text-gray-400">
|
||||
{token.name || 'Listed in the Chain 138 token registry.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{market ? (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-950/60">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Price</div>
|
||||
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.priceUsd)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-950/60">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Liquidity</div>
|
||||
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.liquidityUsd)}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<MarketEvidenceNote lastUpdated={market.lastUpdated} compact />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{token.tags && token.tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{[...token.tags].sort((a, b) => tagPriority(a) - tagPriority(b)).slice(0, 3).map((tag) => (
|
||||
<EntityBadge key={`${token.address}-${tag}`} label={tag} className="px-2 py-1 text-[11px]" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<TabPanel idPrefix="tokens-page" tabId="curated" activeTab={activeTab}>
|
||||
<Card title="Find a token">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
value={lookupQuery}
|
||||
onChange={(event) => setLookupQuery(event.target.value)}
|
||||
placeholder="Token symbol, name, or contract address"
|
||||
className="min-h-10 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-950"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!lookupQuery.trim()}
|
||||
className="min-h-10 rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="mt-4" title="Curated Chain 138 tokens">
|
||||
<ListFilterBar
|
||||
query={listQuery}
|
||||
onQueryChange={setListQuery}
|
||||
placeholder="Filter curated tokens by symbol, name, or address"
|
||||
resultCount={filteredCuratedTokens.length}
|
||||
totalCount={featuredCuratedTokens.length}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{paginatedCuratedTokens.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 md:col-span-2 xl:col-span-3">
|
||||
No curated tokens match your filter.
|
||||
</p>
|
||||
) : (
|
||||
paginatedCuratedTokens
|
||||
.filter((token): token is TokenListToken & { address: string } => typeof token.address === 'string' && token.address.trim().length > 0)
|
||||
.map((token) => {
|
||||
const market = featuredMarkets[token.address.toLowerCase()]?.market
|
||||
return (
|
||||
<Link
|
||||
key={token.address}
|
||||
href={`/tokens/${token.address}`}
|
||||
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-950/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.symbol || token.name || 'Token'}</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm leading-5 text-gray-600 dark:text-gray-400">
|
||||
{token.name || 'Listed in the Chain 138 token registry.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{market ? (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-950/60">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Price</div>
|
||||
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.priceUsd)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-950/60">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Liquidity</div>
|
||||
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.liquidityUsd)}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<MarketEvidenceNote lastUpdated={market.lastUpdated} compact />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{token.tags && token.tags.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{[...token.tags].sort((a, b) => tagPriority(a) - tagPriority(b)).slice(0, 3).map((tag) => (
|
||||
<EntityBadge key={`${token.address}-${tag}`} label={tag} className="px-2 py-1 text-[11px]" />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<PaginationControls
|
||||
page={curatedPage}
|
||||
pageCount={curatedPageCount}
|
||||
onPageChange={setCuratedPage}
|
||||
label="Curated tokens"
|
||||
ariaLabel="Curated token pagination"
|
||||
/>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<div className="mt-5">
|
||||
<TabPanel idPrefix="tokens-page" tabId="indexed" activeTab={activeTab}>
|
||||
<Card title="Indexed tokens (Blockscout)">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Canonical registry tokens appear first. Non-canonical indexed duplicates are labeled so operators can distinguish curated assets from stray contract listings.
|
||||
</p>
|
||||
<ListFilterBar
|
||||
query={listQuery}
|
||||
onQueryChange={setListQuery}
|
||||
placeholder="Filter indexed tokens on this page"
|
||||
resultCount={filteredIndexedTokens.length}
|
||||
totalCount={sortedIndexedTokens.length}
|
||||
/>
|
||||
{indexedLoading ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading indexed token feed…</p>
|
||||
) : sortedIndexedTokens.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Indexed token feed is temporarily unavailable.</p>
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading indexed token feed…</p>
|
||||
) : filteredIndexedTokens.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{sortedIndexedTokens.length === 0
|
||||
? 'Indexed token feed is temporarily unavailable.'
|
||||
: 'No indexed tokens on this page match your filter.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{sortedIndexedTokens.map((token) => {
|
||||
<div className="mt-4 grid gap-2">
|
||||
{filteredIndexedTokens.map((token) => {
|
||||
const canonical = isCanonicalTokenAddress(token.address, canonicalAddressSet)
|
||||
return (
|
||||
<Link
|
||||
@@ -264,17 +353,25 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
|
||||
<p className="mt-1 truncate text-xs text-gray-600 dark:text-gray-400">{token.name || token.address}</p>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{token.holders != null ? `${token.holders.toLocaleString()} holders` : 'Holders unavailable'}
|
||||
{token.holders != null ? `${formatInteger(token.holders)} holders` : 'Holders unavailable'}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<PaginationControls
|
||||
page={indexedPage}
|
||||
onPageChange={setIndexedPage}
|
||||
label="Indexed tokens"
|
||||
ariaLabel="Indexed token pagination"
|
||||
disabled={indexedLoading}
|
||||
hasNextPage={indexedHasNextPage}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div className="mt-5">
|
||||
<TabPanel idPrefix="tokens-page" tabId="shortcuts" activeTab={activeTab}>
|
||||
<Card title="Common token searches">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{quickSearches.map((token) => (
|
||||
@@ -289,7 +386,7 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,13 +11,15 @@ import {
|
||||
TransactionLookupDiagnostic,
|
||||
TransactionTokenTransfer,
|
||||
} from '@/services/api/transactions'
|
||||
import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format'
|
||||
import { formatInteger, formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import PostureBadge from '@/components/common/PostureBadge'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { useDetailTabQuery } from '@/utils/useDetailTabQuery'
|
||||
|
||||
const TRANSACTION_DETAIL_TABS_ID = 'transaction-detail'
|
||||
@@ -388,29 +390,19 @@ export default function TransactionDetailPage() {
|
||||
}, [hash])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Transaction Detail"
|
||||
title="Transaction"
|
||||
description="Inspect a single transaction and pivot into its block, counterparties, or a broader explorer search when you need more context."
|
||||
title={transaction?.hash ? `${transaction.hash.slice(0, 10)}…` : 'Transaction'}
|
||||
description="Block, counterparties, token transfers, and internal calls."
|
||||
actions={[
|
||||
{ href: '/transactions', label: 'All transactions' },
|
||||
{ href: '/blocks', label: 'Recent blocks' },
|
||||
{ href: '/search', label: 'Search explorer' },
|
||||
{ href: '/search', label: 'Search' },
|
||||
...(transaction ? [{ href: `/blocks/${transaction.block_number}`, label: 'Open block' }] : []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
Back to transactions
|
||||
</Link>
|
||||
{(transaction?.hash || hash) && (
|
||||
<Link href={`/search?q=${encodeURIComponent(transaction?.hash || hash)}`} className="text-primary-600 hover:underline">
|
||||
Search this hash
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!router.isReady || loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading transaction...</p>
|
||||
@@ -451,7 +443,17 @@ export default function TransactionDetailPage() {
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<CompactMetricBar
|
||||
metrics={[
|
||||
{ label: 'Outcome', value: transaction.status === 1 ? 'Success' : 'Failed' },
|
||||
{ label: 'Gas', value: gasUtilization != null ? `${gasUtilization}%` : '—' },
|
||||
{ label: 'Value', value: formatWeiAsEth(transaction.value) },
|
||||
{ label: 'Block', value: `#${transaction.block_number}` },
|
||||
]}
|
||||
/>
|
||||
|
||||
<DisclosureSection title="Execution summary detail" defaultOpen={false} alwaysCollapsible>
|
||||
<Card title="Execution Summary">
|
||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
@@ -472,7 +474,7 @@ export default function TransactionDetailPage() {
|
||||
Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'}
|
||||
{nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
|
||||
</div>
|
||||
<div>Gas used: {transaction.gas_used != null ? transaction.gas_used.toLocaleString() : 'Unavailable'}</div>
|
||||
<div>Gas used: {transaction.gas_used != null ? formatInteger(transaction.gas_used) : 'Unavailable'}</div>
|
||||
<div>Utilization: {gasUtilization != null ? `${gasUtilization}%` : 'Unavailable'}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -491,8 +493,8 @@ export default function TransactionDetailPage() {
|
||||
{checkpointTotalUsd != null ? ` — total ${formatUsd(Number(checkpointTotalUsd))}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
<div>Token transfers: {tokenTransferCount.toLocaleString()}</div>
|
||||
<div>Internal calls: {internalCallCount.toLocaleString()}</div>
|
||||
<div>Token transfers: {formatInteger(tokenTransferCount)}</div>
|
||||
<div>Internal calls: {formatInteger(internalCallCount)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
@@ -513,25 +515,28 @@ export default function TransactionDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</DisclosureSection>
|
||||
|
||||
{checkpointAttestation ? (
|
||||
<DisclosureSection title="Mainnet attestation" defaultOpen={false} alwaysCollapsible>
|
||||
<MainnetAttestationPanel
|
||||
batchId={checkpointAttestation.batchId}
|
||||
batchTotalUsd={checkpointAttestation.batchTotalUsd}
|
||||
links={checkpointAttestation.etherscanLinks ?? []}
|
||||
/>
|
||||
</DisclosureSection>
|
||||
) : null}
|
||||
|
||||
<SectionTabs
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
className="mb-6"
|
||||
className="mb-4"
|
||||
idPrefix={TRANSACTION_DETAIL_TABS_ID}
|
||||
ariaLabel="Transaction details"
|
||||
/>
|
||||
|
||||
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'evidence', activeTab)}>
|
||||
<TabPanel idPrefix={TRANSACTION_DETAIL_TABS_ID} tabId="evidence" activeTab={activeTab}>
|
||||
{complianceAssessment ? (
|
||||
<Card title="Transaction Evidence Matrix">
|
||||
<div className="space-y-4">
|
||||
@@ -565,9 +570,9 @@ export default function TransactionDetailPage() {
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'details', activeTab)}>
|
||||
<TabPanel idPrefix={TRANSACTION_DETAIL_TABS_ID} tabId="details" activeTab={activeTab}>
|
||||
<Card title="Transaction Information">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Hash">
|
||||
@@ -616,7 +621,7 @@ export default function TransactionDetailPage() {
|
||||
</DetailRow>
|
||||
{transaction.gas_used != null && (
|
||||
<DetailRow label="Gas Used">
|
||||
{transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()}
|
||||
{formatInteger(transaction.gas_used)} / {formatInteger(transaction.gas_limit)}
|
||||
</DetailRow>
|
||||
)}
|
||||
{transaction.revert_reason && (
|
||||
@@ -662,9 +667,9 @@ export default function TransactionDetailPage() {
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'transfers', activeTab)}>
|
||||
<TabPanel idPrefix={TRANSACTION_DETAIL_TABS_ID} tabId="transfers" activeTab={activeTab}>
|
||||
<Card title="Token Transfers">
|
||||
<Table
|
||||
columns={tokenTransferColumns}
|
||||
@@ -674,9 +679,9 @@ export default function TransactionDetailPage() {
|
||||
/>
|
||||
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Token transfers" />
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'internal', activeTab)}>
|
||||
<TabPanel idPrefix={TRANSACTION_DETAIL_TABS_ID} tabId="internal" activeTab={activeTab}>
|
||||
<Card title="Internal Transactions">
|
||||
<Table
|
||||
columns={internalCallColumns}
|
||||
@@ -686,16 +691,16 @@ export default function TransactionDetailPage() {
|
||||
/>
|
||||
<PaginationControls page={internalPage} pageCount={internalPageCount} onPageChange={setInternalPage} label="Internal calls" />
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
{transaction.input_data ? (
|
||||
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'raw', activeTab)}>
|
||||
<TabPanel idPrefix={TRANSACTION_DETAIL_TABS_ID} tabId="raw" activeTab={activeTab}>
|
||||
<Card title="Raw Input Data">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all rounded-lg bg-gray-50 p-4 text-xs text-gray-800 dark:bg-gray-950 dark:text-gray-200">
|
||||
{transaction.input_data}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,18 +4,19 @@ 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'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { normalizeTransaction } from '@/services/api/blockscout'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import ListFilterBar from '@/components/common/ListFilterBar'
|
||||
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { useListPageQuery } from '@/utils/useListPageQuery'
|
||||
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
|
||||
|
||||
interface TransactionsPageProps {
|
||||
initialTransactions: Transaction[]
|
||||
@@ -44,21 +45,55 @@ function serializeTransactionList(transactions: Transaction[]): Transaction[] {
|
||||
) as Transaction[]
|
||||
}
|
||||
|
||||
type TransactionFilterTab = 'all' | 'success' | 'failed' | 'contracts' | 'transfers'
|
||||
|
||||
function matchesTransactionQuery(transaction: Transaction, query: string): boolean {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
if (!normalizedQuery) return true
|
||||
const fields = [
|
||||
transaction.hash,
|
||||
transaction.from_address,
|
||||
transaction.to_address || '',
|
||||
transaction.contract_address || '',
|
||||
]
|
||||
return fields.some((field) => field.toLowerCase().includes(normalizedQuery))
|
||||
}
|
||||
|
||||
function matchesTransactionFilter(transaction: Transaction, filter: TransactionFilterTab): boolean {
|
||||
switch (filter) {
|
||||
case 'success':
|
||||
return transaction.status === 1
|
||||
case 'failed':
|
||||
return transaction.status != null && transaction.status !== 1
|
||||
case 'contracts':
|
||||
return Boolean(transaction.contract_address)
|
||||
case 'transfers':
|
||||
return (transaction.token_transfers?.length || 0) > 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export default function TransactionsPage({
|
||||
initialTransactions,
|
||||
initialLatestBlocks,
|
||||
initialStats,
|
||||
initialBridgeStatus,
|
||||
}: TransactionsPageProps) {
|
||||
const pageSize = 20
|
||||
const pageSize = 15
|
||||
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
|
||||
const [latestBlocks, setLatestBlocks] = useState(initialLatestBlocks)
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [loading, setLoading] = useState(initialTransactions.length === 0)
|
||||
const [filterTab, setFilterTab] = useState<TransactionFilterTab>('all')
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
const { page, setPage } = useListPageQuery()
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const activityContext = useMemo(
|
||||
() =>
|
||||
summarizeChainActivity({
|
||||
blocks: initialLatestBlocks.map((block) => ({
|
||||
blocks: latestBlocks.map((block) => ({
|
||||
chain_id: chainId,
|
||||
number: block.number,
|
||||
hash: '',
|
||||
@@ -69,12 +104,12 @@ export default function TransactionsPage({
|
||||
gas_limit: 0,
|
||||
})),
|
||||
transactions,
|
||||
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
|
||||
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
|
||||
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
||||
diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null,
|
||||
latestBlockNumber: latestBlocks[0]?.number ?? null,
|
||||
latestBlockTimestamp: latestBlocks[0]?.timestamp ?? null,
|
||||
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
||||
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
|
||||
}),
|
||||
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, transactions],
|
||||
[chainId, bridgeStatus, latestBlocks, stats, transactions],
|
||||
)
|
||||
|
||||
const loadTransactions = useCallback(async () => {
|
||||
@@ -99,8 +134,59 @@ export default function TransactionsPage({
|
||||
void loadTransactions()
|
||||
}, [initialTransactions, loadTransactions, page])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLatestBlocks.length > 0 && initialStats && initialBridgeStatus) {
|
||||
setLatestBlocks(initialLatestBlocks)
|
||||
setStats(initialStats)
|
||||
setBridgeStatus(initialBridgeStatus)
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
Promise.all([
|
||||
fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>(
|
||||
'/api/v2/blocks?page=1&page_size=3',
|
||||
).catch(() => null),
|
||||
fetchExplorerTruthContext(),
|
||||
])
|
||||
.then(([blocksResult, truthContext]) => {
|
||||
if (!active) return
|
||||
if (initialLatestBlocks.length === 0 && Array.isArray(blocksResult?.items)) {
|
||||
setLatestBlocks(
|
||||
blocksResult.items
|
||||
.map((item) => ({
|
||||
number: Number(item.height || 0),
|
||||
timestamp: item.timestamp || '',
|
||||
}))
|
||||
.filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp),
|
||||
)
|
||||
}
|
||||
setStats((current) => current ?? truthContext.initialStats)
|
||||
setBridgeStatus((current) => current ?? truthContext.initialBridgeStatus)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [initialBridgeStatus, initialLatestBlocks, initialStats])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [filterTab, filterQuery, setPage])
|
||||
|
||||
const filteredTransactions = useMemo(
|
||||
() => transactions.filter((transaction) => matchesTransactionFilter(transaction, filterTab) && matchesTransactionQuery(transaction, filterQuery)),
|
||||
[filterQuery, filterTab, transactions],
|
||||
)
|
||||
|
||||
const paginatedTransactions = useMemo(() => {
|
||||
const start = (page - 1) * pageSize
|
||||
return filteredTransactions.slice(start, start + pageSize)
|
||||
}, [filteredTransactions, page, pageSize])
|
||||
|
||||
const transactionSummary = useMemo(() => {
|
||||
const sampleSize = transactions.length
|
||||
const sampleSize = paginatedTransactions.length
|
||||
if (sampleSize === 0) {
|
||||
return {
|
||||
sampleSize: 0,
|
||||
@@ -111,12 +197,12 @@ export default function TransactionsPage({
|
||||
}
|
||||
}
|
||||
|
||||
const successes = transactions.filter((transaction) => transaction.status === 1).length
|
||||
const contractCreations = transactions.filter((transaction) => Boolean(transaction.contract_address)).length
|
||||
const tokenTransferTransactions = transactions.filter(
|
||||
const successes = paginatedTransactions.filter((transaction) => transaction.status === 1).length
|
||||
const contractCreations = paginatedTransactions.filter((transaction) => Boolean(transaction.contract_address)).length
|
||||
const tokenTransferTransactions = paginatedTransactions.filter(
|
||||
(transaction) => (transaction.token_transfers?.length || 0) > 0,
|
||||
).length
|
||||
const feeValues = transactions
|
||||
const feeValues = paginatedTransactions
|
||||
.map((transaction) => {
|
||||
if (!transaction.fee) return null
|
||||
const numeric = Number(transaction.fee)
|
||||
@@ -135,10 +221,23 @@ export default function TransactionsPage({
|
||||
tokenTransferTransactions,
|
||||
averageFee,
|
||||
}
|
||||
}, [paginatedTransactions])
|
||||
|
||||
const filterTabs = useMemo<SectionTab<TransactionFilterTab>[]>(() => {
|
||||
const countFor = (filter: TransactionFilterTab) =>
|
||||
transactions.filter((transaction) => matchesTransactionFilter(transaction, filter)).length
|
||||
|
||||
return [
|
||||
{ id: 'all', label: 'All', count: transactions.length },
|
||||
{ id: 'success', label: 'Success', count: countFor('success') },
|
||||
{ id: 'failed', label: 'Failed', count: countFor('failed') },
|
||||
{ id: 'contracts', label: 'Contract creation', count: countFor('contracts') },
|
||||
{ id: 'transfers', label: 'Token transfers', count: countFor('transfers') },
|
||||
]
|
||||
}, [transactions])
|
||||
|
||||
const showPagination = page > 1 || transactions.length > 0
|
||||
const canGoNext = transactions.length === pageSize
|
||||
const showPagination = filteredTransactions.length > pageSize || page > 1
|
||||
const canGoNext = page * pageSize < filteredTransactions.length
|
||||
|
||||
const columns = [
|
||||
{
|
||||
@@ -188,56 +287,63 @@ export default function TransactionsPage({
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Indexed Flow"
|
||||
title="Transactions"
|
||||
description="Review recent Chain 138 transactions and move directly into the linked block, address, search, and watchlist surfaces from here."
|
||||
description="Recent Chain 138 transactions with filters for status, contract creation, and token transfers."
|
||||
actions={[
|
||||
{ href: '/blocks', label: 'Open blocks' },
|
||||
{ href: '/addresses', label: 'Browse addresses' },
|
||||
{ href: '/watchlist', label: 'Open watchlist' },
|
||||
{ href: '/blocks', label: 'Blocks' },
|
||||
{ href: '/search', label: 'Search' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Transaction Recency Context" />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={initialStats}
|
||||
bridgeStatus={initialBridgeStatus}
|
||||
scopeLabel="This page reflects the latest indexed visible transaction activity."
|
||||
/>
|
||||
</div>
|
||||
<ExplorerFreshnessDisclosure
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
activityTitle="Recency"
|
||||
scopeLabel="Indexed transaction activity on this page."
|
||||
/>
|
||||
|
||||
{!loading && transactions.length > 0 && (
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card>
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Sample Size</div>
|
||||
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.sampleSize.toLocaleString()}</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">Transactions on the current explorer page.</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Success Rate</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.successRate}%</span>
|
||||
<EntityBadge label={transactionSummary.successRate >= 90 ? 'healthy' : 'mixed'} tone={transactionSummary.successRate >= 90 ? 'success' : 'warning'} />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">Based on the visible recent transaction sample.</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Contract Creations</div>
|
||||
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.contractCreations.toLocaleString()}</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">New contracts created in the visible sample.</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Avg Sample Fee</div>
|
||||
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.averageFee || 'Unavailable'}</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Token-transfer txs: {transactionSummary.tokenTransferTransactions.toLocaleString()}
|
||||
</div>
|
||||
</Card>
|
||||
<SectionTabs
|
||||
tabs={filterTabs}
|
||||
activeTab={filterTab}
|
||||
onChange={setFilterTab}
|
||||
idPrefix="transactions-filter"
|
||||
ariaLabel="Transaction filters"
|
||||
className="-mx-4 mb-4 px-4"
|
||||
/>
|
||||
|
||||
<ListFilterBar
|
||||
query={filterQuery}
|
||||
onQueryChange={setFilterQuery}
|
||||
placeholder="Filter visible transactions by hash or address"
|
||||
resultCount={paginatedTransactions.length}
|
||||
totalCount={filteredTransactions.length}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{!loading && paginatedTransactions.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
|
||||
<span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{transactionSummary.sampleSize}</span> on page
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{transactionSummary.successRate}%</span> success
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{transactionSummary.contractCreations}</span> contracts
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{transactionSummary.tokenTransferTransactions}</span> with transfers
|
||||
</span>
|
||||
{transactionSummary.averageFee ? (
|
||||
<span>
|
||||
avg fee <span className="font-medium text-gray-900 dark:text-white">{transactionSummary.averageFee}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -248,8 +354,12 @@ export default function TransactionsPage({
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={transactions}
|
||||
emptyMessage="Recent transactions are unavailable right now."
|
||||
data={paginatedTransactions}
|
||||
emptyMessage={
|
||||
filterTab === 'all' && !filterQuery.trim()
|
||||
? 'Recent transactions are unavailable right now.'
|
||||
: 'No transactions on this page match the current filter.'
|
||||
}
|
||||
keyExtractor={(tx) => tx.hash}
|
||||
/>
|
||||
)}
|
||||
@@ -265,55 +375,25 @@ export default function TransactionsPage({
|
||||
className="mt-6"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 grid gap-4 lg:grid-cols-2">
|
||||
<Card title="Next Steps">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Use the linked hashes above to inspect detail pages, or pivot into block production, address activity, and explorer-wide search.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/blocks" className="text-primary-600 hover:underline">
|
||||
Blocks →
|
||||
</Link>
|
||||
<Link href="/addresses" className="text-primary-600 hover:underline">
|
||||
Addresses →
|
||||
</Link>
|
||||
<Link href="/search" className="text-primary-600 hover:underline">
|
||||
Search →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<TransactionsPageProps> = async () => {
|
||||
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([
|
||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null),
|
||||
fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null),
|
||||
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
|
||||
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
||||
])
|
||||
const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=15').catch(
|
||||
() => null,
|
||||
)
|
||||
const initialTransactions = Array.isArray(transactionsResult?.items)
|
||||
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
|
||||
: []
|
||||
const initialLatestBlocks = Array.isArray(blocksResult?.items)
|
||||
? blocksResult.items
|
||||
.map((item) => ({
|
||||
number: Number(item.height || 0),
|
||||
timestamp: item.timestamp || '',
|
||||
}))
|
||||
.filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp)
|
||||
: []
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialTransactions: serializeTransactionList(initialTransactions),
|
||||
initialLatestBlocks,
|
||||
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
|
||||
initialBridgeStatus: bridgeResult,
|
||||
initialLatestBlocks: [],
|
||||
initialStats: null,
|
||||
initialBridgeStatus: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
NetworksCatalog,
|
||||
TokenListCatalog,
|
||||
} from '@/components/wallet/AddToMetaMask'
|
||||
import { fetchPublicJsonWithMeta } from '@/utils/publicExplorer'
|
||||
|
||||
interface WalletRoutePageProps {
|
||||
initialNetworks: NetworksCatalog | null
|
||||
@@ -21,21 +20,13 @@ export default function WalletRoutePage(props: WalletRoutePageProps) {
|
||||
return <WalletPage {...props} />
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<WalletRoutePageProps> = async () => {
|
||||
const [networksResult, tokenListResult, capabilitiesResult] = await Promise.all([
|
||||
fetchPublicJsonWithMeta<NetworksCatalog>('/api/config/networks').catch(() => null),
|
||||
fetchPublicJsonWithMeta<TokenListCatalog>('/api/v1/report/token-list?chainId=138&wallet=1').catch(() => null),
|
||||
fetchPublicJsonWithMeta<CapabilitiesCatalog>('/api/config/capabilities').catch(() => null),
|
||||
])
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialNetworks: networksResult?.data || null,
|
||||
initialTokenList: tokenListResult?.data || null,
|
||||
initialCapabilities: capabilitiesResult?.data || null,
|
||||
initialNetworksMeta: networksResult?.meta || null,
|
||||
initialTokenListMeta: tokenListResult?.meta || null,
|
||||
initialCapabilitiesMeta: capabilitiesResult?.meta || null,
|
||||
},
|
||||
}
|
||||
}
|
||||
export const getServerSideProps: GetServerSideProps<WalletRoutePageProps> = async () => ({
|
||||
props: {
|
||||
initialNetworks: null,
|
||||
initialTokenList: null,
|
||||
initialCapabilities: null,
|
||||
initialNetworksMeta: null,
|
||||
initialTokenListMeta: null,
|
||||
initialCapabilitiesMeta: null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,7 +4,8 @@ import Link from 'next/link'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
||||
import CompactMetricBar from '@/components/common/CompactMetricBar'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import { accessApi, type WalletAccessSession } from '@/services/api/access'
|
||||
import { addressesApi, type AddressInfo, type TransactionSummary } from '@/services/api/addresses'
|
||||
import {
|
||||
@@ -29,7 +30,6 @@ function shortAddress(value?: string | null): string {
|
||||
}
|
||||
|
||||
export default function WatchlistPage() {
|
||||
const { mode } = useUiMode()
|
||||
const [entries, setEntries] = useState<string[]>([])
|
||||
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
|
||||
const [snapshots, setSnapshots] = useState<Record<string, TrackedAddressSnapshot>>({})
|
||||
@@ -178,53 +178,29 @@ export default function WatchlistPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
<PageIntro
|
||||
compact
|
||||
eyebrow="Tracked Entities"
|
||||
title="Watchlist"
|
||||
description={
|
||||
mode === 'guided'
|
||||
? 'Track the addresses that matter most, review a quick explorer snapshot for each one, and move directly into address detail or recent indexed transactions from one shared workflow surface.'
|
||||
: 'Track important addresses, review quick snapshots, and jump directly into explorer workflows.'
|
||||
}
|
||||
description="Track addresses, review quick snapshots, and jump into detail pages."
|
||||
actions={[
|
||||
{ href: '/addresses', label: 'Browse addresses' },
|
||||
{ href: '/search', label: 'Search explorer' },
|
||||
{ href: '/transactions', label: 'Recent transactions' },
|
||||
{ href: '/addresses', label: 'Addresses' },
|
||||
{ href: '/search', label: 'Search' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card title="Tracked Entities Overview">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Tracked addresses</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">{entries.length}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent indexed activity</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">{trackedSummaries.withRecentTransactions}</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Entries with at least one visible recent transaction.</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">EOAs / contracts</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{trackedSummaries.eoas} / {trackedSummaries.contracts}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Visible tx volume</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{trackedSummaries.totalTransactions.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Aggregate indexed transaction count across tracked entries.</div>
|
||||
</div>
|
||||
</div>
|
||||
<Explain>
|
||||
This view keeps tracked-address shortcuts and quick explorer evidence in one place so Guided mode can explain what each entity represents while Expert mode stays denser.
|
||||
</Explain>
|
||||
</Card>
|
||||
<CompactMetricBar
|
||||
className="mb-4"
|
||||
metrics={[
|
||||
{ label: 'Tracked', value: String(entries.length) },
|
||||
{ label: 'With recent tx', value: String(trackedSummaries.withRecentTransactions) },
|
||||
{ label: 'EOAs', value: String(trackedSummaries.eoas) },
|
||||
{ label: 'Contracts', value: String(trackedSummaries.contracts) },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card title="Connected Wallet">
|
||||
<DisclosureSection title="Connected wallet" defaultOpen={Boolean(walletSession)} alwaysCollapsible className="mb-4">
|
||||
{walletSession ? (
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
@@ -261,9 +237,9 @@ export default function WatchlistPage() {
|
||||
Connect a wallet from the <Link href="/wallet" className="text-primary-600 hover:underline">wallet tools page</Link> to pin your own address into tracked workflows.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</DisclosureSection>
|
||||
|
||||
<Card title="Saved Addresses">
|
||||
<Card title="Saved addresses">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{entries.length === 0
|
||||
@@ -314,31 +290,13 @@ export default function WatchlistPage() {
|
||||
<Link href={`/addresses/${entry}`} className="text-primary-600 hover:underline">
|
||||
<Address address={entry} showCopy={false} />
|
||||
</Link>
|
||||
<div className="mt-2 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Type</div>
|
||||
<div className="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{info ? (info.is_contract ? 'Contract' : 'EOA') : loadingSnapshots ? 'Loading…' : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Indexed txs</div>
|
||||
<div className="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{info ? info.transaction_count.toLocaleString() : loadingSnapshots ? 'Loading…' : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token holdings</div>
|
||||
<div className="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{info ? info.token_count.toLocaleString() : loadingSnapshots ? 'Loading…' : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent visible tx</div>
|
||||
<div className="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{recentTransaction ? `#${recentTransaction.block_number.toLocaleString()}` : loadingSnapshots ? 'Loading…' : 'None visible'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>{info ? (info.is_contract ? 'Contract' : 'EOA') : loadingSnapshots ? '…' : 'Unknown'}</span>
|
||||
<span>{info ? `${info.transaction_count} txs` : ''}</span>
|
||||
<span>{info ? `${info.token_count} tokens` : ''}</span>
|
||||
<span>
|
||||
{recentTransaction ? `Latest #${recentTransaction.block_number}` : loadingSnapshots ? '' : 'No recent tx'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface MissionControlRelayItemSummary {
|
||||
status: string
|
||||
text: string
|
||||
tone: 'normal' | 'warning' | 'danger'
|
||||
polledAt?: string | null
|
||||
}
|
||||
|
||||
export interface MissionControlRelaySnapshot {
|
||||
@@ -162,17 +163,6 @@ 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`
|
||||
}
|
||||
|
||||
function describeRelayStatus(snapshot: MissionControlRelaySnapshot, status: string): string {
|
||||
if (snapshot.last_error?.scope === 'bridge_inventory') {
|
||||
@@ -239,12 +229,10 @@ export function summarizeMissionControlRelay(
|
||||
const statusLabel = describeRelayStatus(snapshot, status)
|
||||
const destination = snapshot.destination?.chain_name
|
||||
const queueSize = snapshot.queue?.size
|
||||
const pollAge = relativeAge(snapshot.last_source_poll?.at)
|
||||
|
||||
let text = `${label}: ${statusLabel}`
|
||||
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)) {
|
||||
@@ -254,7 +242,14 @@ export function summarizeMissionControlRelay(
|
||||
tone = 'danger'
|
||||
}
|
||||
|
||||
return { key, label, status, text, tone }
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
status,
|
||||
text,
|
||||
tone,
|
||||
polledAt: snapshot.last_source_poll?.at || null,
|
||||
}
|
||||
})
|
||||
.filter((item): item is MissionControlRelayItemSummary => item !== null)
|
||||
|
||||
|
||||
@@ -22,11 +22,6 @@ function sortDescending(values: number[]): number[] {
|
||||
return [...values].sort((left, right) => right - left)
|
||||
}
|
||||
|
||||
function toTimestamp(value?: string | null): number | null {
|
||||
if (!value) return null
|
||||
const parsed = Date.parse(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
export function summarizeChainActivity(input: {
|
||||
blocks?: Block[]
|
||||
@@ -71,13 +66,7 @@ export function summarizeChainActivity(input: {
|
||||
const transactionVisibilityUnavailable =
|
||||
freshness?.latest_indexed_transaction.source === 'unavailable' ||
|
||||
freshness?.latest_indexed_transaction.completeness === 'unavailable'
|
||||
const latestTransactionAgeSeconds =
|
||||
freshness?.latest_indexed_transaction.age_seconds ??
|
||||
(() => {
|
||||
const timestamp = toTimestamp(latestTransactionTimestamp)
|
||||
if (timestamp == null) return null
|
||||
return Math.max(0, Math.round((Date.now() - timestamp) / 1000))
|
||||
})()
|
||||
const latestTransactionAgeSeconds = freshness?.latest_indexed_transaction.age_seconds ?? null
|
||||
|
||||
const gap = freshness?.latest_non_empty_block.distance_from_head ??
|
||||
diagnostics?.tx_lag_blocks ??
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatWeiAsEth } from './format'
|
||||
import {
|
||||
formatBigIntWhole,
|
||||
formatInteger,
|
||||
formatRelativeAgeAt,
|
||||
formatTimestamp,
|
||||
formatWeiAsEth,
|
||||
} from './format'
|
||||
|
||||
describe('formatWeiAsEth', () => {
|
||||
it('formats zero and whole ETH values without losing precision', () => {
|
||||
@@ -13,3 +19,32 @@ describe('formatWeiAsEth', () => {
|
||||
expect(formatWeiAsEth('9007199254740993')).toBe('0.009 ETH')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatInteger', () => {
|
||||
it('uses en-US grouping for finite numbers', () => {
|
||||
expect(formatInteger(1234567)).toBe('1,234,567')
|
||||
expect(formatInteger(null)).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatBigIntWhole', () => {
|
||||
it('groups bigint digits with commas', () => {
|
||||
expect(formatBigIntWhole(1234567890123456789n)).toBe('1,234,567,890,123,456,789')
|
||||
expect(formatBigIntWhole(-42n)).toBe('-42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTimestamp', () => {
|
||||
it('uses fixed en-US locale formatting', () => {
|
||||
const formatted = formatTimestamp('2026-01-15T12:30:00.000Z')
|
||||
expect(formatted).toMatch(/1\/15\/2026/)
|
||||
expect(formatted).toMatch(/PM|AM/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRelativeAgeAt', () => {
|
||||
it('computes relative age from a fixed reference time', () => {
|
||||
const reference = Date.parse('2026-06-21T12:00:00.000Z')
|
||||
expect(formatRelativeAgeAt(reference, '2026-06-21T11:59:30.000Z')).toBe('30s ago')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,20 +60,44 @@ export function formatTokenAmount(value: string | number | null | undefined, dec
|
||||
return symbol ? `${formatted} ${symbol}` : formatted
|
||||
}
|
||||
|
||||
export function formatTimestamp(value?: string | null): string {
|
||||
if (!value) return 'Unknown'
|
||||
const date = new Date(value)
|
||||
const TIMESTAMP_LOCALE = 'en-US'
|
||||
const TIMESTAMP_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true,
|
||||
}
|
||||
const DATE_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
}
|
||||
const INTEGER_FORMAT = new Intl.NumberFormat(TIMESTAMP_LOCALE)
|
||||
|
||||
export function formatTimestamp(value?: string | number | Date | null): string {
|
||||
if (value == null) return 'Unknown'
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
return typeof value === 'string' ? value : 'Unknown'
|
||||
}
|
||||
return date.toLocaleString()
|
||||
return date.toLocaleString(TIMESTAMP_LOCALE, TIMESTAMP_OPTIONS)
|
||||
}
|
||||
|
||||
export function formatRelativeAge(value?: string | null): string {
|
||||
export function formatDate(value?: string | number | Date | null): string {
|
||||
if (value == null) return 'Unknown'
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'Unknown'
|
||||
return date.toLocaleDateString(TIMESTAMP_LOCALE, DATE_OPTIONS)
|
||||
}
|
||||
|
||||
export function formatRelativeAgeAt(referenceMs: number, value?: string | null): string {
|
||||
if (!value) return 'Unknown'
|
||||
const parsed = Date.parse(value)
|
||||
if (!Number.isFinite(parsed)) return 'Unknown'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
|
||||
const seconds = Math.max(0, Math.round((referenceMs - parsed) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
@@ -82,3 +106,20 @@ export function formatRelativeAge(value?: string | null): string {
|
||||
const days = Math.round(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
/** Client-only helper; prefer ClientRelativeTime in render paths to avoid hydration mismatch. */
|
||||
export function formatRelativeAge(value?: string | null): string {
|
||||
return formatRelativeAgeAt(Date.now(), value)
|
||||
}
|
||||
|
||||
export function formatInteger(value?: number | null): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '0'
|
||||
return INTEGER_FORMAT.format(value)
|
||||
}
|
||||
|
||||
export function formatBigIntWhole(value: bigint): string {
|
||||
const negative = value < 0n
|
||||
const digits = (negative ? -value : value).toString()
|
||||
const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
return negative ? `-${grouped}` : grouped
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatBigIntWhole } from './format'
|
||||
|
||||
export type EthereumRpcProvider = {
|
||||
request: (args: { method: string; params?: unknown }) => Promise<unknown>
|
||||
}
|
||||
@@ -100,10 +102,10 @@ export async function filterTokensWithNonZeroBalance<T extends WalletBalanceToke
|
||||
export function formatNativeEthFromWei(wei: bigint, maxFractionDigits = 4): string {
|
||||
const whole = wei / 10n ** 18n
|
||||
const fraction = wei % 10n ** 18n
|
||||
if (fraction === 0n) return `${whole.toLocaleString()} ETH`
|
||||
if (fraction === 0n) return `${formatBigIntWhole(whole)} ETH`
|
||||
|
||||
const fractionStr = fraction.toString().padStart(18, '0').replace(/0+$/, '')
|
||||
const trimmed = fractionStr.slice(0, maxFractionDigits).replace(/0+$/, '')
|
||||
if (!trimmed) return `${whole.toLocaleString()} ETH`
|
||||
return `${whole.toLocaleString()}.${trimmed} ETH`
|
||||
if (!trimmed) return `${formatBigIntWhole(whole)} ETH`
|
||||
return `${formatBigIntWhole(whole)}.${trimmed} ETH`
|
||||
}
|
||||
|
||||
70
frontend/src/utils/walletWatchToken.test.ts
Normal file
70
frontend/src/utils/walletWatchToken.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { vi } from 'vitest'
|
||||
import { switchOrAddChain, watchTokenInWallet } from '@/utils/walletWatchToken'
|
||||
import type { WalletChain } from '@/components/wallet/AddToMetaMask'
|
||||
|
||||
const chain138: WalletChain = {
|
||||
chainId: '0x8a',
|
||||
chainIdDecimal: 138,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org'],
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org'],
|
||||
}
|
||||
|
||||
describe('walletWatchToken', () => {
|
||||
it('switches when chain already exists', async () => {
|
||||
const request = vi.fn().mockResolvedValue(null)
|
||||
const ok = await switchOrAddChain({ request }, chain138)
|
||||
expect(ok).toBe(true)
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
method: 'wallet_switchEthereumChain',
|
||||
params: [{ chainId: '0x8a' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('adds chain when wallet reports 4902', async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({ code: 4902 })
|
||||
.mockResolvedValueOnce(null)
|
||||
const ok = await switchOrAddChain({ request }, chain138)
|
||||
expect(ok).toBe(true)
|
||||
expect(request).toHaveBeenNthCalledWith(2, {
|
||||
method: 'wallet_addEthereumChain',
|
||||
params: [
|
||||
expect.objectContaining({
|
||||
chainId: '0x8a',
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('watchTokenInWallet passes hex chainId for Chain 138 tokens', async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(true)
|
||||
const result = await watchTokenInWallet(
|
||||
{ request },
|
||||
{
|
||||
chainId: 138,
|
||||
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
symbol: 'cUSDC',
|
||||
decimals: 6,
|
||||
},
|
||||
chain138,
|
||||
)
|
||||
expect(result).toEqual({ ok: true, added: true })
|
||||
expect(request).toHaveBeenNthCalledWith(2, {
|
||||
method: 'wallet_watchAsset',
|
||||
params: {
|
||||
type: 'ERC20',
|
||||
options: expect.objectContaining({
|
||||
chainId: '0x8a',
|
||||
symbol: 'cUSDC',
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
64
frontend/src/utils/walletWatchToken.ts
Normal file
64
frontend/src/utils/walletWatchToken.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { WalletChain } from '@/components/wallet/AddToMetaMask'
|
||||
import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain'
|
||||
import {
|
||||
buildWatchAssetRpcRequest,
|
||||
type WatchAssetToken,
|
||||
} from '@/utils/walletWatchAsset'
|
||||
import {
|
||||
isMobileWalletContext,
|
||||
type EthereumProvider,
|
||||
} from '@/utils/walletProviderEnv'
|
||||
|
||||
export async function switchOrAddChain(
|
||||
ethereum: EthereumProvider,
|
||||
chain: WalletChain,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await ethereum.request({
|
||||
method: 'wallet_switchEthereumChain',
|
||||
params: [{ chainId: chain.chainId }],
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
const err = e as { code?: number }
|
||||
if (err.code !== 4902) return false
|
||||
}
|
||||
|
||||
try {
|
||||
await ethereum.request({
|
||||
method: 'wallet_addEthereumChain',
|
||||
params: [
|
||||
toWalletAddEthereumChainParams(chain, {
|
||||
preferSingleRpc: isMobileWalletContext(),
|
||||
}),
|
||||
],
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function watchTokenInWallet(
|
||||
ethereum: EthereumProvider,
|
||||
token: WatchAssetToken,
|
||||
chain: WalletChain,
|
||||
): Promise<{ ok: boolean; added?: boolean; error?: string }> {
|
||||
const switched = await switchOrAddChain(ethereum, chain)
|
||||
if (!switched) {
|
||||
return { ok: false, error: `Failed to switch to ${chain.chainName}. Add the network first, then retry.` }
|
||||
}
|
||||
|
||||
try {
|
||||
const added = await ethereum.request(
|
||||
buildWatchAssetRpcRequest(token, isMobileWalletContext()),
|
||||
)
|
||||
return { ok: true, added: Boolean(added) }
|
||||
} catch (e) {
|
||||
const err = e as { code?: number; message?: string }
|
||||
if (err.code === 4001 || /rejected|denied|not been authorized/i.test(err.message || '')) {
|
||||
return { ok: false, error: 'Wallet request was rejected. Approve the popup to add the token.' }
|
||||
}
|
||||
return { ok: false, error: err.message || 'Could not add token to wallet.' }
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,27 @@ if [[ -f "${SMOKE_SCRIPT}" ]]; then
|
||||
echo "WARN: institutional smoke failed — see ${SMOKE_SCRIPT}" >&2
|
||||
}
|
||||
fi
|
||||
|
||||
SMOKE_ROUTES="${FRONTEND_ROOT}/scripts/smoke-routes.mjs"
|
||||
SMOKE_SCROLL="${FRONTEND_ROOT}/scripts/smoke-scroll-height.mjs"
|
||||
if [[ -f "${SMOKE_ROUTES}" ]] && [[ "${EXPLORER_SKIP_ROUTE_SMOKE:-}" != "1" ]]; then
|
||||
echo ""
|
||||
echo "== Playwright route smoke (optional) =="
|
||||
if (cd "${FRONTEND_ROOT}" && npm exec -- playwright install chromium >/dev/null 2>&1 && BASE_URL="${EXPLORER_BASE:-https://explorer.d-bis.org}" npm run smoke:routes); then
|
||||
echo "Route smoke: PASS"
|
||||
else
|
||||
echo "WARN: route smoke skipped or failed — set EXPLORER_SKIP_ROUTE_SMOKE=1 to silence; run npm install in frontend first" >&2
|
||||
fi
|
||||
fi
|
||||
if [[ -f "${SMOKE_SCROLL}" ]] && [[ "${EXPLORER_SKIP_SCROLL_SMOKE:-}" != "1" ]] && [[ "${EXPLORER_SKIP_ROUTE_SMOKE:-}" != "1" ]]; then
|
||||
echo ""
|
||||
echo "== Playwright scroll-height smoke (optional) =="
|
||||
if (cd "${FRONTEND_ROOT}" && BASE_URL="${EXPLORER_BASE:-https://explorer.d-bis.org}" npm run smoke:scroll); then
|
||||
echo "Scroll-height smoke: PASS"
|
||||
else
|
||||
echo "WARN: scroll-height smoke failed — set EXPLORER_SKIP_SCROLL_SMOKE=1 to silence" >&2
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
echo "Nginx follow-up:"
|
||||
echo " Switch the explorer server block to proxy / and /_next/ to 127.0.0.1:${FRONTEND_PORT}"
|
||||
|
||||
Reference in New Issue
Block a user