refactor: rename SolaceScanScout to Solace and update related configurations
- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation. - Changed default base URL for Playwright tests and updated security headers to reflect the new branding. - Enhanced README and API documentation to include new authentication endpoints and product access details. This refactor aligns the project branding and improves clarity in the API documentation.
This commit is contained in:
873
frontend/src/components/access/AccessManagementPage.tsx
Normal file
873
frontend/src/components/access/AccessManagementPage.tsx
Normal file
@@ -0,0 +1,873 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import {
|
||||
accessApi,
|
||||
type AccessAPIKeyRecord,
|
||||
type AccessAuditEntry,
|
||||
type AccessProduct,
|
||||
type AccessSubscription,
|
||||
type AccessUsageSummary,
|
||||
type AccessUser,
|
||||
type WalletAccessSession,
|
||||
} from '@/services/api/access'
|
||||
|
||||
const ACCESS_SCOPE_OPTIONS = ['rpc:read', 'rpc:write', 'rpc:admin'] as const
|
||||
const OPERATOR_IDENTITIES = [
|
||||
{
|
||||
slug: 'thirdweb-rpc',
|
||||
label: 'ThirdWeb',
|
||||
vmid: 2103,
|
||||
address: '0xB2dEA0e264ddfFf91057A3415112e57A1a5Eac14',
|
||||
},
|
||||
{
|
||||
slug: 'alltra-rpc',
|
||||
label: 'Alltra/HYBX',
|
||||
vmid: 2102,
|
||||
address: '0xaf6e3444AEaf7855cf41b557C94A96dc7fcF49C1',
|
||||
},
|
||||
{
|
||||
slug: 'core-rpc',
|
||||
label: 'DBIS',
|
||||
vmid: 2101,
|
||||
address: '0x4A666F96fC8764181194447A7dFdb7d471b301C8',
|
||||
},
|
||||
] as const
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = 'text',
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
type?: string
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</span>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AccessManagementPage() {
|
||||
const [products, setProducts] = useState<AccessProduct[]>([])
|
||||
const [user, setUser] = useState<AccessUser | null>(null)
|
||||
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
|
||||
const [connectingWallet, setConnectingWallet] = useState(false)
|
||||
const [apiKeys, setApiKeys] = useState<AccessAPIKeyRecord[]>([])
|
||||
const [subscriptions, setSubscriptions] = useState<AccessSubscription[]>([])
|
||||
const [usage, setUsage] = useState<AccessUsageSummary[]>([])
|
||||
const [auditEntries, setAuditEntries] = useState<AccessAuditEntry[]>([])
|
||||
const [adminSubscriptions, setAdminSubscriptions] = useState<AccessSubscription[]>([])
|
||||
const [adminAuditEntries, setAdminAuditEntries] = useState<AccessAuditEntry[]>([])
|
||||
const [auditLimit, setAuditLimit] = useState('20')
|
||||
const [adminAuditLimit, setAdminAuditLimit] = useState('50')
|
||||
const [adminSubscriptionStatus, setAdminSubscriptionStatus] = useState('pending')
|
||||
const [adminAuditProduct, setAdminAuditProduct] = useState('')
|
||||
const [adminActionNotes, setAdminActionNotes] = useState<Record<string, string>>({})
|
||||
const [email, setEmail] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [apiKeyName, setAPIKeyName] = useState('Core RPC key')
|
||||
const [apiKeyTier, setAPIKeyTier] = useState('pro')
|
||||
const [apiKeyProduct, setAPIKeyProduct] = useState('thirdweb-rpc')
|
||||
const [apiKeyExpiresDays, setAPIKeyExpiresDays] = useState('30')
|
||||
const [apiKeyMonthlyQuota, setAPIKeyMonthlyQuota] = useState('')
|
||||
const [apiKeyScopes, setAPIKeyScopes] = useState<string[]>(['rpc:read', 'rpc:write'])
|
||||
const [createdKey, setCreatedKey] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const clearSessionState = useCallback(() => {
|
||||
setUser(null)
|
||||
setApiKeys([])
|
||||
setSubscriptions([])
|
||||
setUsage([])
|
||||
setAuditEntries([])
|
||||
setAdminSubscriptions([])
|
||||
setAdminAuditEntries([])
|
||||
}, [])
|
||||
|
||||
const syncWalletSession = useCallback(() => {
|
||||
setWalletSession(accessApi.getStoredWalletSession())
|
||||
}, [])
|
||||
|
||||
const loadAdminData = useCallback(async (
|
||||
isAdmin: boolean,
|
||||
nextSubscriptionStatus = adminSubscriptionStatus,
|
||||
nextAuditProduct = adminAuditProduct,
|
||||
nextAuditLimit = Number(adminAuditLimit),
|
||||
) => {
|
||||
if (!isAdmin) {
|
||||
setAdminSubscriptions([])
|
||||
setAdminAuditEntries([])
|
||||
return
|
||||
}
|
||||
const [adminResponse, adminAuditResponse] = await Promise.all([
|
||||
accessApi.listAdminSubscriptions(nextSubscriptionStatus).catch(() => ({ subscriptions: [] })),
|
||||
accessApi.listAdminAudit(nextAuditLimit, nextAuditProduct).catch(() => ({ entries: [] })),
|
||||
])
|
||||
setAdminSubscriptions(adminResponse.subscriptions || [])
|
||||
setAdminAuditEntries(adminAuditResponse.entries || [])
|
||||
}, [adminAuditLimit, adminAuditProduct, adminSubscriptionStatus])
|
||||
|
||||
const loadSignedInData = useCallback(async () => {
|
||||
const [me, keys, usageResponse, auditResponse] = await Promise.all([
|
||||
accessApi.getMe(),
|
||||
accessApi.listAPIKeys(),
|
||||
accessApi.getUsage().catch(() => ({ usage: [] })),
|
||||
accessApi.listAudit(Number(auditLimit)).catch(() => ({ entries: [] })),
|
||||
])
|
||||
setUser(me.user)
|
||||
setSubscriptions(me.subscriptions || [])
|
||||
setApiKeys(keys.api_keys || [])
|
||||
setUsage(usageResponse.usage || [])
|
||||
setAuditEntries(auditResponse.entries || [])
|
||||
await loadAdminData(Boolean(me.user?.is_admin))
|
||||
}, [auditLimit, loadAdminData])
|
||||
|
||||
const loadAccessData = useCallback(async () => {
|
||||
const productResponse = await accessApi.listProducts()
|
||||
setProducts(productResponse.products || [])
|
||||
syncWalletSession()
|
||||
|
||||
const token = accessApi.getStoredAccessToken()
|
||||
if (!token) {
|
||||
clearSessionState()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await loadSignedInData()
|
||||
} catch {
|
||||
accessApi.clearSession()
|
||||
clearSessionState()
|
||||
}
|
||||
}, [clearSessionState, loadSignedInData, syncWalletSession])
|
||||
|
||||
useEffect(() => {
|
||||
void loadAccessData()
|
||||
}, [loadAccessData])
|
||||
|
||||
useEffect(() => {
|
||||
syncWalletSession()
|
||||
window.addEventListener('storage', syncWalletSession)
|
||||
window.addEventListener('explorer-access-session-changed', syncWalletSession)
|
||||
return () => {
|
||||
window.removeEventListener('storage', syncWalletSession)
|
||||
window.removeEventListener('explorer-access-session-changed', syncWalletSession)
|
||||
}
|
||||
}, [syncWalletSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
void accessApi
|
||||
.listAudit(Number(auditLimit))
|
||||
.then((response) => setAuditEntries(response.entries || []))
|
||||
.catch(() => {})
|
||||
}, [auditLimit, user])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.is_admin) return
|
||||
void loadAdminData(true)
|
||||
}, [adminSubscriptionStatus, adminAuditLimit, adminAuditProduct, loadAdminData, user?.is_admin])
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeyProduct === 'core-rpc') {
|
||||
setAPIKeyScopes((current) =>
|
||||
current.includes('rpc:admin') ? current : [...current, 'rpc:admin'],
|
||||
)
|
||||
} else {
|
||||
setAPIKeyScopes((current) => current.filter((scope) => scope !== 'rpc:admin'))
|
||||
}
|
||||
}, [apiKeyProduct])
|
||||
|
||||
const handleRegister = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setMessage('')
|
||||
try {
|
||||
const response = await accessApi.register(email, username, password)
|
||||
setUser(response.user)
|
||||
setMessage('Account created. You can now issue API keys for managed RPC access.')
|
||||
await loadSignedInData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setMessage('')
|
||||
try {
|
||||
const response = await accessApi.login(email, password)
|
||||
setUser(response.user)
|
||||
await loadSignedInData()
|
||||
setMessage('Signed in successfully.')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateAPIKey = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setMessage('')
|
||||
setCreatedKey('')
|
||||
try {
|
||||
const response = await accessApi.createAPIKey({
|
||||
name: apiKeyName,
|
||||
tier: apiKeyTier,
|
||||
productSlug: apiKeyProduct,
|
||||
expiresDays: apiKeyExpiresDays === 'never' ? 0 : Number(apiKeyExpiresDays || 0),
|
||||
monthlyQuota: apiKeyMonthlyQuota.trim() ? Number(apiKeyMonthlyQuota) : undefined,
|
||||
scopes: apiKeyScopes,
|
||||
})
|
||||
setCreatedKey(response.api_key)
|
||||
setMessage('API key created. This is the only time the plaintext key will be shown.')
|
||||
await loadSignedInData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create API key')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRotate = async (key: AccessAPIKeyRecord) => {
|
||||
setError('')
|
||||
setMessage('')
|
||||
setCreatedKey('')
|
||||
try {
|
||||
const response = await accessApi.createAPIKey({
|
||||
name: key.name.replace(/\s+\[[^\]]+\]$/, ''),
|
||||
tier: key.tier,
|
||||
productSlug: key.productSlug,
|
||||
monthlyQuota: key.monthlyQuota,
|
||||
scopes: key.scopes,
|
||||
})
|
||||
await accessApi.revokeAPIKey(key.id)
|
||||
setCreatedKey(response.api_key)
|
||||
setMessage('API key rotated. The old key has been revoked and the new plaintext key is shown below once.')
|
||||
await loadSignedInData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to rotate API key')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async (id: string) => {
|
||||
setError('')
|
||||
setMessage('')
|
||||
try {
|
||||
await accessApi.revokeAPIKey(id)
|
||||
setMessage('API key revoked.')
|
||||
await loadSignedInData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to revoke API key')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSignOut = () => {
|
||||
accessApi.clearSession()
|
||||
clearSessionState()
|
||||
setCreatedKey('')
|
||||
setMessage('Signed out.')
|
||||
}
|
||||
|
||||
const handleWalletConnect = async () => {
|
||||
setError('')
|
||||
setMessage('')
|
||||
try {
|
||||
setConnectingWallet(true)
|
||||
const session = await accessApi.connectWalletSession()
|
||||
setWalletSession(session)
|
||||
await loadSignedInData()
|
||||
setMessage('Wallet connected. Account sign-in is active and authenticated explorer access is now available.')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Wallet connection failed')
|
||||
} finally {
|
||||
setConnectingWallet(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWalletDisconnect = () => {
|
||||
accessApi.clearWalletSession()
|
||||
syncWalletSession()
|
||||
clearSessionState()
|
||||
setCreatedKey('')
|
||||
setMessage('Wallet session disconnected.')
|
||||
}
|
||||
|
||||
const handleRequestSubscription = async (productSlug: string, tier: string) => {
|
||||
setError('')
|
||||
setMessage('')
|
||||
try {
|
||||
await accessApi.requestSubscription(productSlug, tier)
|
||||
await loadSignedInData()
|
||||
setMessage('Access request saved.')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to request access')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdminSubscriptionAction = async (subscriptionId: string, status: string) => {
|
||||
setError('')
|
||||
setMessage('')
|
||||
try {
|
||||
await accessApi.updateAdminSubscription(subscriptionId, status, adminActionNotes[subscriptionId] || '')
|
||||
await loadSignedInData()
|
||||
setAdminActionNotes((current) => ({ ...current, [subscriptionId]: '' }))
|
||||
setMessage(`Subscription ${status === 'active' ? 'approved' : status}.`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update subscription')
|
||||
}
|
||||
}
|
||||
|
||||
const getSubscriptionForProduct = (productSlug: string) =>
|
||||
subscriptions.find((subscription) => subscription.productSlug === productSlug)
|
||||
|
||||
const handleScopeToggle = (scope: string) => {
|
||||
setAPIKeyScopes((current) =>
|
||||
current.includes(scope) ? current.filter((entry) => entry !== scope) : [...current, scope],
|
||||
)
|
||||
}
|
||||
|
||||
const handleAdminAuditProductChange = async (value: string) => {
|
||||
setAdminAuditProduct(value)
|
||||
}
|
||||
|
||||
const getOperatorIdentity = (productSlug: string) =>
|
||||
OPERATOR_IDENTITIES.find((entry) => entry.slug === productSlug)
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<PageIntro
|
||||
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."
|
||||
actions={[
|
||||
{ href: '/wallet', label: 'Wallet tools' },
|
||||
{ href: '/system', label: 'System status' },
|
||||
{ href: '/search', label: 'Search explorer' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{message ? (
|
||||
<Card className="mb-6 border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
<p className="text-sm text-emerald-900 dark:text-emerald-100">{message}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
||||
<p className="text-sm text-red-900 dark:text-red-100">{error}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-8 grid gap-6 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">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge label={product.provider} tone="info" />
|
||||
<EntityBadge label={`vmid ${product.vmid}`} />
|
||||
<EntityBadge label={product.default_tier} tone="success" />
|
||||
<EntityBadge label={product.billing_model} tone="warning" />
|
||||
{product.requires_approval ? <EntityBadge label="approval required" tone="warning" /> : <EntityBadge label="self-service" tone="success" />}
|
||||
</div>
|
||||
<p>{product.description}</p>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">HTTP</div>
|
||||
<code className="break-all text-xs">{product.http_url}</code>
|
||||
</div>
|
||||
{product.ws_url ? (
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">WS</div>
|
||||
<code className="break-all text-xs">{product.ws_url}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">Use cases</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{product.use_cases.map((item) => (
|
||||
<EntityBadge key={item} label={item} className="normal-case tracking-normal" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{getOperatorIdentity(product.slug) ? (
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">Primary operator / deployer</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<EntityBadge label={getOperatorIdentity(product.slug)?.label || product.provider} tone="info" />
|
||||
<EntityBadge label={`vmid ${getOperatorIdentity(product.slug)?.vmid || product.vmid}`} />
|
||||
</div>
|
||||
<code className="mt-2 block break-all text-xs">{getOperatorIdentity(product.slug)?.address}</code>
|
||||
</div>
|
||||
) : null}
|
||||
{user ? (
|
||||
<div className="border-t border-gray-200 pt-3 dark:border-gray-700">
|
||||
{(() => {
|
||||
const subscription = getSubscriptionForProduct(product.slug)
|
||||
if (subscription) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge label={subscription.status} tone={subscription.status === 'active' ? 'success' : 'warning'} />
|
||||
<EntityBadge label={subscription.tier} />
|
||||
<EntityBadge label={`${subscription.requestsUsed}/${subscription.monthlyQuota || 0}`} tone="info" />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{subscription.notes || 'Subscription record present.'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRequestSubscription(product.slug, product.default_tier)}
|
||||
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
{product.requires_approval ? 'Request access' : 'Activate access'}
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_1.2fr]">
|
||||
<div className="space-y-6">
|
||||
<Card title="Wallet Authentication">
|
||||
<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.
|
||||
</p>
|
||||
{walletSession ? (
|
||||
<div className="space-y-3 rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge label="wallet sign-in active" tone="success" />
|
||||
<EntityBadge label={walletSession.track} tone="info" />
|
||||
{walletSession.permissions.map((permission) => (
|
||||
<EntityBadge key={permission} label={permission} className="normal-case tracking-normal" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
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()}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleWalletDisconnect}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Disconnect wallet
|
||||
</button>
|
||||
<Link href="/wallet" className="rounded-lg border border-primary-300 px-4 py-2 text-sm font-medium text-primary-700 hover:bg-primary-50 dark:border-primary-800 dark:text-primary-300 dark:hover:bg-primary-950/20">
|
||||
Open wallet tools
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-primary-300 bg-primary-50/60 p-4 dark:border-primary-700/50 dark:bg-primary-950/20">
|
||||
<div className="mb-3 text-sm text-primary-900 dark:text-primary-100">
|
||||
No wallet session is active. Connect a browser wallet to sign in to your account and unlock the access-management plane.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleWalletConnect()}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Operator Identities">
|
||||
<div className="space-y-4">
|
||||
{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 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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={user ? `Signed in as ${user.username}` : 'Create or Access Account'}>
|
||||
{user ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{user.email}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleRegister} className="space-y-3">
|
||||
<Field label="Email" value={email} onChange={setEmail} type="email" />
|
||||
<Field label="Username" value={username} onChange={setUsername} />
|
||||
<Field label="Password" value={password} onChange={setPassword} type="password" />
|
||||
<button type="submit" className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700">
|
||||
Register
|
||||
</button>
|
||||
</form>
|
||||
<form onSubmit={handleLogin} className="space-y-3 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<Field label="Email" value={email} onChange={setEmail} type="email" />
|
||||
<Field label="Password" value={password} onChange={setPassword} type="password" />
|
||||
<button type="submit" className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{user ? (
|
||||
<Card title="Create API Key">
|
||||
<form onSubmit={handleCreateAPIKey} className="space-y-3">
|
||||
<Field label="Key name" value={apiKeyName} onChange={setAPIKeyName} />
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tier</span>
|
||||
<select value={apiKeyTier} onChange={(event) => setAPIKeyTier(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="free">free</option>
|
||||
<option value="pro">pro</option>
|
||||
<option value="enterprise">enterprise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Product</span>
|
||||
<select value={apiKeyProduct} onChange={(event) => setAPIKeyProduct(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">
|
||||
{products.map((product) => (
|
||||
<option key={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">Expiry</span>
|
||||
<select value={apiKeyExpiresDays} onChange={(event) => setAPIKeyExpiresDays(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="7">7 days</option>
|
||||
<option value="30">30 days</option>
|
||||
<option value="90">90 days</option>
|
||||
<option value="365">365 days</option>
|
||||
<option value="never">No expiry</option>
|
||||
</select>
|
||||
</label>
|
||||
<Field label="Monthly quota override (optional)" value={apiKeyMonthlyQuota} onChange={setAPIKeyMonthlyQuota} />
|
||||
<div>
|
||||
<span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Scopes</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ACCESS_SCOPE_OPTIONS.map((scope) => (
|
||||
<label key={scope} className="inline-flex items-center gap-2 rounded-full border border-gray-300 px-3 py-2 text-sm dark:border-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={apiKeyScopes.includes(scope)}
|
||||
onChange={() => handleScopeToggle(scope)}
|
||||
/>
|
||||
<span>{scope}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700">
|
||||
Issue key
|
||||
</button>
|
||||
</form>
|
||||
{createdKey ? (
|
||||
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<div className="mb-2 text-sm font-semibold text-amber-900 dark:text-amber-100">Plaintext API key</div>
|
||||
<code className="block break-all text-xs text-amber-900 dark:text-amber-100">{createdKey}</code>
|
||||
</div>
|
||||
) : 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">
|
||||
{user ? (
|
||||
apiKeys.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{apiKeys.map((key) => (
|
||||
<div key={key.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">{key.name}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<EntityBadge label={key.tier} tone="success" />
|
||||
{key.productSlug ? <EntityBadge label={key.productSlug} tone="info" /> : null}
|
||||
<EntityBadge label={`${key.rateLimitPerSecond}/s`} tone="info" />
|
||||
<EntityBadge label={`${key.rateLimitPerMinute}/min`} />
|
||||
<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" />}
|
||||
</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'}
|
||||
</div>
|
||||
</div>
|
||||
{!key.revoked ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRotate(key)}
|
||||
className="rounded-lg border border-primary-300 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-50 dark:border-primary-800 dark:text-primary-300 dark:hover:bg-primary-950/20"
|
||||
>
|
||||
Rotate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRevoke(key.id)}
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
{key.scopes.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{key.scopes.map((scope) => (
|
||||
<EntityBadge key={`${key.id}-${scope}`} label={scope} className="normal-case tracking-normal" />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">No API keys issued yet.</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Sign in to issue and manage RPC access keys for Core, Thirdweb, and Alltra products.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-6 border-t border-gray-200 pt-4 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400">
|
||||
Billing, quotas, and paywalls can be layered onto this access plane next. The current slice establishes identity, product discovery, and key lifecycle management.
|
||||
</div>
|
||||
{user && usage.length > 0 ? (
|
||||
<div className="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<div className="mb-3 text-sm font-semibold text-gray-900 dark:text-white">Usage Summary</div>
|
||||
<div className="space-y-3">
|
||||
{usage.map((item) => (
|
||||
<div key={item.product_slug} 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={item.product_slug} tone="info" />
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{user ? (
|
||||
<div className="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<div className="mb-3 flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Recent API Activity</div>
|
||||
<label className="block min-w-[10rem]">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">Entries shown</span>
|
||||
<select
|
||||
value={auditLimit}
|
||||
onChange={(event) => setAuditLimit(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="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{auditEntries.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{auditEntries.map((entry) => (
|
||||
<div key={`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 API usage has been logged yet for this account.</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/wallet" className="text-primary-600 hover:underline">Wallet →</Link>
|
||||
<Link href="/system" className="text-primary-600 hover:underline">System →</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
52
frontend/src/components/common/EntityBadge.tsx
Normal file
52
frontend/src/components/common/EntityBadge.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
function toneClasses(tone: 'neutral' | 'success' | 'warning' | 'info') {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200'
|
||||
case 'warning':
|
||||
return 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200'
|
||||
case 'info':
|
||||
return 'border-sky-200 bg-sky-50 text-sky-800 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-200'
|
||||
default:
|
||||
return 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
export function getEntityBadgeTone(tag: string): 'neutral' | 'success' | 'warning' | 'info' {
|
||||
const normalized = tag.toLowerCase()
|
||||
if (normalized === 'compliant' || normalized === 'listed' || normalized === 'verified') {
|
||||
return 'success'
|
||||
}
|
||||
if (normalized === 'wrapped') {
|
||||
return 'warning'
|
||||
}
|
||||
if (normalized === 'bridge' || normalized === 'canonical' || normalized === 'official') {
|
||||
return 'info'
|
||||
}
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
export default function EntityBadge({
|
||||
label,
|
||||
tone,
|
||||
className,
|
||||
}: {
|
||||
label: string
|
||||
tone?: 'neutral' | 'success' | 'warning' | 'info'
|
||||
className?: string
|
||||
}) {
|
||||
const resolvedTone = tone || getEntityBadgeTone(label)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-wide',
|
||||
toneClasses(resolvedTone),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
178
frontend/src/components/common/ExplorerAgentTool.tsx
Normal file
178
frontend/src/components/common/ExplorerAgentTool.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, useMemo, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { getExplorerApiBase } from '@/services/api/blockscout'
|
||||
|
||||
interface AgentMessage {
|
||||
role: 'assistant' | 'user'
|
||||
content: string
|
||||
}
|
||||
|
||||
const QUICK_PROMPTS = [
|
||||
'Explain this page',
|
||||
'Summarize the chain status',
|
||||
'Help me inspect a contract',
|
||||
'Find likely navigation issues',
|
||||
] as const
|
||||
|
||||
export default function ExplorerAgentTool() {
|
||||
const pathname = usePathname() ?? '/'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [input, setInput] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [messages, setMessages] = useState<AgentMessage[]>([
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Explorer AI Agent Tool is ready. I can explain this page, summarize what you are looking at, and help investigate transactions, contracts, routes, and system surfaces.',
|
||||
},
|
||||
])
|
||||
|
||||
const pageContext = useMemo(
|
||||
() => ({
|
||||
path: pathname,
|
||||
view: 'explorer',
|
||||
}),
|
||||
[pathname],
|
||||
)
|
||||
|
||||
const sendMessage = async (content: string) => {
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed || submitting) return
|
||||
|
||||
const nextMessages: AgentMessage[] = [...messages, { role: 'user', content: trimmed }]
|
||||
setMessages(nextMessages)
|
||||
setInput('')
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getExplorerApiBase()}/api/v1/ai/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: nextMessages,
|
||||
pageContext,
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const reply =
|
||||
payload?.message?.content ||
|
||||
payload?.reply ||
|
||||
'The agent did not return a readable reply.'
|
||||
|
||||
setMessages((current) => [...current, { role: 'assistant', content: String(reply) }])
|
||||
} catch (error) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
error instanceof Error
|
||||
? `Agent tool is temporarily unavailable: ${error.message}`
|
||||
: 'Agent tool is temporarily unavailable.',
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
await sendMessage(input)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-5 right-5 z-40 flex max-w-[calc(100vw-1.5rem)] flex-col items-end gap-3">
|
||||
{open ? (
|
||||
<section className="w-[min(24rem,calc(100vw-1.5rem))] overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Explorer AI Agent Tool</h2>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Page-aware guidance for the explorer. Helpful, read-only, and designed for quick investigation.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
{QUICK_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
type="button"
|
||||
onClick={() => void sendMessage(prompt)}
|
||||
className="rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 dark:border-primary-500/30 dark:bg-primary-500/10 dark:text-primary-300 dark:hover:bg-primary-500/20"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-h-[22rem] space-y-3 overflow-y-auto px-4 py-3">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={`${message.role}-${index}`}
|
||||
className={`rounded-2xl px-3 py-2 text-sm ${
|
||||
message.role === 'assistant'
|
||||
? 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100'
|
||||
: 'ml-6 bg-primary-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
<label className="block">
|
||||
<span className="sr-only">Ask the explorer agent</span>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="Ask about this page, a transaction, a token, or an access-control flow."
|
||||
className="w-full rounded-xl 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-950 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Current view: {pathname}</p>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !input.trim()}
|
||||
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{submitting ? 'Thinking…' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-primary-600 px-4 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-primary-700"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-white/15">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-4l-4 4v-4Z" />
|
||||
</svg>
|
||||
</span>
|
||||
Agent Tool
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Navbar from './Navbar'
|
||||
import Footer from './Footer'
|
||||
import ExplorerAgentTool from './ExplorerAgentTool'
|
||||
|
||||
export default function ExplorerChrome({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-primary-600 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<Navbar />
|
||||
<div className="flex-1">{children}</div>
|
||||
<div id="main-content" className="flex-1">
|
||||
{children}
|
||||
</div>
|
||||
<ExplorerAgentTool />
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,15 +12,18 @@ export default function Footer() {
|
||||
<div className="grid gap-4 sm:gap-6 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white sm:text-lg">
|
||||
SolaceScanScout
|
||||
SolaceScan
|
||||
</div>
|
||||
<p className="max-w-xl text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Built from Blockscout foundations and Solace Bank Group PLC frontend
|
||||
work. Explorer data is powered by Blockscout, Chain 138 RPC, and the
|
||||
companion MetaMask Snap.
|
||||
Built on Blockscout for the DBIS / Defi Oracle 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">
|
||||
Public explorer access may appear under <code>blockscout.defi-oracle.io</code> or <code>explorer.d-bis.org</code>.
|
||||
Both domains belong to the same DBIS / Defi Oracle explorer surface.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
© {year} Solace Bank Group PLC. All rights reserved.
|
||||
© {year} DBIS / Defi Oracle. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -29,11 +32,12 @@ export default function Footer() {
|
||||
Resources
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><a className={footerLinkClass} href="/docs.html">Documentation</a></li>
|
||||
<li><Link className={footerLinkClass} href="/search">Search</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/docs">Documentation</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/bridge">Bridge Monitoring</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/liquidity">Liquidity Access</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/more">More Tools</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/operations">Operations Hub</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/addresses">Addresses</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/watchlist">Watchlist</Link></li>
|
||||
<li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li>
|
||||
@@ -55,8 +59,8 @@ export default function Footer() {
|
||||
</p>
|
||||
<p>
|
||||
Snap site:{' '}
|
||||
<a className={footerLinkClass} href="https://explorer.d-bis.org/snap/" target="_blank" rel="noopener noreferrer">
|
||||
explorer.d-bis.org/snap/
|
||||
<a className={footerLinkClass} href="/snap/" target="_blank" rel="noopener noreferrer">
|
||||
/snap/ on the current explorer domain
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
|
||||
214
frontend/src/components/common/GruStandardsCard.tsx
Normal file
214
frontend/src/components/common/GruStandardsCard.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import type { GruStandardsProfile } from '@/services/api/gru'
|
||||
import Link from 'next/link'
|
||||
|
||||
const STANDARD_EXPLANATIONS: Record<string, string> = {
|
||||
'ERC-20': 'Base fungible-token surface for wallets, DEXs, explorers, and accounting systems.',
|
||||
AccessControl: 'Role-governed administration for mint, burn, pause, and supervised operations.',
|
||||
Pausable: 'Emergency intervention surface for freezing activity during incidents or policy actions.',
|
||||
'EIP-712': 'Typed signing domain for structured off-chain approvals and payment flows.',
|
||||
'ERC-2612': 'Permit support for signature-based approvals without a separate on-chain approve transaction.',
|
||||
'ERC-3009': 'Authorization-based transfer model for signed payment flows without prior allowances.',
|
||||
'ERC-5267': 'Discoverable EIP-712 domain introspection so wallets and relayers can inspect the signing domain cleanly.',
|
||||
IeMoneyToken: 'Repo-native eMoney token methodology for issuance and redemption semantics.',
|
||||
DeterministicStorageNamespace: 'Stable namespace for upgrade-aware policy, registry, and audit resolution.',
|
||||
JurisdictionAndSupervisionMetadata: 'Governance, supervisory, disclosure, and reporting metadata required by the GRU operating model.',
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string | null {
|
||||
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return null
|
||||
const units = [
|
||||
{ label: 'day', value: 86400 },
|
||||
{ label: 'hour', value: 3600 },
|
||||
{ label: 'minute', value: 60 },
|
||||
]
|
||||
const parts: string[] = []
|
||||
let remaining = Math.floor(seconds)
|
||||
for (const unit of units) {
|
||||
if (remaining >= unit.value) {
|
||||
const count = Math.floor(remaining / unit.value)
|
||||
remaining -= count * unit.value
|
||||
parts.push(`${count} ${unit.label}${count === 1 ? '' : 's'}`)
|
||||
}
|
||||
if (parts.length === 2) break
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return `${remaining} second${remaining === 1 ? '' : 's'}`
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export default function GruStandardsCard({
|
||||
profile,
|
||||
title = 'GRU v2 Standards',
|
||||
}: {
|
||||
profile: GruStandardsProfile
|
||||
title?: string
|
||||
}) {
|
||||
const detectedCount = profile.standards.filter((standard) => standard.detected).length
|
||||
const requiredCount = profile.standards.filter((standard) => standard.required).length
|
||||
const missingRequired = profile.standards.filter((standard) => standard.required && !standard.detected)
|
||||
const noticePeriod = formatDuration(profile.minimumUpgradeNoticePeriodSeconds)
|
||||
const recommendations = [
|
||||
missingRequired.length > 0
|
||||
? `Review the live contract ABI and deployment against the GRU v2 base-token matrix before treating this asset as fully canonical.`
|
||||
: `The live contract exposes the full required GRU v2 base-token surface currently checked by the explorer.`,
|
||||
profile.wrappedTransport
|
||||
? 'This looks like a wrapped transport asset, so confirm the corresponding bridge lane and reserve-verifier posture in addition to the token ABI.'
|
||||
: 'This looks like a canonical GRU asset, so the next meaningful checks are reserve, governance, and transport activation beyond the token interface itself.',
|
||||
profile.x402Ready
|
||||
? 'This contract appears ready for x402-style payment flows because the explorer can see the required signature and domain surfaces.'
|
||||
: 'This contract does not currently look x402-ready from the live explorer surface; verify EIP-712, ERC-5267, and permit or authorization flow exposure before using it as a payment rail.',
|
||||
profile.forwardCanonical === true
|
||||
? 'This version is marked forward-canonical, so it should be treated as the preferred successor surface even if older liquidity or transport versions still coexist.'
|
||||
: profile.forwardCanonical === false
|
||||
? 'This version is not forward-canonical, which usually means it is legacy, staged, or transport-only relative to the intended primary canonical surface.'
|
||||
: 'Forward-canonical posture is not directly detectable on this contract, so rely on the transport overlay and deployment records before making promotion assumptions.',
|
||||
profile.legacyAliasSupport
|
||||
? 'Legacy alias support is exposed, which is useful during version cutovers and explorer/search reconciliation.'
|
||||
: 'Legacy alias support is not visible from the current explorer contract surface, so name/version migration may need registry or deployment-record cross-checks.',
|
||||
'Use the repo standards references to reconcile any missing surface with the intended GRU profile and rollout phase.',
|
||||
]
|
||||
|
||||
return (
|
||||
<Card title={title}>
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Profile">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge label={profile.profileId} tone="info" className="normal-case tracking-normal" />
|
||||
<EntityBadge
|
||||
label={profile.wrappedTransport ? 'wrapped transport' : 'canonical GRU'}
|
||||
tone={profile.wrappedTransport ? 'warning' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{detectedCount} of {requiredCount} required base-token standards are currently detectable from the live contract surface.
|
||||
</div>
|
||||
</div>
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow label="Standards" valueClassName="flex flex-wrap gap-2">
|
||||
{profile.standards.map((standard) => (
|
||||
<EntityBadge
|
||||
key={standard.id}
|
||||
label={standard.detected ? `${standard.id} detected` : `${standard.id} missing`}
|
||||
tone={standard.detected ? 'success' : 'warning'}
|
||||
className="normal-case tracking-normal"
|
||||
/>
|
||||
))}
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow label="Transport Posture">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge
|
||||
label={profile.x402Ready ? 'x402 ready' : 'x402 not ready'}
|
||||
tone={profile.x402Ready ? 'success' : 'warning'}
|
||||
/>
|
||||
<EntityBadge
|
||||
label={
|
||||
profile.forwardCanonical === true
|
||||
? 'forward canonical'
|
||||
: profile.forwardCanonical === false
|
||||
? 'not forward canonical'
|
||||
: 'forward canonical unknown'
|
||||
}
|
||||
tone={
|
||||
profile.forwardCanonical === true
|
||||
? 'success'
|
||||
: profile.forwardCanonical === false
|
||||
? 'warning'
|
||||
: 'info'
|
||||
}
|
||||
/>
|
||||
<EntityBadge
|
||||
label={profile.legacyAliasSupport ? 'legacy aliases exposed' : 'no alias surface'}
|
||||
tone={profile.legacyAliasSupport ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div 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="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Settlement posture</div>
|
||||
<div className="mt-2 text-gray-900 dark:text-white">
|
||||
{profile.wrappedTransport
|
||||
? 'This contract presents itself like a wrapped public-transport asset instead of the canonical Chain 138 money surface.'
|
||||
: 'This contract presents itself like the canonical Chain 138 GRU money surface instead of a wrapped transport mirror.'}
|
||||
</div>
|
||||
</div>
|
||||
<div 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="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Upgrade notice</div>
|
||||
<div className="mt-2 text-gray-900 dark:text-white">
|
||||
{noticePeriod
|
||||
? `${noticePeriod} (${profile.minimumUpgradeNoticePeriodSeconds} seconds)`
|
||||
: 'No readable minimum upgrade notice period was detected from the current explorer surface.'}
|
||||
</div>
|
||||
</div>
|
||||
<div 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="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Version posture</div>
|
||||
<div className="mt-2 text-gray-900 dark:text-white">
|
||||
{profile.activeVersion || profile.forwardVersion
|
||||
? `Active liquidity/transport version: ${profile.activeVersion || 'unknown'}; preferred forward version: ${profile.forwardVersion || 'unknown'}.`
|
||||
: 'No explicit active-versus-forward version posture is available from the local GRU catalog yet.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow label="Interpretation">
|
||||
<div className="space-y-3">
|
||||
{profile.standards.map((standard) => (
|
||||
<div key={`${standard.id}-explanation`} 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">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{standard.id}</div>
|
||||
<EntityBadge label={standard.detected ? 'detected' : 'missing'} tone={standard.detected ? 'success' : 'warning'} />
|
||||
</div>
|
||||
<div className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
{STANDARD_EXPLANATIONS[standard.id] || 'GRU-specific standard surfaced by the repo standards profile.'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailRow>
|
||||
|
||||
{profile.metadata.length > 0 ? (
|
||||
<DetailRow label="Metadata">
|
||||
<div className="space-y-3">
|
||||
{profile.metadata.map((field) => (
|
||||
<div key={field.label} 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="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{field.label}</div>
|
||||
<div className="mt-2 break-all text-gray-900 dark:text-white">{field.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailRow>
|
||||
) : null}
|
||||
|
||||
<DetailRow label="References">
|
||||
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div><Link href="/docs/gru" className="text-primary-600 hover:underline">Explorer GRU guide</Link></div>
|
||||
<div>Canonical profile: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">{profile.profileId}</code></div>
|
||||
<div>Repo standards matrix: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">docs/04-configuration/GRU_C_STAR_V2_STANDARDS_MATRIX_AND_IMPLEMENTATION_PLAN.md</code></div>
|
||||
<div>Machine-readable profile: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">config/gru-standards-profile.json</code></div>
|
||||
<div>Transport overlay: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">config/gru-transport-active.json</code></div>
|
||||
<div>x402 support note: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">docs/04-configuration/CHAIN138_X402_TOKEN_SUPPORT.md</code></div>
|
||||
<div>Chain 138 readiness guide: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">docs/04-configuration/GRU_V2_CHAIN138_READINESS.md</code></div>
|
||||
</div>
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow label="Recommendations">
|
||||
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{recommendations.map((item) => (
|
||||
<div key={item} className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { accessApi, type WalletAccessSession } from '@/services/api/access'
|
||||
|
||||
const navLink = 'text-gray-700 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors'
|
||||
const navLinkActive = 'text-primary-600 dark:text-primary-400 font-medium'
|
||||
const navItemBase =
|
||||
'rounded-xl px-3 py-2 text-[15px] font-medium transition-all duration-150'
|
||||
const navLink =
|
||||
`${navItemBase} text-gray-700 dark:text-gray-300 hover:bg-primary-50 hover:text-primary-700 dark:hover:bg-gray-700/70 dark:hover:text-primary-300`
|
||||
const navLinkActive =
|
||||
`${navItemBase} bg-primary-50 text-primary-700 shadow-sm ring-1 ring-primary-100 dark:bg-primary-500/10 dark:text-primary-300 dark:ring-primary-500/20`
|
||||
|
||||
function NavDropdown({
|
||||
label,
|
||||
icon,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
active?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
||||
const menuId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node | null
|
||||
if (!target || !wrapperRef.current?.contains(target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
document.addEventListener('touchstart', handlePointerDown)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown)
|
||||
document.removeEventListener('touchstart', handlePointerDown)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onBlurCapture={(event) => {
|
||||
const nextTarget = event.relatedTarget as Node | null
|
||||
if (nextTarget && wrapperRef.current?.contains(nextTarget)) {
|
||||
return
|
||||
}
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-md ${navLink}`}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`flex items-center gap-1.5 ${active && !open ? navLinkActive : navLink}`}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
aria-controls={menuId}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
<svg className={`w-3.5 h-3.5 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden>
|
||||
<svg className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<ul
|
||||
className="absolute left-0 top-full mt-1 min-w-[200px] rounded-lg bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50"
|
||||
id={menuId}
|
||||
className="absolute left-0 top-full z-50 mt-1 min-w-[220px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
role="menu"
|
||||
>
|
||||
{children}
|
||||
@@ -59,7 +111,8 @@ function DropdownItem({
|
||||
children: React.ReactNode
|
||||
external?: boolean
|
||||
}) {
|
||||
const className = `flex items-center gap-2 px-4 py-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 ${navLink}`
|
||||
const className =
|
||||
'flex items-center gap-2 px-4 py-2.5 text-gray-700 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-primary-400'
|
||||
if (external) {
|
||||
return (
|
||||
<li role="none">
|
||||
@@ -81,30 +134,91 @@ function DropdownItem({
|
||||
}
|
||||
|
||||
export default function Navbar() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname() ?? ''
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [exploreOpen, setExploreOpen] = useState(false)
|
||||
const [toolsOpen, setToolsOpen] = useState(false)
|
||||
const [dataOpen, setDataOpen] = useState(false)
|
||||
const [operationsOpen, setOperationsOpen] = useState(false)
|
||||
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
|
||||
const [connectingWallet, setConnectingWallet] = useState(false)
|
||||
|
||||
const isExploreActive =
|
||||
pathname === '/' ||
|
||||
pathname.startsWith('/blocks') ||
|
||||
pathname.startsWith('/transactions') ||
|
||||
pathname.startsWith('/addresses')
|
||||
const isDataActive =
|
||||
pathname.startsWith('/tokens') ||
|
||||
pathname.startsWith('/pools') ||
|
||||
pathname.startsWith('/analytics') ||
|
||||
pathname.startsWith('/watchlist')
|
||||
const isOperationsActive =
|
||||
pathname.startsWith('/bridge') ||
|
||||
pathname.startsWith('/routes') ||
|
||||
pathname.startsWith('/liquidity') ||
|
||||
pathname.startsWith('/operations') ||
|
||||
pathname.startsWith('/operator') ||
|
||||
pathname.startsWith('/system') ||
|
||||
pathname.startsWith('/weth')
|
||||
const isDocsActive = pathname.startsWith('/docs')
|
||||
const isAccessActive = pathname.startsWith('/access')
|
||||
|
||||
useEffect(() => {
|
||||
const syncWalletSession = () => {
|
||||
setWalletSession(accessApi.getStoredWalletSession())
|
||||
}
|
||||
|
||||
syncWalletSession()
|
||||
window.addEventListener('storage', syncWalletSession)
|
||||
window.addEventListener('explorer-access-session-changed', syncWalletSession)
|
||||
return () => {
|
||||
window.removeEventListener('storage', syncWalletSession)
|
||||
window.removeEventListener('explorer-access-session-changed', syncWalletSession)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAccessClick = async () => {
|
||||
if (walletSession) {
|
||||
router.push('/access')
|
||||
setMobileMenuOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setConnectingWallet(true)
|
||||
await accessApi.connectWalletSession()
|
||||
router.push('/access')
|
||||
setMobileMenuOpen(false)
|
||||
} catch (error) {
|
||||
console.error('Wallet connect failed', error)
|
||||
router.push('/access')
|
||||
setMobileMenuOpen(false)
|
||||
} finally {
|
||||
setConnectingWallet(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setMobileMenuOpen((open) => {
|
||||
const nextOpen = !open
|
||||
if (!nextOpen) {
|
||||
setExploreOpen(false)
|
||||
setToolsOpen(false)
|
||||
setDataOpen(false)
|
||||
setOperationsOpen(false)
|
||||
}
|
||||
return nextOpen
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="border-b border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-14 items-center justify-between sm:h-16">
|
||||
<div className="flex min-w-0 items-center gap-3 md:gap-8">
|
||||
<div className="flex min-w-0 items-center gap-3 md:gap-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="group inline-flex max-w-[calc(100vw-5rem)] flex-col rounded-xl px-2 py-1.5 text-base font-bold text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-gray-700/70 sm:max-w-none sm:px-3 sm:py-2 sm:text-xl"
|
||||
className="group inline-flex max-w-[calc(100vw-5rem)] flex-col rounded-xl px-2 py-1.5 text-base font-bold text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-gray-700/70 sm:max-w-none sm:px-2.5 sm:py-1.5 sm:text-[1.05rem]"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label="Go to explorer home"
|
||||
>
|
||||
@@ -116,58 +230,87 @@ export default function Navbar() {
|
||||
</span>
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="sm:hidden">SolaceScan</span>
|
||||
<span className="hidden sm:inline">SolaceScanScout</span>
|
||||
<span className="hidden sm:inline">SolaceScan</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 hidden text-xs font-normal text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200 sm:block">
|
||||
The Defi Oracle Meta Explorer
|
||||
<span className="mt-0.5 hidden text-[0.78rem] font-normal text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200 sm:block">
|
||||
Chain 138 Explorer by DBIS
|
||||
</span>
|
||||
</Link>
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
<div className="hidden items-center gap-1.5 md:flex">
|
||||
<NavDropdown
|
||||
label="Explore"
|
||||
icon={<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0h.5a2.5 2.5 0 002.5-2.5V3.935M12 12a2 2 0 104 0 2 2 0 00-4 0z" /></svg>}
|
||||
active={isExploreActive}
|
||||
icon={<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0h.5a2.5 2.5 0 002.5-2.5V3.935M12 12a2 2 0 104 0 2 2 0 00-4 0z" /></svg>}
|
||||
>
|
||||
<DropdownItem href="/" icon={<span className="text-gray-400">⌂</span>}>Home</DropdownItem>
|
||||
<DropdownItem href="/blocks" icon={<span className="text-gray-400">▣</span>}>Blocks</DropdownItem>
|
||||
<DropdownItem href="/transactions" icon={<span className="text-gray-400">⇄</span>}>Transactions</DropdownItem>
|
||||
<DropdownItem href="/addresses" icon={<span className="text-gray-400">⌗</span>}>Addresses</DropdownItem>
|
||||
</NavDropdown>
|
||||
<Link
|
||||
href="/search"
|
||||
className={`hidden md:inline-flex items-center ${pathname.startsWith('/search') ? navLinkActive : navLink}`}
|
||||
>
|
||||
Search
|
||||
</Link>
|
||||
<NavDropdown
|
||||
label="Data"
|
||||
active={isDataActive}
|
||||
icon={<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7h16M4 12h16M4 17h10" /></svg>}
|
||||
>
|
||||
<DropdownItem href="/tokens">Tokens</DropdownItem>
|
||||
<DropdownItem href="/analytics">Analytics</DropdownItem>
|
||||
<DropdownItem href="/pools">Pools</DropdownItem>
|
||||
<DropdownItem href="/watchlist">Watchlist</DropdownItem>
|
||||
</NavDropdown>
|
||||
<Link
|
||||
href="/docs"
|
||||
className={`hidden md:inline-flex items-center ${isDocsActive ? navLinkActive : navLink}`}
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<NavDropdown
|
||||
label="Operations"
|
||||
active={isOperationsActive}
|
||||
icon={<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>}
|
||||
>
|
||||
<DropdownItem href="/operations">Operations Hub</DropdownItem>
|
||||
<DropdownItem href="/bridge">Bridge</DropdownItem>
|
||||
<DropdownItem href="/routes">Routes</DropdownItem>
|
||||
<DropdownItem href="/liquidity">Liquidity</DropdownItem>
|
||||
<DropdownItem href="/system">System</DropdownItem>
|
||||
<DropdownItem href="/operator">Operator</DropdownItem>
|
||||
<DropdownItem href="/weth">WETH</DropdownItem>
|
||||
<DropdownItem href="/chain138-command-center.html" external>Command Center</DropdownItem>
|
||||
</NavDropdown>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className={`hidden md:inline-flex items-center px-3 py-2 rounded-md ${pathname.startsWith('/wallet') ? navLinkActive : navLink}`}
|
||||
className={`hidden md:inline-flex items-center ${pathname.startsWith('/wallet') ? navLinkActive : navLink}`}
|
||||
>
|
||||
Wallet
|
||||
</Link>
|
||||
<NavDropdown
|
||||
label="Tools"
|
||||
icon={<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAccessClick()}
|
||||
className={`hidden md:inline-flex items-center ${isAccessActive ? navLinkActive : navLink}`}
|
||||
>
|
||||
<DropdownItem href="/search">Search</DropdownItem>
|
||||
<DropdownItem href="/tokens">Tokens</DropdownItem>
|
||||
<DropdownItem href="/pools">Pools</DropdownItem>
|
||||
<DropdownItem href="/watchlist">Watchlist</DropdownItem>
|
||||
<DropdownItem href="/wallet">Wallet</DropdownItem>
|
||||
<DropdownItem href="/liquidity">Liquidity</DropdownItem>
|
||||
<DropdownItem href="/bridge">Bridge</DropdownItem>
|
||||
<DropdownItem href="/routes">Routes</DropdownItem>
|
||||
<DropdownItem href="/more">More</DropdownItem>
|
||||
<DropdownItem href="/chain138-command-center.html" external>Command Center</DropdownItem>
|
||||
</NavDropdown>
|
||||
{connectingWallet ? 'Connecting…' : walletSession ? 'Access' : 'Connect Wallet'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
className="rounded-md p-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
onClick={toggleMobileMenu}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -175,40 +318,62 @@ export default function Navbar() {
|
||||
{mobileMenuOpen && (
|
||||
<div className="border-t border-gray-200 py-2 pb-3 dark:border-gray-700 md:hidden">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link href="/" className={`px-3 py-2.5 rounded-md ${pathname === '/' ? navLinkActive : navLink}`} onClick={() => setMobileMenuOpen(false)}>Home</Link>
|
||||
<Link href="/" className={pathname === '/' ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Home</Link>
|
||||
<Link href="/search" className={pathname.startsWith('/search') ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Search</Link>
|
||||
<div className="relative">
|
||||
<button type="button" className={`flex items-center justify-between w-full px-3 py-2.5 rounded-md ${navLink}`} onClick={() => setExploreOpen((o) => !o)} aria-expanded={exploreOpen}>
|
||||
<button type="button" className={`flex w-full items-center justify-between ${navLink}`} onClick={() => setExploreOpen((value) => !value)} aria-expanded={exploreOpen}>
|
||||
<span>Explore</span>
|
||||
<svg className={`w-4 h-4 transition-transform ${exploreOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
<svg className={`h-4 w-4 transition-transform ${exploreOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
{exploreOpen && (
|
||||
<ul className="pl-4 mt-1 space-y-0.5">
|
||||
<li><Link href="/blocks" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Blocks</Link></li>
|
||||
<li><Link href="/transactions" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Transactions</Link></li>
|
||||
<li><Link href="/addresses" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Addresses</Link></li>
|
||||
<ul className="mt-1 space-y-0.5 pl-4">
|
||||
<li><Link href="/blocks" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Blocks</Link></li>
|
||||
<li><Link href="/transactions" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Transactions</Link></li>
|
||||
<li><Link href="/addresses" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Addresses</Link></li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button type="button" className={`flex items-center justify-between w-full px-3 py-2.5 rounded-md ${navLink}`} onClick={() => setToolsOpen((o) => !o)} aria-expanded={toolsOpen}>
|
||||
<span>Tools</span>
|
||||
<svg className={`w-4 h-4 transition-transform ${toolsOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
<button type="button" className={`flex w-full items-center justify-between ${navLink}`} onClick={() => setDataOpen((value) => !value)} aria-expanded={dataOpen}>
|
||||
<span>Data</span>
|
||||
<svg className={`h-4 w-4 transition-transform ${dataOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
{toolsOpen && (
|
||||
<ul className="pl-4 mt-1 space-y-0.5">
|
||||
<li><Link href="/search" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Search</Link></li>
|
||||
<li><Link href="/tokens" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Tokens</Link></li>
|
||||
<li><Link href="/pools" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Pools</Link></li>
|
||||
<li><Link href="/watchlist" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Watchlist</Link></li>
|
||||
<li><Link href="/wallet" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Wallet</Link></li>
|
||||
<li><Link href="/liquidity" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Liquidity</Link></li>
|
||||
<li><Link href="/bridge" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Bridge</Link></li>
|
||||
<li><Link href="/routes" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Routes</Link></li>
|
||||
<li><Link href="/more" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>More</Link></li>
|
||||
<li><a href="/chain138-command-center.html" className={`block px-3 py-2 rounded-md ${navLink}`} target="_blank" rel="noopener noreferrer" onClick={() => setMobileMenuOpen(false)}>Command Center</a></li>
|
||||
{dataOpen && (
|
||||
<ul className="mt-1 space-y-0.5 pl-4">
|
||||
<li><Link href="/tokens" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Tokens</Link></li>
|
||||
<li><Link href="/analytics" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Analytics</Link></li>
|
||||
<li><Link href="/pools" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Pools</Link></li>
|
||||
<li><Link href="/watchlist" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Watchlist</Link></li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Link href="/docs" className={isDocsActive ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Docs</Link>
|
||||
<div className="relative">
|
||||
<button type="button" className={`flex w-full items-center justify-between ${navLink}`} onClick={() => setOperationsOpen((value) => !value)} aria-expanded={operationsOpen}>
|
||||
<span>Operations</span>
|
||||
<svg className={`h-4 w-4 transition-transform ${operationsOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
{operationsOpen && (
|
||||
<ul className="mt-1 space-y-0.5 pl-4">
|
||||
<li><Link href="/operations" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Operations Hub</Link></li>
|
||||
<li><Link href="/bridge" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Bridge</Link></li>
|
||||
<li><Link href="/routes" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Routes</Link></li>
|
||||
<li><Link href="/liquidity" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Liquidity</Link></li>
|
||||
<li><Link href="/system" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>System</Link></li>
|
||||
<li><Link href="/operator" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Operator</Link></li>
|
||||
<li><Link href="/weth" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>WETH</Link></li>
|
||||
<li><a href="/chain138-command-center.html" className={`block rounded-md px-3 py-2 ${navLink}`} target="_blank" rel="noopener noreferrer" onClick={() => setMobileMenuOpen(false)}>Command Center</a></li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Link href="/wallet" className={pathname.startsWith('/wallet') ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Wallet</Link>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full text-left ${isAccessActive ? navLinkActive : navLink}`}
|
||||
onClick={() => void handleAccessClick()}
|
||||
>
|
||||
{connectingWallet ? 'Connecting wallet…' : walletSession ? 'Access' : 'Connect wallet'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
45
frontend/src/components/common/PageIntro.tsx
Normal file
45
frontend/src/components/common/PageIntro.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface PageIntroAction {
|
||||
href: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function PageIntro({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions = [],
|
||||
}: {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
description: string
|
||||
actions?: PageIntroAction[]
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 rounded-3xl border border-gray-200 bg-white/90 p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800/80 sm:mb-8 sm:p-6">
|
||||
{eyebrow ? (
|
||||
<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 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-300">
|
||||
{eyebrow}
|
||||
</div>
|
||||
) : null}
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">{title}</h1>
|
||||
<p className="mt-3 max-w-4xl text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base">
|
||||
{description}
|
||||
</p>
|
||||
{actions.length > 0 ? (
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{actions.map((action) => (
|
||||
<Link
|
||||
key={`${action.href}-${action.label}`}
|
||||
href={action.href}
|
||||
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-200 dark:hover:text-primary-300"
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import { blocksApi, type Block } from '@/services/api/blocks'
|
||||
import {
|
||||
@@ -7,8 +8,14 @@ import {
|
||||
type MissionControlBridgeStatusResponse,
|
||||
type MissionControlChainStatus,
|
||||
} from '@/services/api/missionControl'
|
||||
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
import {
|
||||
statsApi,
|
||||
type ExplorerRecentActivitySnapshot,
|
||||
type ExplorerStats,
|
||||
type ExplorerTransactionTrendPoint,
|
||||
} from '@/services/api/stats'
|
||||
import { transactionsApi, type Transaction } from '@/services/api/transactions'
|
||||
import { formatWeiAsEth } from '@/utils/format'
|
||||
import OperationsPageShell, {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
@@ -17,6 +24,15 @@ import OperationsPageShell, {
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
interface AnalyticsOperationsPageProps {
|
||||
initialStats?: ExplorerStats | null
|
||||
initialTransactionTrend?: ExplorerTransactionTrendPoint[]
|
||||
initialActivitySnapshot?: ExplorerRecentActivitySnapshot | null
|
||||
initialBlocks?: Block[]
|
||||
initialTransactions?: Transaction[]
|
||||
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||
}
|
||||
|
||||
function getChainStatus(bridgeStatus: MissionControlBridgeStatusResponse | null): MissionControlChainStatus | null {
|
||||
const chains = bridgeStatus?.data?.chains
|
||||
if (!chains) return null
|
||||
@@ -24,11 +40,20 @@ function getChainStatus(bridgeStatus: MissionControlBridgeStatusResponse | null)
|
||||
return firstChain || null
|
||||
}
|
||||
|
||||
export default function AnalyticsOperationsPage() {
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(null)
|
||||
const [blocks, setBlocks] = useState<Block[]>([])
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
export default function AnalyticsOperationsPage({
|
||||
initialStats = null,
|
||||
initialTransactionTrend = [],
|
||||
initialActivitySnapshot = null,
|
||||
initialBlocks = [],
|
||||
initialTransactions = [],
|
||||
initialBridgeStatus = null,
|
||||
}: AnalyticsOperationsPageProps) {
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||
const [transactionTrend, setTransactionTrend] = useState<ExplorerTransactionTrendPoint[]>(initialTransactionTrend)
|
||||
const [activitySnapshot, setActivitySnapshot] = useState<ExplorerRecentActivitySnapshot | null>(initialActivitySnapshot)
|
||||
const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
|
||||
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.analytics
|
||||
|
||||
@@ -36,8 +61,10 @@ export default function AnalyticsOperationsPage() {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const [statsResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
|
||||
const [statsResult, trendResult, snapshotResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
|
||||
statsApi.get(),
|
||||
statsApi.getTransactionTrend(),
|
||||
statsApi.getRecentActivitySnapshot(),
|
||||
blocksApi.list({ chain_id: 138, page: 1, page_size: 5 }),
|
||||
transactionsApi.list(138, 1, 5),
|
||||
missionControlApi.getBridgeStatus(),
|
||||
@@ -46,15 +73,17 @@ export default function AnalyticsOperationsPage() {
|
||||
if (cancelled) return
|
||||
|
||||
if (statsResult.status === 'fulfilled') setStats(statsResult.value)
|
||||
if (trendResult.status === 'fulfilled') setTransactionTrend(trendResult.value)
|
||||
if (snapshotResult.status === 'fulfilled') setActivitySnapshot(snapshotResult.value)
|
||||
if (blocksResult.status === 'fulfilled') setBlocks(blocksResult.value.data)
|
||||
if (transactionsResult.status === 'fulfilled') setTransactions(transactionsResult.value.data)
|
||||
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
|
||||
|
||||
const failedCount = [statsResult, blocksResult, transactionsResult, bridgeResult].filter(
|
||||
const failedCount = [statsResult, trendResult, snapshotResult, blocksResult, transactionsResult, bridgeResult].filter(
|
||||
(result) => result.status === 'rejected'
|
||||
).length
|
||||
|
||||
if (failedCount === 4) {
|
||||
if (failedCount === 6) {
|
||||
setLoadingError('Analytics data is temporarily unavailable from the public explorer APIs.')
|
||||
}
|
||||
}
|
||||
@@ -71,6 +100,27 @@ export default function AnalyticsOperationsPage() {
|
||||
}, [])
|
||||
|
||||
const chainStatus = useMemo(() => getChainStatus(bridgeStatus), [bridgeStatus])
|
||||
const trailingWindow = useMemo(() => transactionTrend.slice(0, 7), [transactionTrend])
|
||||
const sevenDayAverage = useMemo(() => {
|
||||
if (trailingWindow.length === 0) return 0
|
||||
const total = trailingWindow.reduce((sum, point) => sum + point.transaction_count, 0)
|
||||
return total / trailingWindow.length
|
||||
}, [trailingWindow])
|
||||
const topDay = useMemo(() => {
|
||||
if (trailingWindow.length === 0) return null
|
||||
return trailingWindow.reduce((best, point) => (point.transaction_count > best.transaction_count ? point : best))
|
||||
}, [trailingWindow])
|
||||
const averageGasUtilization = useMemo(() => {
|
||||
if (blocks.length === 0) return 0
|
||||
return blocks.reduce((sum, block) => {
|
||||
const ratio = block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0
|
||||
return sum + ratio
|
||||
}, 0) / blocks.length
|
||||
}, [blocks])
|
||||
const trendPeak = useMemo(
|
||||
() => trailingWindow.reduce((max, point) => Math.max(max, point.transaction_count), 0),
|
||||
[trailingWindow],
|
||||
)
|
||||
|
||||
return (
|
||||
<OperationsPageShell page={page}>
|
||||
@@ -107,9 +157,111 @@ export default function AnalyticsOperationsPage() {
|
||||
: 'Latest public RPC head age from mission control.'
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
title="7d Avg Tx"
|
||||
value={formatNumber(Math.round(sevenDayAverage))}
|
||||
description="Average daily transactions over the latest seven charted days."
|
||||
className="border border-violet-200 bg-violet-50/70 dark:border-violet-900/50 dark:bg-violet-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Recent Success Rate"
|
||||
value={activitySnapshot ? `${Math.round(activitySnapshot.success_rate * 100)}%` : 'Unknown'}
|
||||
description="Success rate across the public main-page transaction sample."
|
||||
/>
|
||||
<MetricCard
|
||||
title="Failure Rate"
|
||||
value={activitySnapshot ? `${Math.round(activitySnapshot.failure_rate * 100)}%` : 'Unknown'}
|
||||
description="The complement to the recent success rate in the visible sample."
|
||||
className="border border-rose-200 bg-rose-50/70 dark:border-rose-900/50 dark:bg-rose-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Avg Gas Used"
|
||||
value={activitySnapshot ? formatNumber(Math.round(activitySnapshot.average_gas_used)) : 'Unknown'}
|
||||
description="Average gas used in the recent sampled transactions."
|
||||
/>
|
||||
<MetricCard
|
||||
title="Avg Block Gas"
|
||||
value={`${Math.round(averageGasUtilization * 100)}%`}
|
||||
description="Average gas utilization across the latest visible blocks."
|
||||
className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Activity Trend">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Peak Day</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{topDay ? formatNumber(topDay.transaction_count) : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{topDay?.date || 'No trend data yet'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Contract Creations</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{formatNumber(activitySnapshot?.contract_creations)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Within the sampled recent transaction feed.</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Avg Sample Fee</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{activitySnapshot ? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6) : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Average fee from the recent public transaction sample.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activitySnapshot ? (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token Transfer Share</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{Math.round(activitySnapshot.token_transfer_share * 100)}%
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions involving token transfers.</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Contract Call Share</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{Math.round(activitySnapshot.contract_call_share * 100)}%
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions calling contracts.</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Creation Share</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{Math.round(activitySnapshot.contract_creation_share * 100)}%
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions deploying contracts.</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{trailingWindow.map((point) => {
|
||||
const width = trendPeak > 0 ? Math.max(8, Math.round((point.transaction_count / trendPeak) * 100)) : 0
|
||||
return (
|
||||
<div key={point.date}>
|
||||
<div className="mb-1 flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>{point.date}</span>
|
||||
<span>{formatNumber(point.transaction_count)} tx</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-gray-200 dark:bg-gray-800">
|
||||
<div className="h-2 rounded-full bg-primary-600" style={{ width: `${width}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{trailingWindow.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Trend data is temporarily unavailable.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Recent Blocks">
|
||||
<div className="space-y-4">
|
||||
{blocks.map((block) => (
|
||||
@@ -119,15 +271,18 @@ export default function AnalyticsOperationsPage() {
|
||||
>
|
||||
<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">
|
||||
<Link href={`/blocks/${block.number}`} className="text-base font-semibold text-primary-600 hover:underline">
|
||||
Block {formatNumber(block.number)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{truncateMiddle(block.hash)} · miner {truncateMiddle(block.miner)}
|
||||
{truncateMiddle(block.hash)} · miner{' '}
|
||||
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
|
||||
{truncateMiddle(block.miner)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatNumber(block.transaction_count)} tx · {relativeAge(block.timestamp)}
|
||||
{formatNumber(block.transaction_count)} tx · {Math.round((block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0) * 100)}% gas · {relativeAge(block.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,11 +302,26 @@ export default function AnalyticsOperationsPage() {
|
||||
>
|
||||
<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">
|
||||
<Link href={`/transactions/${transaction.hash}`} className="text-base font-semibold text-primary-600 hover:underline">
|
||||
{truncateMiddle(transaction.hash, 12, 10)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Block {formatNumber(transaction.block_number)} · from {truncateMiddle(transaction.from_address)}
|
||||
Block{' '}
|
||||
<Link href={`/blocks/${transaction.block_number}`} className="text-primary-600 hover:underline">
|
||||
{formatNumber(transaction.block_number)}
|
||||
</Link>
|
||||
{' '}· from{' '}
|
||||
<Link href={`/addresses/${transaction.from_address}`} className="text-primary-600 hover:underline">
|
||||
{truncateMiddle(transaction.from_address)}
|
||||
</Link>
|
||||
{transaction.to_address ? (
|
||||
<>
|
||||
{' '}· to{' '}
|
||||
<Link href={`/addresses/${transaction.to_address}`} className="text-primary-600 hover:underline">
|
||||
{truncateMiddle(transaction.to_address)}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -164,6 +334,13 @@ export default function AnalyticsOperationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
{transaction.method ? <StatusBadge status={transaction.method} tone="warning" /> : null}
|
||||
{transaction.contract_address ? <StatusBadge status="contract creation" tone="warning" /> : null}
|
||||
{transaction.token_transfers && transaction.token_transfers.length > 0 ? (
|
||||
<StatusBadge status={`${transaction.token_transfers.length} token transfer${transaction.token_transfers.length === 1 ? '' : 's'}`} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{transactions.length === 0 ? (
|
||||
|
||||
@@ -100,9 +100,13 @@ function ActionLink({
|
||||
)
|
||||
}
|
||||
|
||||
export default function BridgeMonitoringPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [feedState, setFeedState] = useState<FeedState>('connecting')
|
||||
export default function BridgeMonitoringPage({
|
||||
initialBridgeStatus = null,
|
||||
}: {
|
||||
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||
}) {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [feedState, setFeedState] = useState<FeedState>(initialBridgeStatus ? 'fallback' : 'connecting')
|
||||
const page = explorerFeaturePages.bridge
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -30,21 +30,55 @@ interface TokenPoolRecord {
|
||||
pools: MissionControlLiquidityPool[]
|
||||
}
|
||||
|
||||
interface EndpointCard {
|
||||
name: string
|
||||
method: string
|
||||
href: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
interface LiquidityOperationsPageProps {
|
||||
initialTokenList?: TokenListResponse | null
|
||||
initialRouteMatrix?: RouteMatrixResponse | null
|
||||
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
|
||||
initialInternalPlan?: InternalExecutionPlanResponse | null
|
||||
initialTokenPoolRecords?: TokenPoolRecord[]
|
||||
}
|
||||
|
||||
function routePairLabel(routeId: string, routeLabel: string, tokenIn?: string, tokenOut?: string): string {
|
||||
return [tokenIn, tokenOut].filter(Boolean).join(' / ') || routeLabel || routeId
|
||||
}
|
||||
|
||||
export default function LiquidityOperationsPage() {
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
|
||||
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>([])
|
||||
export default function LiquidityOperationsPage({
|
||||
initialTokenList = null,
|
||||
initialRouteMatrix = null,
|
||||
initialPlannerCapabilities = null,
|
||||
initialInternalPlan = null,
|
||||
initialTokenPoolRecords = [],
|
||||
}: LiquidityOperationsPageProps) {
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
|
||||
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>(initialTokenPoolRecords)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
if (
|
||||
initialTokenList &&
|
||||
initialRouteMatrix &&
|
||||
initialPlannerCapabilities &&
|
||||
initialInternalPlan &&
|
||||
initialTokenPoolRecords.length > 0
|
||||
) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult] =
|
||||
await Promise.allSettled([
|
||||
@@ -102,7 +136,13 @@ export default function LiquidityOperationsPage() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [
|
||||
initialInternalPlan,
|
||||
initialPlannerCapabilities,
|
||||
initialRouteMatrix,
|
||||
initialTokenList,
|
||||
initialTokenPoolRecords,
|
||||
])
|
||||
|
||||
const featuredTokens = useMemo(
|
||||
() => selectFeaturedLiquidityTokens(tokenList?.tokens || []),
|
||||
@@ -139,7 +179,7 @@ export default function LiquidityOperationsPage() {
|
||||
[routeMatrix, aggregatedPools.length, featuredTokens.length, livePlannerProviders.length, internalPlan?.plannerResponse?.decision, routeBackedPoolAddresses.length]
|
||||
)
|
||||
|
||||
const endpointCards = [
|
||||
const endpointCards: EndpointCard[] = [
|
||||
{
|
||||
name: 'Canonical route matrix',
|
||||
method: 'GET',
|
||||
@@ -166,6 +206,18 @@ export default function LiquidityOperationsPage() {
|
||||
},
|
||||
]
|
||||
|
||||
const copyEndpoint = async (endpoint: EndpointCard) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(endpoint.href)
|
||||
setCopiedEndpoint(endpoint.name)
|
||||
window.setTimeout(() => {
|
||||
setCopiedEndpoint((current) => (current === endpoint.name ? null : current))
|
||||
}, 1500)
|
||||
} catch {
|
||||
setCopiedEndpoint(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-8 max-w-4xl">
|
||||
@@ -258,9 +310,16 @@ export default function LiquidityOperationsPage() {
|
||||
</div>
|
||||
))}
|
||||
{aggregatedPools.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No live pool inventory is available right now.
|
||||
</p>
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50/70 p-4 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<p className="text-sm leading-6 text-amber-900 dark:text-amber-100">
|
||||
Mission-control pool inventory is currently empty, but the live route matrix still references{' '}
|
||||
{formatNumber(routeBackedPoolAddresses.length)} pool-backed legs across{' '}
|
||||
{formatNumber(routeMatrix?.counts?.filteredLiveRoutes)} published live routes.
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-amber-900/80 dark:text-amber-100/80">
|
||||
Use the highlighted route-backed paths below and the public route matrix endpoint while pool inventory catches up.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -339,12 +398,9 @@ export default function LiquidityOperationsPage() {
|
||||
<Card title="Explorer Access Points">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{endpointCards.map((endpoint) => (
|
||||
<a
|
||||
<div
|
||||
key={endpoint.href}
|
||||
href={endpoint.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 transition hover:border-primary-400 hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{endpoint.name}</div>
|
||||
@@ -356,7 +412,24 @@ export default function LiquidityOperationsPage() {
|
||||
{endpoint.href}
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-400">{endpoint.notes}</div>
|
||||
</a>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyEndpoint(endpoint)}
|
||||
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"
|
||||
>
|
||||
{copiedEndpoint === endpoint.name ? 'Copied' : 'Copy endpoint'}
|
||||
</button>
|
||||
{endpoint.name === 'Mission-control token pools' ? (
|
||||
<Link
|
||||
href="/pools"
|
||||
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 pools page
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -404,12 +477,12 @@ export default function LiquidityOperationsPage() {
|
||||
>
|
||||
Open wallet tools
|
||||
</Link>
|
||||
<a
|
||||
href="/docs.html"
|
||||
<Link
|
||||
href="/docs"
|
||||
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Explorer docs
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -45,14 +45,28 @@ function ActionLink({
|
||||
)
|
||||
}
|
||||
|
||||
export default function MoreOperationsPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(null)
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
|
||||
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(null)
|
||||
interface OperationsHubPageProps {
|
||||
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||
initialRouteMatrix?: RouteMatrixResponse | null
|
||||
initialNetworksConfig?: NetworksConfigResponse | null
|
||||
initialTokenList?: TokenListResponse | null
|
||||
initialCapabilities?: CapabilitiesResponse | null
|
||||
}
|
||||
|
||||
export default function OperationsHubPage({
|
||||
initialBridgeStatus = null,
|
||||
initialRouteMatrix = null,
|
||||
initialNetworksConfig = null,
|
||||
initialTokenList = null,
|
||||
initialCapabilities = null,
|
||||
}: OperationsHubPageProps) {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
|
||||
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(initialNetworksConfig)
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
|
||||
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(initialCapabilities)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.more
|
||||
const page = explorerFeaturePages.operations
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -17,6 +17,13 @@ import OperationsPageShell, {
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
interface OperatorOperationsPageProps {
|
||||
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||
initialRouteMatrix?: RouteMatrixResponse | null
|
||||
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
|
||||
initialInternalPlan?: InternalExecutionPlanResponse | null
|
||||
}
|
||||
|
||||
function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
|
||||
const normalized = String(status || 'unknown').toLowerCase()
|
||||
if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger'
|
||||
@@ -24,11 +31,16 @@ function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
export default function OperatorOperationsPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
|
||||
export default function OperatorOperationsPage({
|
||||
initialBridgeStatus = null,
|
||||
initialRouteMatrix = null,
|
||||
initialPlannerCapabilities = null,
|
||||
initialInternalPlan = null,
|
||||
}: OperatorOperationsPageProps) {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.operator
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ export default function PoolsOperationsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Liquidity Shortcuts">
|
||||
<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.
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
type RouteMatrixResponse,
|
||||
} from '@/services/api/routes'
|
||||
|
||||
interface RoutesMonitoringPageProps {
|
||||
initialRouteMatrix?: RouteMatrixResponse | null
|
||||
initialNetworks?: ExplorerNetwork[]
|
||||
initialPools?: MissionControlLiquidityPool[]
|
||||
}
|
||||
|
||||
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
|
||||
|
||||
function relativeAge(isoString?: string): string {
|
||||
@@ -80,10 +86,14 @@ function ActionLink({
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoutesMonitoringPage() {
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [networks, setNetworks] = useState<ExplorerNetwork[]>([])
|
||||
const [pools, setPools] = useState<MissionControlLiquidityPool[]>([])
|
||||
export default function RoutesMonitoringPage({
|
||||
initialRouteMatrix = null,
|
||||
initialNetworks = [],
|
||||
initialPools = [],
|
||||
}: RoutesMonitoringPageProps) {
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
|
||||
const [networks, setNetworks] = useState<ExplorerNetwork[]>(initialNetworks)
|
||||
const [pools, setPools] = useState<MissionControlLiquidityPool[]>(initialPools)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.routes
|
||||
|
||||
@@ -389,7 +399,7 @@ export default function RoutesMonitoringPage() {
|
||||
<ActionLink
|
||||
href={action.href}
|
||||
label={action.label}
|
||||
external={'external' in action ? action.external : undefined}
|
||||
external={Boolean((action as { external?: boolean }).external)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,13 +12,29 @@ import OperationsPageShell, {
|
||||
relativeAge,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
export default function SystemOperationsPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(null)
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
|
||||
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(null)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(null)
|
||||
interface SystemOperationsPageProps {
|
||||
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||
initialNetworksConfig?: NetworksConfigResponse | null
|
||||
initialTokenList?: TokenListResponse | null
|
||||
initialCapabilities?: CapabilitiesResponse | null
|
||||
initialRouteMatrix?: RouteMatrixResponse | null
|
||||
initialStats?: ExplorerStats | null
|
||||
}
|
||||
|
||||
export default function SystemOperationsPage({
|
||||
initialBridgeStatus = null,
|
||||
initialNetworksConfig = null,
|
||||
initialTokenList = null,
|
||||
initialCapabilities = null,
|
||||
initialRouteMatrix = null,
|
||||
initialStats = null,
|
||||
}: SystemOperationsPageProps) {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(initialNetworksConfig)
|
||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
|
||||
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(initialCapabilities)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.system
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ import OperationsPageShell, {
|
||||
truncateMiddle,
|
||||
} from './OperationsPageShell'
|
||||
|
||||
interface WethOperationsPageProps {
|
||||
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
|
||||
initialInternalPlan?: InternalExecutionPlanResponse | null
|
||||
}
|
||||
|
||||
function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
|
||||
const normalized = String(status || 'unknown').toLowerCase()
|
||||
if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger'
|
||||
@@ -27,10 +33,14 @@ function relaySnapshot(relay: MissionControlRelayPayload | undefined) {
|
||||
return relay?.url_probe?.body || relay?.file_snapshot
|
||||
}
|
||||
|
||||
export default function WethOperationsPage() {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
|
||||
export default function WethOperationsPage({
|
||||
initialBridgeStatus = null,
|
||||
initialPlannerCapabilities = null,
|
||||
initialInternalPlan = null,
|
||||
}: WethOperationsPageProps) {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
|
||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const page = explorerFeaturePages.weth
|
||||
|
||||
@@ -85,6 +95,13 @@ export default function WethOperationsPage() {
|
||||
|
||||
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 and transport 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>
|
||||
|
||||
{loadingError ? (
|
||||
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
||||
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
|
||||
|
||||
427
frontend/src/components/home/HomePage.tsx
Normal file
427
frontend/src/components/home/HomePage.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives/Card'
|
||||
import Link from 'next/link'
|
||||
import { blocksApi, type Block } from '@/services/api/blocks'
|
||||
import {
|
||||
statsApi,
|
||||
type ExplorerRecentActivitySnapshot,
|
||||
type ExplorerStats,
|
||||
type ExplorerTransactionTrendPoint,
|
||||
} from '@/services/api/stats'
|
||||
import {
|
||||
missionControlApi,
|
||||
type MissionControlRelaySummary,
|
||||
} from '@/services/api/missionControl'
|
||||
import { loadDashboardData } from '@/utils/dashboard'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import { formatTimestamp, formatWeiAsEth } from '@/utils/format'
|
||||
|
||||
type HomeStats = ExplorerStats
|
||||
|
||||
interface HomePageProps {
|
||||
initialStats?: HomeStats | null
|
||||
initialRecentBlocks?: Block[]
|
||||
initialTransactionTrend?: ExplorerTransactionTrendPoint[]
|
||||
initialActivitySnapshot?: ExplorerRecentActivitySnapshot | null
|
||||
initialRelaySummary?: MissionControlRelaySummary | null
|
||||
}
|
||||
|
||||
export default function Home({
|
||||
initialStats = null,
|
||||
initialRecentBlocks = [],
|
||||
initialTransactionTrend = [],
|
||||
initialActivitySnapshot = null,
|
||||
initialRelaySummary = null,
|
||||
}: HomePageProps) {
|
||||
const [stats, setStats] = useState<HomeStats | null>(initialStats)
|
||||
const [recentBlocks, setRecentBlocks] = useState<Block[]>(initialRecentBlocks)
|
||||
const [transactionTrend, setTransactionTrend] = useState<ExplorerTransactionTrendPoint[]>(initialTransactionTrend)
|
||||
const [activitySnapshot, setActivitySnapshot] = useState<ExplorerRecentActivitySnapshot | null>(initialActivitySnapshot)
|
||||
const [relaySummary, setRelaySummary] = useState<MissionControlRelaySummary | null>(initialRelaySummary)
|
||||
const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>(
|
||||
initialRelaySummary ? 'fallback' : 'connecting'
|
||||
)
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const latestBlock = stats?.latest_block ?? recentBlocks[0]?.number ?? null
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
const dashboardData = await loadDashboardData({
|
||||
loadStats: () => statsApi.get(),
|
||||
loadRecentTransactionTrend: () => statsApi.getTransactionTrend(),
|
||||
loadRecentBlocks: async () => {
|
||||
const response = await blocksApi.list({
|
||||
chain_id: chainId,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
onError: (scope, error) => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(`Failed to load dashboard ${scope}:`, error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
setStats((current) => dashboardData.stats ?? current)
|
||||
setRecentBlocks((current) => (dashboardData.recentBlocks.length > 0 ? dashboardData.recentBlocks : current))
|
||||
setTransactionTrend((current) =>
|
||||
(dashboardData.recentTransactionTrend || []).length > 0 ? dashboardData.recentTransactionTrend : current,
|
||||
)
|
||||
}, [chainId])
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard()
|
||||
}, [loadDashboard])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
statsApi.getRecentActivitySnapshot().then((snapshot) => {
|
||||
if (!cancelled) {
|
||||
setActivitySnapshot(snapshot)
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to load recent activity snapshot:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadSnapshot = async () => {
|
||||
try {
|
||||
const summary = await missionControlApi.getRelaySummary()
|
||||
if (!cancelled) {
|
||||
setRelaySummary(summary)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to load mission control relay summary:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSnapshot()
|
||||
|
||||
const unsubscribe = missionControlApi.subscribeRelaySummary(
|
||||
(summary) => {
|
||||
if (!cancelled) {
|
||||
setRelaySummary(summary)
|
||||
setRelayFeedState('live')
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
if (!cancelled) {
|
||||
setRelayFeedState('fallback')
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Mission control live stream update issue:', error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const relayToneClasses =
|
||||
relaySummary?.tone === 'danger'
|
||||
? 'border-red-200 bg-red-50 text-red-900 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100'
|
||||
: relaySummary?.tone === 'warning'
|
||||
? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100'
|
||||
: 'border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-100'
|
||||
const latestTrendPoint = transactionTrend[0] || null
|
||||
const peakTrendPoint = transactionTrend.reduce<ExplorerTransactionTrendPoint | null>(
|
||||
(best, point) => (!best || point.transaction_count > best.transaction_count ? point : best),
|
||||
null,
|
||||
)
|
||||
const relayAttentionCount = relaySummary?.items.filter((item) => item.tone !== 'normal').length || 0
|
||||
const relayOperationalCount = relaySummary?.items.filter((item) => item.tone === 'normal').length || 0
|
||||
const relayPrimaryItems = relaySummary?.items.slice(0, 6) || []
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="mb-2 text-3xl font-bold sm:text-4xl">SolaceScan</h1>
|
||||
<p className="text-base text-gray-600 dark:text-gray-400 sm:text-lg">Chain 138 Explorer by DBIS</p>
|
||||
</div>
|
||||
|
||||
{relaySummary && (
|
||||
<Card className={`mb-6 border shadow-sm ${relayToneClasses}`}>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.22em] opacity-75">Mission Control</div>
|
||||
<div className="mt-2 text-xl font-semibold sm:text-2xl">
|
||||
{relaySummary.tone === 'danger'
|
||||
? 'Relay lanes need attention'
|
||||
: relaySummary.tone === 'warning'
|
||||
? 'Relay lanes are degraded'
|
||||
: 'Relay lanes are operational'}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 opacity-90 sm:text-base">
|
||||
{relaySummary.text}. This surface summarizes the public relay posture in a compact operator-friendly format.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<EntityBadge
|
||||
label={relayFeedState === 'live' ? 'live sse' : relayFeedState === 'fallback' ? 'snapshot fallback' : 'connecting'}
|
||||
tone={relayFeedState === 'fallback' ? 'warning' : relayFeedState === 'connecting' ? 'info' : 'success'}
|
||||
/>
|
||||
<EntityBadge
|
||||
label={relaySummary.tone === 'danger' ? 'attention needed' : relaySummary.tone === 'warning' ? 'degraded' : 'operational'}
|
||||
tone={relaySummary.tone === 'danger' ? 'warning' : relaySummary.tone === 'warning' ? 'info' : 'success'}
|
||||
/>
|
||||
<EntityBadge label={`${relayOperationalCount} operational`} tone="success" />
|
||||
<EntityBadge label={`${relayAttentionCount} flagged`} tone={relayAttentionCount > 0 ? 'warning' : 'info'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-[220px] gap-3 sm:grid-cols-2 lg:w-[290px] lg:grid-cols-1">
|
||||
<div className="rounded-2xl border border-white/40 bg-white/50 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide opacity-70">Live Feed</div>
|
||||
<div className="mt-2 text-lg font-semibold">
|
||||
{relayFeedState === 'live' ? 'Streaming' : relayFeedState === 'fallback' ? 'Snapshot mode' : 'Connecting'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm opacity-80">
|
||||
{relayFeedState === 'live'
|
||||
? 'Receiving named mission-control events.'
|
||||
: relayFeedState === 'fallback'
|
||||
? 'Using the latest available snapshot.'
|
||||
: 'Negotiating the event stream.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
href="/operations"
|
||||
className="inline-flex items-center justify-center rounded-xl bg-gray-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-black dark:bg-white dark:text-gray-900 dark:hover:bg-gray-100"
|
||||
>
|
||||
Open operations hub
|
||||
</Link>
|
||||
<Link
|
||||
href="/explorer-api/v1/mission-control/stream"
|
||||
className="inline-flex items-center justify-center rounded-xl border border-current/20 px-4 py-2.5 text-sm font-semibold hover:bg-white/40 dark:hover:bg-black/10"
|
||||
>
|
||||
Open live stream
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{relayPrimaryItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="rounded-2xl border border-white/40 bg-white/55 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{item.label}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-wide opacity-70">{item.status}</div>
|
||||
</div>
|
||||
<EntityBadge
|
||||
label={item.tone === 'danger' ? 'flagged' : item.tone === 'warning' ? 'degraded' : 'live'}
|
||||
tone={item.tone === 'danger' ? 'warning' : item.tone === 'warning' ? 'info' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 opacity-90">{item.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{relaySummary.items.length > relayPrimaryItems.length ? (
|
||||
<div className="text-sm opacity-80">
|
||||
Showing {relayPrimaryItems.length} of {relaySummary.items.length} relay lanes. The live stream and operations hub carry the fuller view.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="mb-8 grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Latest Block</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">
|
||||
{latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Total Blocks</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{stats.total_blocks.toLocaleString()}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Total Transactions</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{stats.total_transactions.toLocaleString()}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Total Addresses</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{stats.total_addresses.toLocaleString()}</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!stats && (
|
||||
<Card className="mb-8">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Live network stats are temporarily unavailable. Recent blocks and explorer tools are still available below.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Recent Blocks">
|
||||
{recentBlocks.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Recent blocks are unavailable right now.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentBlocks.map((block) => (
|
||||
<div key={block.number} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
|
||||
<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-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card title="Activity Pulse">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
A concise public view of chain activity, index coverage, and recent execution patterns.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 sm: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-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Latest Daily Volume</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{latestTrendPoint ? latestTrendPoint.transaction_count.toLocaleString() : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{latestTrendPoint?.date || 'Trend feed unavailable'}</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">Recent Success Rate</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{activitySnapshot ? `${Math.round(activitySnapshot.success_rate * 100)}%` : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{activitySnapshot ? `${activitySnapshot.sample_size} sampled transactions` : 'Recent activity snapshot unavailable'}
|
||||
</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 Recent 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 sample.</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">Peak Charted Day</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{peakTrendPoint ? peakTrendPoint.transaction_count.toLocaleString() : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{peakTrendPoint?.date || 'No trend data yet'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Link href="/analytics" className="text-primary-600 hover:underline">
|
||||
Open full analytics →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Explorer Shortcuts">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Go directly to the explorer surfaces that provide the strongest operational and discovery context.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/search" className="text-primary-600 hover:underline">
|
||||
Search →
|
||||
</Link>
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
Transactions →
|
||||
</Link>
|
||||
<Link href="/tokens" className="text-primary-600 hover:underline">
|
||||
Tokens →
|
||||
</Link>
|
||||
<Link href="/addresses" className="text-primary-600 hover:underline">
|
||||
Addresses →
|
||||
</Link>
|
||||
<Link href="/analytics" className="text-primary-600 hover:underline">
|
||||
Analytics →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Liquidity & Routes">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Explore the public Chain 138 DODO PMM liquidity mesh, the canonical route matrix, and the
|
||||
partner payload endpoints exposed through the explorer.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/routes" className="text-primary-600 hover:underline">
|
||||
Open routes and liquidity →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Wallet & Token Discovery">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Add Chain 138, Ethereum Mainnet, and ALL Mainnet to MetaMask, then use the explorer token
|
||||
list URL so supported tokens appear automatically.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/wallet" className="text-primary-600 hover:underline">
|
||||
Open wallet tools →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Bridge & Relay Monitoring">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Open the public bridge monitoring surface for relay status, mission-control links, bridge trace tooling,
|
||||
and the visual command center entry points.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/bridge" className="text-primary-600 hover:underline">
|
||||
Open bridge monitoring →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Operations Hub">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Open the public operations surface for wrapped-asset references, analytics shortcuts, operator links,
|
||||
system topology views, and other Chain 138 support tools.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/operations" className="text-primary-600 hover:underline">
|
||||
Open operations hub →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||||
|
||||
type WalletChain = {
|
||||
export type WalletChain = {
|
||||
chainId: string
|
||||
chainIdDecimal?: number
|
||||
chainName: string
|
||||
@@ -20,7 +20,7 @@ type WalletChain = {
|
||||
explorerApiUrl?: string
|
||||
}
|
||||
|
||||
type TokenListToken = {
|
||||
export type TokenListToken = {
|
||||
chainId: number
|
||||
address: string
|
||||
name: string
|
||||
@@ -31,7 +31,7 @@ type TokenListToken = {
|
||||
extensions?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type NetworksCatalog = {
|
||||
export type NetworksCatalog = {
|
||||
name?: string
|
||||
version?: {
|
||||
major?: number
|
||||
@@ -42,7 +42,7 @@ type NetworksCatalog = {
|
||||
chains?: WalletChain[]
|
||||
}
|
||||
|
||||
type TokenListCatalog = {
|
||||
export type TokenListCatalog = {
|
||||
name?: string
|
||||
version?: {
|
||||
major?: number
|
||||
@@ -53,7 +53,7 @@ type TokenListCatalog = {
|
||||
tokens?: TokenListToken[]
|
||||
}
|
||||
|
||||
type CapabilitiesCatalog = {
|
||||
export type CapabilitiesCatalog = {
|
||||
name?: string
|
||||
version?: {
|
||||
major?: number
|
||||
@@ -84,11 +84,20 @@ type CapabilitiesCatalog = {
|
||||
}
|
||||
}
|
||||
|
||||
type FetchMetadata = {
|
||||
export type FetchMetadata = {
|
||||
source?: string | null
|
||||
lastModified?: string | null
|
||||
}
|
||||
|
||||
interface AddToMetaMaskProps {
|
||||
initialNetworks?: NetworksCatalog | null
|
||||
initialTokenList?: TokenListCatalog | null
|
||||
initialCapabilities?: CapabilitiesCatalog | null
|
||||
initialNetworksMeta?: FetchMetadata | null
|
||||
initialTokenListMeta?: FetchMetadata | null
|
||||
initialCapabilitiesMeta?: FetchMetadata | null
|
||||
}
|
||||
|
||||
type EthereumProvider = {
|
||||
request: (args: { method: string; params?: unknown }) => Promise<unknown>
|
||||
}
|
||||
@@ -99,7 +108,7 @@ const FALLBACK_CHAIN_138: WalletChain = {
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'],
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org'],
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'],
|
||||
shortName: 'dbis',
|
||||
infoURL: 'https://explorer.d-bis.org',
|
||||
@@ -139,7 +148,7 @@ const FALLBACK_CAPABILITIES_138: CapabilitiesCatalog = {
|
||||
name: 'Chain 138 RPC Capabilities',
|
||||
version: { major: 1, minor: 1, patch: 0 },
|
||||
timestamp: '2026-03-28T00:00:00Z',
|
||||
generatedBy: 'SolaceScanScout',
|
||||
generatedBy: 'SolaceScan',
|
||||
chainId: 138,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
rpcUrl: 'https://rpc-http-pub.d-bis.org',
|
||||
@@ -211,19 +220,39 @@ function isCapabilitiesCatalog(value: unknown): value is CapabilitiesCatalog {
|
||||
|
||||
function getApiBase() {
|
||||
return resolveExplorerApiBase({
|
||||
serverFallback: 'https://explorer.d-bis.org',
|
||||
serverFallback: 'https://blockscout.defi-oracle.io',
|
||||
})
|
||||
}
|
||||
|
||||
export function AddToMetaMask() {
|
||||
export function AddToMetaMask({
|
||||
initialNetworks = null,
|
||||
initialTokenList = null,
|
||||
initialCapabilities = null,
|
||||
initialNetworksMeta = null,
|
||||
initialTokenListMeta = null,
|
||||
initialCapabilitiesMeta = null,
|
||||
}: AddToMetaMaskProps) {
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [networks, setNetworks] = useState<NetworksCatalog | null>(null)
|
||||
const [tokenList, setTokenList] = useState<TokenListCatalog | null>(null)
|
||||
const [capabilities, setCapabilities] = useState<CapabilitiesCatalog | null>(null)
|
||||
const [networksMeta, setNetworksMeta] = useState<FetchMetadata | null>(null)
|
||||
const [tokenListMeta, setTokenListMeta] = useState<FetchMetadata | null>(null)
|
||||
const [capabilitiesMeta, setCapabilitiesMeta] = useState<FetchMetadata | null>(null)
|
||||
const [networks, setNetworks] = useState<NetworksCatalog | null>(initialNetworks)
|
||||
const [tokenList, setTokenList] = useState<TokenListCatalog | null>(initialTokenList)
|
||||
const [capabilities, setCapabilities] = useState<CapabilitiesCatalog | null>(
|
||||
initialCapabilities || FALLBACK_CAPABILITIES_138,
|
||||
)
|
||||
const [networksMeta, setNetworksMeta] = useState<FetchMetadata | null>(initialNetworksMeta)
|
||||
const [tokenListMeta, setTokenListMeta] = useState<FetchMetadata | null>(initialTokenListMeta)
|
||||
const [capabilitiesMeta, setCapabilitiesMeta] = useState<FetchMetadata | null>(
|
||||
initialCapabilitiesMeta ||
|
||||
(initialCapabilities
|
||||
? {
|
||||
source: 'explorer-api',
|
||||
lastModified: initialCapabilities.timestamp || null,
|
||||
}
|
||||
: {
|
||||
source: 'frontend-fallback',
|
||||
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
|
||||
}),
|
||||
)
|
||||
|
||||
const ethereum = typeof window !== 'undefined'
|
||||
? (window as unknown as { ethereum?: EthereumProvider }).ethereum
|
||||
@@ -251,7 +280,7 @@ export function AddToMetaMask() {
|
||||
})
|
||||
const json = response.ok ? await response.json() : null
|
||||
const meta: FetchMetadata = {
|
||||
source: response.headers.get('X-Config-Source'),
|
||||
source: response.headers.get('X-Config-Source') || 'explorer-api',
|
||||
lastModified: response.headers.get('Last-Modified'),
|
||||
}
|
||||
return { json, meta }
|
||||
@@ -296,15 +325,17 @@ export function AddToMetaMask() {
|
||||
setCapabilitiesMeta(resolvedCapabilities.meta)
|
||||
} catch {
|
||||
if (!active) return
|
||||
setNetworks(null)
|
||||
setTokenList(null)
|
||||
setCapabilities(FALLBACK_CAPABILITIES_138)
|
||||
setNetworksMeta(null)
|
||||
setTokenListMeta(null)
|
||||
setCapabilitiesMeta({
|
||||
source: 'frontend-fallback',
|
||||
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
|
||||
})
|
||||
setNetworks((current) => current)
|
||||
setTokenList((current) => current)
|
||||
setCapabilities((current) => current || FALLBACK_CAPABILITIES_138)
|
||||
setNetworksMeta((current) => current)
|
||||
setTokenListMeta((current) => current)
|
||||
setCapabilitiesMeta((current) =>
|
||||
current || {
|
||||
source: 'frontend-fallback',
|
||||
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
if (active) {
|
||||
timer = setTimeout(() => {
|
||||
|
||||
36
frontend/src/components/wallet/WalletPage.tsx
Normal file
36
frontend/src/components/wallet/WalletPage.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
CapabilitiesCatalog,
|
||||
FetchMetadata,
|
||||
NetworksCatalog,
|
||||
TokenListCatalog,
|
||||
} from '@/components/wallet/AddToMetaMask'
|
||||
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface WalletPageProps {
|
||||
initialNetworks?: NetworksCatalog | null
|
||||
initialTokenList?: TokenListCatalog | null
|
||||
initialCapabilities?: CapabilitiesCatalog | null
|
||||
initialNetworksMeta?: FetchMetadata | null
|
||||
initialTokenListMeta?: FetchMetadata | null
|
||||
initialCapabilitiesMeta?: FetchMetadata | null
|
||||
}
|
||||
|
||||
export default function WalletPage(props: WalletPageProps) {
|
||||
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 & MetaMask</h1>
|
||||
<p className="mb-6 text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base">
|
||||
Connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets. Use the token list URL so tokens and oracles are discoverable.
|
||||
</p>
|
||||
<AddToMetaMask {...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">
|
||||
Need swap and liquidity discovery too? Visit the{' '}
|
||||
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
|
||||
Liquidity Access
|
||||
</Link>{' '}
|
||||
page for live Chain 138 pools, route matrix links, partner payload templates, and the internal fallback execution plan endpoints.
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user