- Backend REST/gateway/track routes, analytics, Blockscout proxy paths. - Frontend wallet and liquidity surfaces; MetaMask token list alignment. - Deployment docs, verification scripts, address inventory updates. Check: go build ./... under backend/ (pass). Made-with: Cursor
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { Card } from '@/libs/frontend-ui-primitives'
|
|
import {
|
|
getMissionControlRelayLabel,
|
|
getMissionControlRelays,
|
|
missionControlApi,
|
|
type MissionControlBridgeStatusResponse,
|
|
type MissionControlRelayPayload,
|
|
type MissionControlRelaySnapshot,
|
|
} from '@/services/api/missionControl'
|
|
import { explorerFeaturePages } from '@/data/explorerOperations'
|
|
|
|
type FeedState = 'connecting' | 'live' | 'fallback'
|
|
|
|
interface RelayLaneCard {
|
|
key: string
|
|
label: string
|
|
status: string
|
|
profile: string
|
|
sourceChain: string
|
|
destinationChain: string
|
|
queueSize: number
|
|
processed: number
|
|
failed: number
|
|
lastPolled: string
|
|
bridgeAddress: string
|
|
}
|
|
|
|
const relayOrder = ['mainnet_cw', 'mainnet_weth', 'bsc', 'avax', 'avax_cw', 'avax_to_138']
|
|
|
|
function relativeAge(isoString?: string): string {
|
|
if (!isoString) return 'Unknown'
|
|
const parsed = Date.parse(isoString)
|
|
if (!Number.isFinite(parsed)) return 'Unknown'
|
|
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
|
|
if (seconds < 60) return `${seconds}s ago`
|
|
const minutes = Math.round(seconds / 60)
|
|
if (minutes < 60) return `${minutes}m ago`
|
|
const hours = Math.round(minutes / 60)
|
|
return `${hours}h ago`
|
|
}
|
|
|
|
function shortAddress(value?: string): string {
|
|
if (!value) return 'Unspecified'
|
|
if (value.length <= 14) return value
|
|
return `${value.slice(0, 6)}...${value.slice(-4)}`
|
|
}
|
|
|
|
function resolveSnapshot(relay?: MissionControlRelayPayload): MissionControlRelaySnapshot | null {
|
|
return relay?.url_probe?.body || relay?.file_snapshot || null
|
|
}
|
|
|
|
function laneToneClasses(status: string): string {
|
|
const normalized = status.toLowerCase()
|
|
if (['degraded', 'stale', 'stopped', 'down', 'snapshot-error'].includes(normalized)) {
|
|
return 'border-red-200 bg-red-50/80 dark:border-red-900/60 dark:bg-red-950/20'
|
|
}
|
|
if (['paused', 'starting'].includes(normalized)) {
|
|
return 'border-amber-200 bg-amber-50/80 dark:border-amber-900/60 dark:bg-amber-950/20'
|
|
}
|
|
return 'border-emerald-200 bg-emerald-50/80 dark:border-emerald-900/60 dark:bg-emerald-950/20'
|
|
}
|
|
|
|
function statusPillClasses(status: string): string {
|
|
const normalized = status.toLowerCase()
|
|
if (['degraded', 'stale', 'stopped', 'down', 'snapshot-error'].includes(normalized)) {
|
|
return 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-100'
|
|
}
|
|
if (['paused', 'starting'].includes(normalized)) {
|
|
return 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-100'
|
|
}
|
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-100'
|
|
}
|
|
|
|
function ActionLink({
|
|
href,
|
|
label,
|
|
external,
|
|
}: {
|
|
href: string
|
|
label: string
|
|
external?: boolean
|
|
}) {
|
|
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
|
|
const text = `${label} ->`
|
|
|
|
if (external) {
|
|
return (
|
|
<a href={href} className={className} target="_blank" rel="noopener noreferrer">
|
|
{text}
|
|
</a>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Link href={href} className={className}>
|
|
{text}
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
export default function BridgeMonitoringPage() {
|
|
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
|
|
const [feedState, setFeedState] = useState<FeedState>('connecting')
|
|
const page = explorerFeaturePages.bridge
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
const loadSnapshot = async () => {
|
|
try {
|
|
const snapshot = await missionControlApi.getBridgeStatus()
|
|
if (!cancelled) {
|
|
setBridgeStatus(snapshot)
|
|
}
|
|
} catch (error) {
|
|
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
|
console.warn('Failed to load bridge monitoring snapshot:', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
loadSnapshot()
|
|
|
|
const unsubscribe = missionControlApi.subscribeBridgeStatus(
|
|
(status) => {
|
|
if (!cancelled) {
|
|
setBridgeStatus(status)
|
|
setFeedState('live')
|
|
}
|
|
},
|
|
(error) => {
|
|
if (!cancelled) {
|
|
setFeedState('fallback')
|
|
}
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
console.warn('Bridge monitoring live stream issue:', error)
|
|
}
|
|
}
|
|
)
|
|
|
|
return () => {
|
|
cancelled = true
|
|
unsubscribe()
|
|
}
|
|
}, [])
|
|
|
|
const relayLanes = useMemo((): RelayLaneCard[] => {
|
|
const relays = getMissionControlRelays(bridgeStatus)
|
|
if (!relays) return []
|
|
|
|
const orderIndex = new Map(relayOrder.map((key, index) => [key, index]))
|
|
|
|
return Object.entries(relays)
|
|
.map(([key, relay]) => {
|
|
const snapshot = resolveSnapshot(relay)
|
|
const status = String(snapshot?.status || (relay.file_snapshot_error ? 'snapshot-error' : 'configured')).toLowerCase()
|
|
return {
|
|
key,
|
|
label: getMissionControlRelayLabel(key),
|
|
status,
|
|
profile: snapshot?.service?.profile || key,
|
|
sourceChain: snapshot?.source?.chain_name || 'Unknown',
|
|
destinationChain: snapshot?.destination?.chain_name || 'Unknown',
|
|
queueSize: snapshot?.queue?.size ?? 0,
|
|
processed: snapshot?.queue?.processed ?? 0,
|
|
failed: snapshot?.queue?.failed ?? 0,
|
|
lastPolled: relativeAge(snapshot?.last_source_poll?.at),
|
|
bridgeAddress:
|
|
snapshot?.destination?.relay_bridge_default ||
|
|
snapshot?.destination?.relay_bridge ||
|
|
snapshot?.source?.bridge_filter ||
|
|
'',
|
|
}
|
|
})
|
|
.sort((left, right) => {
|
|
const leftIndex = orderIndex.get(left.key) ?? Number.MAX_SAFE_INTEGER
|
|
const rightIndex = orderIndex.get(right.key) ?? Number.MAX_SAFE_INTEGER
|
|
return leftIndex - rightIndex || left.label.localeCompare(right.label)
|
|
})
|
|
}, [bridgeStatus])
|
|
|
|
const chainStatus = bridgeStatus?.data?.chains?.['138']
|
|
const overallStatus = bridgeStatus?.data?.status || 'unknown'
|
|
const checkedAt = relativeAge(bridgeStatus?.data?.checked_at)
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-6 sm:py-8">
|
|
<div className="mb-6 max-w-4xl sm:mb-8">
|
|
<div className="mb-3 inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">
|
|
{page.eyebrow}
|
|
</div>
|
|
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
|
{page.title}
|
|
</h1>
|
|
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
|
{page.description}
|
|
</p>
|
|
</div>
|
|
|
|
{page.note ? (
|
|
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
|
|
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
|
|
{page.note}
|
|
</p>
|
|
</Card>
|
|
) : null}
|
|
|
|
<div className="mb-6 grid gap-4 lg:grid-cols-3">
|
|
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
|
|
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
|
|
Relay Fleet
|
|
</div>
|
|
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
|
|
{overallStatus}
|
|
</div>
|
|
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
|
{relayLanes.length} managed lanes visible
|
|
</div>
|
|
<div className="mt-2 text-xs font-medium uppercase tracking-wide text-sky-800/80 dark:text-sky-100/80">
|
|
Feed: {feedState === 'live' ? 'Live SSE' : feedState === 'fallback' ? 'Snapshot fallback' : 'Connecting'}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="border border-gray-200 dark:border-gray-700">
|
|
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
|
Chain 138 RPC
|
|
</div>
|
|
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
|
|
{chainStatus?.status || 'unknown'}
|
|
</div>
|
|
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
Head age: {chainStatus?.head_age_sec != null ? `${chainStatus.head_age_sec.toFixed(1)}s` : 'Unknown'}
|
|
</div>
|
|
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
|
Latency: {chainStatus?.latency_ms != null ? `${chainStatus.latency_ms}ms` : 'Unknown'}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="border border-gray-200 dark:border-gray-700">
|
|
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
|
|
Last Check
|
|
</div>
|
|
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
|
|
{checkedAt}
|
|
</div>
|
|
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
Public status JSON and live stream are both active.
|
|
</div>
|
|
<div className="mt-4">
|
|
<ActionLink href="/explorer-api/v1/track1/bridge/status" label="Open status JSON" external />
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="mb-8 grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
|
{relayLanes.map((lane) => (
|
|
<Card key={lane.key} className={`border ${laneToneClasses(lane.status)}`}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{lane.label}
|
|
</div>
|
|
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
|
{`${lane.sourceChain} -> ${lane.destinationChain}`}
|
|
</div>
|
|
</div>
|
|
<div className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide ${statusPillClasses(lane.status)}`}>
|
|
{lane.status}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<div className="text-gray-500 dark:text-gray-400">Profile</div>
|
|
<div className="font-medium text-gray-900 dark:text-white">{lane.profile}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-gray-500 dark:text-gray-400">Queue</div>
|
|
<div className="font-medium text-gray-900 dark:text-white">{lane.queueSize}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-gray-500 dark:text-gray-400">Processed</div>
|
|
<div className="font-medium text-gray-900 dark:text-white">{lane.processed}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-gray-500 dark:text-gray-400">Failed</div>
|
|
<div className="font-medium text-gray-900 dark:text-white">{lane.failed}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
|
Last polled: {lane.lastPolled}
|
|
</div>
|
|
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
|
Bridge: {shortAddress(lane.bridgeAddress)}
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
{page.actions.map((action) => (
|
|
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
|
<div className="flex h-full flex-col">
|
|
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
|
{action.title}
|
|
</div>
|
|
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
|
{action.description}
|
|
</p>
|
|
<div className="mt-4">
|
|
<ActionLink
|
|
href={action.href}
|
|
label={action.label}
|
|
external={'external' in action ? action.external : undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|