- Introduced a new Diagnostics struct to capture transaction visibility state and activity state. - Updated BuildSnapshot function to return diagnostics alongside snapshot, completeness, and sampling. - Enhanced test cases to validate the new diagnostics data. - Updated frontend components to utilize the new diagnostics information for improved user feedback on freshness context. This change improves the observability of transaction activity and enhances the user experience by providing clearer insights into the freshness of data.
426 lines
16 KiB
TypeScript
426 lines
16 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 { statsApi, type ExplorerStats } from '@/services/api/stats'
|
|
import { explorerFeaturePages } from '@/data/explorerOperations'
|
|
import { summarizeChainActivity } from '@/utils/activityContext'
|
|
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
|
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
|
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
|
|
|
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
|
|
issueScope: string | null
|
|
issueMessage: string | null
|
|
inventoryShortfallWei: string | null
|
|
inventoryRequiredWei: string | null
|
|
inventoryAvailableWei: string | null
|
|
}
|
|
|
|
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 relayPolicyCue(snapshot: MissionControlRelaySnapshot | null): string | null {
|
|
if (!snapshot) return null
|
|
if (snapshot.last_error?.scope === 'bridge_inventory') {
|
|
return 'Queued release waiting on bridge inventory'
|
|
}
|
|
if (snapshot.last_error?.scope === 'bridge_inventory_probe') {
|
|
return 'Bridge inventory check is temporarily unavailable'
|
|
}
|
|
if (String(snapshot.status || '').toLowerCase() === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
|
|
return 'Delivery disabled by policy'
|
|
}
|
|
return 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 formatWeiToToken(value?: string | null): string {
|
|
if (!value) return 'Unknown'
|
|
try {
|
|
const raw = BigInt(value)
|
|
const whole = raw / 10n ** 18n
|
|
const fractional = (raw % 10n ** 18n).toString().padStart(18, '0').slice(0, 6).replace(/0+$/, '')
|
|
return fractional ? `${whole.toString()}.${fractional}` : whole.toString()
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
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({
|
|
initialBridgeStatus = null,
|
|
initialStats = null,
|
|
}: {
|
|
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
|
initialStats?: ExplorerStats | null
|
|
}) {
|
|
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
|
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
|
const [feedState, setFeedState] = useState<FeedState>(initialBridgeStatus ? 'fallback' : 'connecting')
|
|
const page = explorerFeaturePages.bridge
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
const loadSnapshot = async () => {
|
|
try {
|
|
const [snapshot, latestStats] = await Promise.all([
|
|
missionControlApi.getBridgeStatus(),
|
|
statsApi.get().catch(() => null),
|
|
])
|
|
if (!cancelled) {
|
|
setBridgeStatus(snapshot)
|
|
if (latestStats) {
|
|
setStats(latestStats)
|
|
}
|
|
}
|
|
} 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 activityContext = useMemo(
|
|
() =>
|
|
summarizeChainActivity({
|
|
blocks: [],
|
|
transactions: [],
|
|
latestBlockNumber: stats?.latest_block,
|
|
latestBlockTimestamp: null,
|
|
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
|
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
|
|
}),
|
|
[bridgeStatus, stats],
|
|
)
|
|
|
|
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:
|
|
snapshot?.last_error?.scope === 'bridge_inventory'
|
|
? 'underfunded'
|
|
: snapshot?.last_error?.scope === 'bridge_inventory_probe'
|
|
? 'warning'
|
|
: 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 ||
|
|
'',
|
|
issueScope: snapshot?.last_error?.scope || null,
|
|
issueMessage: snapshot?.last_error?.message || null,
|
|
inventoryShortfallWei: snapshot?.last_error?.shortfall || null,
|
|
inventoryRequiredWei: snapshot?.last_error?.required_amount || null,
|
|
inventoryAvailableWei: snapshot?.last_error?.available_amount || null,
|
|
}
|
|
})
|
|
.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">
|
|
<ActivityContextPanel context={activityContext} title="Bridge Freshness Context" />
|
|
<FreshnessTrustNote
|
|
className="mt-3"
|
|
context={activityContext}
|
|
stats={stats}
|
|
bridgeStatus={bridgeStatus}
|
|
scopeLabel="Bridge relay posture is shown alongside the same explorer freshness model used on the homepage and core explorer routes"
|
|
/>
|
|
</div>
|
|
|
|
<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>
|
|
{relayPolicyCue(resolveSnapshot((getMissionControlRelays(bridgeStatus) || {})[lane.key])) ? (
|
|
<div className="mt-3 text-xs font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
|
|
{relayPolicyCue(resolveSnapshot((getMissionControlRelays(bridgeStatus) || {})[lane.key]))}
|
|
</div>
|
|
) : null}
|
|
{lane.issueScope === 'bridge_inventory' ? (
|
|
<div className="mt-3 rounded-2xl border border-amber-200 bg-amber-50/80 p-3 text-sm text-amber-950 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
|
|
<div className="font-semibold">Bridge inventory below required release amount</div>
|
|
<div className="mt-1">
|
|
Shortfall: {formatWeiToToken(lane.inventoryShortfallWei)} WETH
|
|
</div>
|
|
<div className="mt-1 text-xs opacity-80">
|
|
Required: {formatWeiToToken(lane.inventoryRequiredWei)} WETH · Available: {formatWeiToToken(lane.inventoryAvailableWei)} WETH
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</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>
|
|
)
|
|
}
|