Freshness diagnostics API, UI trust notes, mission control/stats updates, and deploy scripts.
Made-with: Cursor
This commit is contained in:
137
frontend/src/components/common/ActivityContextPanel.tsx
Normal file
137
frontend/src/components/common/ActivityContextPanel.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import type { ChainActivityContext } from '@/utils/activityContext'
|
||||
import { formatRelativeAge, formatTimestamp } from '@/utils/format'
|
||||
import { Explain, useUiMode } from './UiModeContext'
|
||||
|
||||
function resolveTone(state: ChainActivityContext['state']): 'success' | 'warning' | 'neutral' {
|
||||
switch (state) {
|
||||
case 'active':
|
||||
return 'success'
|
||||
case 'low':
|
||||
case 'inactive':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLabel(state: ChainActivityContext['state']): string {
|
||||
switch (state) {
|
||||
case 'active':
|
||||
return 'active'
|
||||
case 'low':
|
||||
return 'low activity'
|
||||
case 'inactive':
|
||||
return 'inactive'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function renderHeadline(context: ChainActivityContext): string {
|
||||
if (context.transaction_visibility_unavailable) {
|
||||
return 'Transaction index freshness is currently unavailable, while chain-head visibility remains live.'
|
||||
}
|
||||
if (context.state === 'unknown') {
|
||||
return 'Recent activity context is temporarily unavailable.'
|
||||
}
|
||||
if (context.state === 'active') {
|
||||
return 'Recent transactions are close to the visible chain tip.'
|
||||
}
|
||||
if (context.head_is_idle) {
|
||||
return 'The chain head is advancing, but the latest visible transaction is older than the current tip.'
|
||||
}
|
||||
return 'Recent transaction activity is sparse right now.'
|
||||
}
|
||||
|
||||
export default function ActivityContextPanel({
|
||||
context,
|
||||
title = 'Chain Activity Context',
|
||||
}: {
|
||||
context: ChainActivityContext
|
||||
title?: string
|
||||
}) {
|
||||
const { mode } = useUiMode()
|
||||
const tone = resolveTone(context.state)
|
||||
const dualTimelineLabel =
|
||||
context.latest_block_timestamp && context.latest_transaction_timestamp
|
||||
? `${formatRelativeAge(context.latest_block_timestamp)} head · ${formatRelativeAge(context.latest_transaction_timestamp)} latest tx`
|
||||
: 'Dual timeline unavailable'
|
||||
|
||||
return (
|
||||
<Card className="border border-sky-200 bg-sky-50/60 dark:border-sky-900/40 dark:bg-sky-950/20" title={title}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{renderHeadline(context)}</div>
|
||||
<Explain>
|
||||
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Use the transaction tip and last non-empty block below to distinguish a quiet chain from a broken explorer.
|
||||
</p>
|
||||
</Explain>
|
||||
</div>
|
||||
<EntityBadge label={resolveLabel(context.state)} tone={tone} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Latest Block</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatRelativeAge(context.latest_block_timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Latest Transaction</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatRelativeAge(context.latest_transaction_timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Last Non-Empty Block</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number}` : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatRelativeAge(context.last_non_empty_block_timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Block Gap</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{context.block_gap_to_latest_transaction != null ? context.block_gap_to_latest_transaction.toLocaleString() : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{mode === 'guided'
|
||||
? 'Difference between the current tip and the latest visible transaction block.'
|
||||
: dualTimelineLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{context.latest_transaction_block_number != null ? (
|
||||
<Link href={`/blocks/${context.latest_transaction_block_number}`} className="text-primary-600 hover:underline">
|
||||
Open latest transaction block →
|
||||
</Link>
|
||||
) : null}
|
||||
{context.last_non_empty_block_number != null ? (
|
||||
<Link href={`/blocks/${context.last_non_empty_block_number}`} className="text-primary-600 hover:underline">
|
||||
Open last non-empty block →
|
||||
</Link>
|
||||
) : null}
|
||||
{context.latest_transaction_timestamp ? (
|
||||
<span>Latest visible transaction time: {formatTimestamp(context.latest_transaction_timestamp)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
27
frontend/src/components/common/BrandLockup.tsx
Normal file
27
frontend/src/components/common/BrandLockup.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import BrandMark from './BrandMark'
|
||||
|
||||
export default function BrandLockup({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<BrandMark size={compact ? 'compact' : 'default'} />
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className={[
|
||||
'block truncate font-semibold tracking-[-0.02em] text-gray-950 dark:text-white',
|
||||
compact ? 'text-[1.45rem]' : 'text-[1.65rem]',
|
||||
].join(' ')}
|
||||
>
|
||||
SolaceScan
|
||||
</span>
|
||||
<span
|
||||
className={[
|
||||
'block truncate font-medium uppercase text-gray-500 dark:text-gray-400',
|
||||
compact ? 'text-[0.72rem] tracking-[0.14em]' : 'text-[0.8rem] tracking-[0.12em]',
|
||||
].join(' ')}
|
||||
>
|
||||
Chain 138 Explorer by DBIS
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
45
frontend/src/components/common/BrandMark.tsx
Normal file
45
frontend/src/components/common/BrandMark.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
export default function BrandMark({ size = 'default' }: { size?: 'default' | 'compact' }) {
|
||||
const containerClassName =
|
||||
size === 'compact'
|
||||
? 'h-10 w-10 rounded-xl'
|
||||
: 'h-11 w-11 rounded-2xl'
|
||||
const iconClassName = size === 'compact' ? 'h-6 w-6' : 'h-7 w-7'
|
||||
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
'relative inline-flex shrink-0 items-center justify-center border border-primary-200/70 bg-white text-primary-600 shadow-[0_10px_30px_rgba(37,99,235,0.10)] transition-transform group-hover:-translate-y-0.5 dark:border-primary-500/20 dark:bg-gray-900 dark:text-primary-400',
|
||||
containerClassName,
|
||||
].join(' ')}
|
||||
>
|
||||
<svg className={iconClassName} viewBox="0 0 32 32" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M16 4.75 7.5 9.2v9.55L16 23.2l8.5-4.45V9.2L16 4.75Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
<path
|
||||
d="m7.75 9.45 8.25 4.3 8.25-4.3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path d="M16 13.9v9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path
|
||||
d="M22.75 6.8c2.35 1.55 3.9 4.2 3.9 7.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
opacity=".9"
|
||||
/>
|
||||
<path
|
||||
d="M9.35 6.8c-2.3 1.55-3.85 4.2-3.85 7.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
opacity=".65"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -2,22 +2,25 @@ import type { ReactNode } from 'react'
|
||||
import Navbar from './Navbar'
|
||||
import Footer from './Footer'
|
||||
import ExplorerAgentTool from './ExplorerAgentTool'
|
||||
import { UiModeProvider } from './UiModeContext'
|
||||
|
||||
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 id="main-content" className="flex-1">
|
||||
{children}
|
||||
<UiModeProvider>
|
||||
<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 id="main-content" className="flex-1">
|
||||
{children}
|
||||
</div>
|
||||
<ExplorerAgentTool />
|
||||
<Footer />
|
||||
</div>
|
||||
<ExplorerAgentTool />
|
||||
<Footer />
|
||||
</div>
|
||||
</UiModeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
85
frontend/src/components/common/FreshnessTrustNote.tsx
Normal file
85
frontend/src/components/common/FreshnessTrustNote.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
import type { ChainActivityContext } from '@/utils/activityContext'
|
||||
import {
|
||||
resolveFreshnessSourceLabel,
|
||||
summarizeFreshnessConfidence,
|
||||
} from '@/utils/explorerFreshness'
|
||||
import { formatRelativeAge } from '@/utils/format'
|
||||
|
||||
function buildSummary(context: ChainActivityContext) {
|
||||
if (context.transaction_visibility_unavailable) {
|
||||
return 'Chain-head visibility is current, while transaction freshness is currently unavailable.'
|
||||
}
|
||||
|
||||
if (context.state === 'active') {
|
||||
return 'Chain head and latest indexed transactions are closely aligned.'
|
||||
}
|
||||
|
||||
if (context.head_is_idle) {
|
||||
return 'Chain head is current, while latest visible transactions trail the tip.'
|
||||
}
|
||||
|
||||
if (context.state === 'low' || context.state === 'inactive') {
|
||||
return 'Chain head is current, and recent visible transaction activity is sparse.'
|
||||
}
|
||||
|
||||
return 'Freshness context is based on the latest visible public explorer evidence.'
|
||||
}
|
||||
|
||||
function buildDetail(context: ChainActivityContext) {
|
||||
if (context.transaction_visibility_unavailable) {
|
||||
return 'Use chain-head visibility and the last non-empty block as the current trust anchors.'
|
||||
}
|
||||
|
||||
const latestTxAge = formatRelativeAge(context.latest_transaction_timestamp)
|
||||
const latestNonEmptyBlock =
|
||||
context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown'
|
||||
|
||||
if (context.head_is_idle) {
|
||||
return `Latest visible transaction: ${latestTxAge}. Last non-empty block: ${latestNonEmptyBlock}.`
|
||||
}
|
||||
|
||||
if (context.state === 'active') {
|
||||
return `Latest visible transaction: ${latestTxAge}. Recent indexed activity remains close to the tip.`
|
||||
}
|
||||
|
||||
return `Latest visible transaction: ${latestTxAge}. Recent head blocks may be quiet even while the chain remains current.`
|
||||
}
|
||||
|
||||
export default function FreshnessTrustNote({
|
||||
context,
|
||||
stats,
|
||||
bridgeStatus,
|
||||
scopeLabel,
|
||||
className = '',
|
||||
}: {
|
||||
context: ChainActivityContext
|
||||
stats?: ExplorerStats | null
|
||||
bridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||
scopeLabel?: string
|
||||
className?: string
|
||||
}) {
|
||||
const sourceLabel = resolveFreshnessSourceLabel(stats, bridgeStatus)
|
||||
const confidenceBadges = summarizeFreshnessConfidence(stats, bridgeStatus)
|
||||
const normalizedClassName = className ? ` ${className}` : ''
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border border-gray-200 bg-white/80 px-4 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/40${normalizedClassName}`}>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context)}</div>
|
||||
<div className="mt-1 text-gray-600 dark:text-gray-400">
|
||||
{buildDetail(context)} {scopeLabel ? `${scopeLabel}. ` : ''}{sourceLabel}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{confidenceBadges.map((badge) => (
|
||||
<span
|
||||
key={badge}
|
||||
className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 dark:border-gray-700 dark:bg-gray-900/70"
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
202
frontend/src/components/common/HeaderCommandPalette.tsx
Normal file
202
frontend/src/components/common/HeaderCommandPalette.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Explain, useUiMode } from './UiModeContext'
|
||||
|
||||
export type HeaderCommandItem = {
|
||||
href?: string
|
||||
label: string
|
||||
description?: string
|
||||
section: string
|
||||
keywords?: string[]
|
||||
onSelect?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
function SearchIcon({ className = 'h-4 w-4' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.9} d="m21 21-4.35-4.35" />
|
||||
<circle cx="11" cy="11" r="6.5" strokeWidth={1.9} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function matchItem(item: HeaderCommandItem, query: string) {
|
||||
const haystack = `${item.label} ${item.description || ''} ${item.section} ${(item.keywords || []).join(' ')}`.toLowerCase()
|
||||
return haystack.includes(query.toLowerCase())
|
||||
}
|
||||
|
||||
export default function HeaderCommandPalette({
|
||||
open,
|
||||
onClose,
|
||||
items,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
items: HeaderCommandItem[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { mode } = useUiMode()
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const itemRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const matches = query.trim()
|
||||
? items.filter((item) => matchItem(item, query))
|
||||
: items
|
||||
|
||||
return [
|
||||
{
|
||||
href: `/search${query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''}`,
|
||||
label: query.trim() ? `Search for “${query.trim()}”` : 'Open full explorer search',
|
||||
description: query.trim()
|
||||
? 'Jump to the full search surface with the current query.'
|
||||
: 'Open the full search page and browse the explorer index.',
|
||||
section: 'Search',
|
||||
keywords: ['query', 'find', 'lookup'],
|
||||
},
|
||||
...matches,
|
||||
]
|
||||
}, [items, query])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('')
|
||||
setActiveIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose, open])
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0)
|
||||
}, [query])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
itemRefs.current[activeIndex]?.scrollIntoView({ block: 'nearest' })
|
||||
}, [activeIndex, open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleSelect = async (item: HeaderCommandItem) => {
|
||||
onClose()
|
||||
if (item.onSelect) {
|
||||
await item.onSelect()
|
||||
return
|
||||
}
|
||||
if (item.href) {
|
||||
router.push(item.href)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-start justify-center bg-gray-950/45 px-4 py-20 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Explorer command palette"
|
||||
className="w-full max-w-2xl overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-[0_30px_100px_rgba(15,23,42,0.32)] dark:border-gray-700 dark:bg-gray-950"
|
||||
>
|
||||
<div className="border-b border-gray-200 px-5 py-4 dark:border-gray-800">
|
||||
<label htmlFor="header-command-search" className="sr-only">
|
||||
Search explorer destinations
|
||||
</label>
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900">
|
||||
<SearchIcon className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||
<input
|
||||
id="header-command-search"
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setActiveIndex((index) => Math.min(index + 1, filteredItems.length - 1))
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setActiveIndex((index) => Math.max(index - 1, 0))
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const activeItem = filteredItems[activeIndex]
|
||||
if (activeItem) void handleSelect(activeItem)
|
||||
}
|
||||
}}
|
||||
placeholder={mode === 'expert' ? 'Search tx / addr / block / tool' : 'Search pages, tools, tokens, and routes'}
|
||||
className="w-full border-0 bg-transparent text-sm text-gray-900 placeholder:text-gray-500 focus:outline-none dark:text-white dark:placeholder:text-gray-400"
|
||||
/>
|
||||
<kbd className="rounded-lg border border-gray-200 px-2 py-1 text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
Esc
|
||||
</kbd>
|
||||
</div>
|
||||
<Explain>
|
||||
<p className="mt-3 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
Search destinations and run high-frequency header actions from one keyboard-first surface.
|
||||
</p>
|
||||
</Explain>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto p-3">
|
||||
<div className="grid gap-1.5">
|
||||
{filteredItems.map((item, index) => (
|
||||
<button
|
||||
key={`${item.section}-${item.label}-${item.href || item.label}`}
|
||||
ref={(node) => {
|
||||
itemRefs.current[index] = node
|
||||
}}
|
||||
type="button"
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => void handleSelect(item)}
|
||||
className={[
|
||||
'flex w-full items-start gap-3 rounded-2xl px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
activeIndex === index
|
||||
? 'bg-primary-50 text-primary-900 dark:bg-primary-500/10 dark:text-primary-100'
|
||||
: 'bg-white text-gray-800 hover:bg-gray-100 dark:bg-gray-950 dark:text-gray-100 dark:hover:bg-gray-900',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="mt-0.5 inline-flex rounded-lg border border-gray-200 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
{item.section}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-semibold">{item.label}</span>
|
||||
{mode === 'guided' && item.description ? (
|
||||
<span className="mt-0.5 block text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{item.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 px-5 py-3 text-[11px] uppercase tracking-[0.16em] text-gray-500 dark:border-gray-800 dark:text-gray-400">
|
||||
{mode === 'expert' ? 'Keyboard-first ' : 'Use '}
|
||||
<kbd className="rounded border border-gray-200 px-1.5 py-0.5 font-medium dark:border-gray-700">/</kbd> or{' '}
|
||||
<kbd className="rounded border border-gray-200 px-1.5 py-0.5 font-medium dark:border-gray-700">Ctrl/Cmd + K</kbd>{' '}
|
||||
{mode === 'expert' ? 'to reopen.' : 'to reopen this palette.'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close command palette"
|
||||
className="fixed inset-0 -z-10 cursor-default"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
57
frontend/src/components/common/UiModeContext.tsx
Normal file
57
frontend/src/components/common/UiModeContext.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export type UiMode = 'guided' | 'expert'
|
||||
|
||||
const UI_MODE_STORAGE_KEY = 'explorer_ui_mode'
|
||||
|
||||
const UiModeContext = createContext<{
|
||||
mode: UiMode
|
||||
setMode: (mode: UiMode) => void
|
||||
toggleMode: () => void
|
||||
} | null>(null)
|
||||
|
||||
export function UiModeProvider({ children }: { children: ReactNode }) {
|
||||
const [mode, setModeState] = useState<UiMode>('guided')
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const stored = window.localStorage.getItem(UI_MODE_STORAGE_KEY)
|
||||
if (stored === 'guided' || stored === 'expert') {
|
||||
setModeState(stored)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setMode = (nextMode: UiMode) => {
|
||||
setModeState(nextMode)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(UI_MODE_STORAGE_KEY, nextMode)
|
||||
}
|
||||
}
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
mode,
|
||||
setMode,
|
||||
toggleMode: () => setMode(mode === 'guided' ? 'expert' : 'guided'),
|
||||
}),
|
||||
[mode],
|
||||
)
|
||||
|
||||
return <UiModeContext.Provider value={value}>{children}</UiModeContext.Provider>
|
||||
}
|
||||
|
||||
export function useUiMode() {
|
||||
const context = useContext(UiModeContext)
|
||||
if (!context) {
|
||||
throw new Error('useUiMode must be used within a UiModeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function Explain({ children }: { children: ReactNode }) {
|
||||
const { mode } = useUiMode()
|
||||
if (mode === 'expert') return null
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
} from '@/services/api/stats'
|
||||
import { transactionsApi, type Transaction } from '@/services/api/transactions'
|
||||
import { formatWeiAsEth } from '@/utils/format'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
|
||||
import OperationsPageShell, {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
@@ -121,6 +125,17 @@ export default function AnalyticsOperationsPage({
|
||||
() => trailingWindow.reduce((max, point) => Math.max(max, point.transaction_count), 0),
|
||||
[trailingWindow],
|
||||
)
|
||||
const activityContext = useMemo(
|
||||
() =>
|
||||
summarizeChainActivity({
|
||||
blocks,
|
||||
transactions,
|
||||
latestBlockNumber: stats?.latest_block ?? blocks[0]?.number ?? null,
|
||||
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
|
||||
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
||||
}),
|
||||
[blocks, bridgeStatus, stats, transactions],
|
||||
)
|
||||
|
||||
return (
|
||||
<OperationsPageShell page={page}>
|
||||
@@ -130,6 +145,17 @@ export default function AnalyticsOperationsPage({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Analytics Freshness Context" />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel="This page combines public stats, recent block samples, and indexed transactions."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Total Blocks"
|
||||
@@ -264,6 +290,11 @@ export default function AnalyticsOperationsPage({
|
||||
|
||||
<Card title="Recent Blocks">
|
||||
<div className="space-y-4">
|
||||
{shouldExplainEmptyHeadBlocks(blocks, activityContext) ? (
|
||||
<p className="rounded-xl border border-amber-200 bg-amber-50/70 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
|
||||
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
|
||||
</p>
|
||||
) : null}
|
||||
{blocks.map((block) => (
|
||||
<div
|
||||
key={block.hash}
|
||||
|
||||
@@ -51,6 +51,14 @@ function resolveSnapshot(relay?: MissionControlRelayPayload): MissionControlRela
|
||||
return relay?.url_probe?.body || relay?.file_snapshot || null
|
||||
}
|
||||
|
||||
function relayPolicyCue(snapshot: MissionControlRelaySnapshot | null): string | null {
|
||||
if (!snapshot) return null
|
||||
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)) {
|
||||
@@ -300,6 +308,11 @@ export default function BridgeMonitoringPage({
|
||||
<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}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,11 @@ import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config'
|
||||
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import { useUiMode } from '@/components/common/UiModeContext'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
|
||||
function relativeAge(isoString?: string): string {
|
||||
if (!isoString) return 'Unknown'
|
||||
@@ -60,6 +65,7 @@ export default function OperationsHubPage({
|
||||
initialTokenList = null,
|
||||
initialCapabilities = null,
|
||||
}: OperationsHubPageProps) {
|
||||
const { mode } = useUiMode()
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
|
||||
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(initialNetworksConfig)
|
||||
@@ -138,6 +144,19 @@ export default function OperationsHubPage({
|
||||
new Set((tokenList?.tokens || []).map((token) => token.symbol).filter(Boolean) as string[])
|
||||
).slice(0, 8)
|
||||
}, [tokenList])
|
||||
const activityContext = useMemo(
|
||||
() =>
|
||||
summarizeChainActivity({
|
||||
blocks: [],
|
||||
transactions: [],
|
||||
latestBlockNumber: bridgeStatus?.data?.chains?.['138']?.block_number
|
||||
? Number(bridgeStatus.data.chains['138'].block_number)
|
||||
: null,
|
||||
latestBlockTimestamp: null,
|
||||
freshness: resolveEffectiveFreshness(null, bridgeStatus),
|
||||
}),
|
||||
[bridgeStatus],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
@@ -167,6 +186,16 @@ export default function OperationsHubPage({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Operations Freshness Context" />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel="This page reflects mission-control freshness, public bridge status, and explorer-served config surfaces."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
|
||||
@@ -226,7 +255,7 @@ export default function OperationsHubPage({
|
||||
{relativeAge(bridgeStatus?.data?.checked_at)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Public mission-control snapshot freshness.
|
||||
{mode === 'guided' ? 'Public mission-control snapshot freshness.' : 'Mission-control freshness.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
@@ -235,7 +264,7 @@ export default function OperationsHubPage({
|
||||
{relativeAge(routeMatrix?.updated)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Token-aggregation route inventory timestamp.
|
||||
{mode === 'guided' ? 'Token-aggregation route inventory timestamp.' : 'Route inventory timestamp.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
@@ -244,7 +273,7 @@ export default function OperationsHubPage({
|
||||
{networksConfig?.defaultChainId ?? 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Wallet onboarding points at Chain 138 by default.
|
||||
{mode === 'guided' ? 'Wallet onboarding points at Chain 138 by default.' : 'Default wallet chain.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
@@ -255,7 +284,7 @@ export default function OperationsHubPage({
|
||||
: 'Partial'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
`wallet_addEthereumChain` and `wallet_watchAsset` compatibility.
|
||||
{mode === 'guided' ? '`wallet_addEthereumChain` and `wallet_watchAsset` compatibility.' : 'Wallet RPC support.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,14 @@ function relaySnapshot(relay: MissionControlRelayPayload | undefined) {
|
||||
return relay?.url_probe?.body || relay?.file_snapshot
|
||||
}
|
||||
|
||||
function relaySummary(snapshot: ReturnType<typeof relaySnapshot>) {
|
||||
if (!snapshot) return 'destination unknown'
|
||||
if (snapshot.status === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
|
||||
return `Delivery paused · queue ${formatNumber(snapshot.queue?.size ?? 0)}`
|
||||
}
|
||||
return `Queue ${formatNumber(snapshot.queue?.size ?? 0)} · ${snapshot.destination?.chain_name || 'destination unknown'}`
|
||||
}
|
||||
|
||||
export default function WethOperationsPage({
|
||||
initialBridgeStatus = null,
|
||||
initialPlannerCapabilities = null,
|
||||
@@ -112,13 +120,13 @@ export default function WethOperationsPage({
|
||||
<MetricCard
|
||||
title="Mainnet WETH Lane"
|
||||
value={mainnetWeth?.status || 'unknown'}
|
||||
description={`Queue ${formatNumber(mainnetWeth?.queue?.size ?? 0)} · ${mainnetWeth?.destination?.chain_name || 'destination unknown'}`}
|
||||
description={relaySummary(mainnetWeth)}
|
||||
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Mainnet cW Lane"
|
||||
value={mainnetCw?.status || 'unknown'}
|
||||
description={`Queue ${formatNumber(mainnetCw?.queue?.size ?? 0)} · ${mainnetCw?.destination?.chain_name || 'destination unknown'}`}
|
||||
description={relaySummary(mainnetCw)}
|
||||
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
|
||||
/>
|
||||
<MetricCard
|
||||
|
||||
@@ -10,36 +10,106 @@ import {
|
||||
} from '@/services/api/stats'
|
||||
import {
|
||||
missionControlApi,
|
||||
summarizeMissionControlRelay,
|
||||
type MissionControlBridgeStatusResponse,
|
||||
type MissionControlRelaySummary,
|
||||
} from '@/services/api/missionControl'
|
||||
import { loadDashboardData } from '@/utils/dashboard'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import { formatTimestamp, formatWeiAsEth } from '@/utils/format'
|
||||
import { formatRelativeAge, formatTimestamp, formatWeiAsEth } from '@/utils/format'
|
||||
import { transactionsApi, type Transaction } from '@/services/api/transactions'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
||||
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
|
||||
|
||||
type HomeStats = ExplorerStats
|
||||
|
||||
interface HomePageProps {
|
||||
initialStats?: HomeStats | null
|
||||
initialRecentBlocks?: Block[]
|
||||
initialRecentTransactions?: Transaction[]
|
||||
initialTransactionTrend?: ExplorerTransactionTrendPoint[]
|
||||
initialActivitySnapshot?: ExplorerRecentActivitySnapshot | null
|
||||
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||
initialRelaySummary?: MissionControlRelaySummary | null
|
||||
}
|
||||
|
||||
function resolveRelaySeverityLabel(status?: string, tone?: 'normal' | 'warning' | 'danger') {
|
||||
const normalized = String(status || '').toLowerCase()
|
||||
if (normalized === 'down') return 'down'
|
||||
if (normalized === 'degraded' || normalized === 'stale' || normalized === 'stopped') return 'degraded'
|
||||
if (normalized === 'paused') return 'paused'
|
||||
if (['starting', 'unknown', 'snapshot-error'].includes(normalized) || tone === 'warning') return 'warning'
|
||||
return 'operational'
|
||||
}
|
||||
|
||||
function resolveRelayBadgeTone(status?: string, tone?: 'normal' | 'warning' | 'danger'): 'success' | 'info' | 'warning' {
|
||||
const severity = resolveRelaySeverityLabel(status, tone)
|
||||
if (severity === 'operational') return 'success'
|
||||
if (severity === 'warning') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function getLaneImpactNote(key: string, severity: string) {
|
||||
if (key === 'mainnet_weth' && severity === 'paused') {
|
||||
return 'New Mainnet WETH bridge deliveries are currently queued while this lane is paused. Core Chain 138 browsing remains available.'
|
||||
}
|
||||
if (key === 'avax' || key === 'avalanche' || key === 'avax_cw' || key === 'avax_to_138') {
|
||||
return severity === 'operational'
|
||||
? 'Avalanche lane visibility is healthy.'
|
||||
: 'Affects Avalanche-connected bridge visibility and routing. Core Chain 138 browsing remains available.'
|
||||
}
|
||||
if (key.includes('mainnet')) {
|
||||
return severity === 'operational'
|
||||
? 'Ethereum Mainnet relay visibility is healthy.'
|
||||
: 'Affects Mainnet bridge posture and route visibility more than core Chain 138 browsing.'
|
||||
}
|
||||
if (key.includes('bsc')) {
|
||||
return severity === 'operational'
|
||||
? 'BSC relay visibility is healthy.'
|
||||
: 'Affects BSC-connected bridge posture and route visibility more than core Chain 138 browsing.'
|
||||
}
|
||||
return severity === 'operational'
|
||||
? 'Relay lane visibility is healthy.'
|
||||
: 'Affects this relay lane more than core Chain 138 chain browsing.'
|
||||
}
|
||||
|
||||
function formatObservabilityValue(value: number | null, formatter: (value: number) => string) {
|
||||
if (value == null) {
|
||||
return { value: 'Unknown', note: 'Not reported by the current public stats payload.' }
|
||||
}
|
||||
return { value: formatter(value), note: 'Current public stats payload.' }
|
||||
}
|
||||
|
||||
function formatGasPriceGwei(value: number) {
|
||||
if (!Number.isFinite(value)) return 'Unknown'
|
||||
return `${value.toFixed(3)} gwei`
|
||||
}
|
||||
|
||||
export default function Home({
|
||||
initialStats = null,
|
||||
initialRecentBlocks = [],
|
||||
initialRecentTransactions = [],
|
||||
initialTransactionTrend = [],
|
||||
initialActivitySnapshot = null,
|
||||
initialBridgeStatus = null,
|
||||
initialRelaySummary = null,
|
||||
}: HomePageProps) {
|
||||
const { mode } = useUiMode()
|
||||
const [stats, setStats] = useState<HomeStats | null>(initialStats)
|
||||
const [recentBlocks, setRecentBlocks] = useState<Block[]>(initialRecentBlocks)
|
||||
const [transactionTrend, setTransactionTrend] = useState<ExplorerTransactionTrendPoint[]>(initialTransactionTrend)
|
||||
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
|
||||
const [activitySnapshot, setActivitySnapshot] = useState<ExplorerRecentActivitySnapshot | null>(initialActivitySnapshot)
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [relaySummary, setRelaySummary] = useState<MissionControlRelaySummary | null>(initialRelaySummary)
|
||||
const [missionExpanded, setMissionExpanded] = useState(false)
|
||||
const [relayExpanded, setRelayExpanded] = useState(false)
|
||||
const [relayPage, setRelayPage] = useState(1)
|
||||
const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>(
|
||||
initialRelaySummary ? 'fallback' : 'connecting'
|
||||
initialRelaySummary || initialBridgeStatus ? 'fallback' : 'connecting'
|
||||
)
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const latestBlock = stats?.latest_block ?? recentBlocks[0]?.number ?? null
|
||||
@@ -92,14 +162,41 @@ export default function Home({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
if (recentTransactions.length > 0) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
transactionsApi.listSafe(chainId, 1, 5)
|
||||
.then(({ ok, data }) => {
|
||||
if (!cancelled && ok && data.length > 0) {
|
||||
setRecentTransactions(data)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to load recent transactions for activity context:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [chainId, recentTransactions.length])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadSnapshot = async () => {
|
||||
try {
|
||||
const summary = await missionControlApi.getRelaySummary()
|
||||
const status = await missionControlApi.getBridgeStatus()
|
||||
if (!cancelled) {
|
||||
setRelaySummary(summary)
|
||||
setBridgeStatus(status)
|
||||
setRelaySummary(summarizeMissionControlRelay(status))
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
@@ -110,10 +207,11 @@ export default function Home({
|
||||
|
||||
loadSnapshot()
|
||||
|
||||
const unsubscribe = missionControlApi.subscribeRelaySummary(
|
||||
(summary) => {
|
||||
const unsubscribe = missionControlApi.subscribeBridgeStatus(
|
||||
(status) => {
|
||||
if (!cancelled) {
|
||||
setRelaySummary(summary)
|
||||
setBridgeStatus(status)
|
||||
setRelaySummary(summarizeMissionControlRelay(status))
|
||||
setRelayFeedState('live')
|
||||
}
|
||||
},
|
||||
@@ -144,103 +242,375 @@ export default function Home({
|
||||
(best, point) => (!best || point.transaction_count > best.transaction_count ? point : best),
|
||||
null,
|
||||
)
|
||||
const averageBlockTimeSeconds =
|
||||
stats?.average_block_time_ms != null ? Math.round(stats.average_block_time_ms / 1000) : null
|
||||
const averageGasPriceGwei = stats?.average_gas_price_gwei ?? null
|
||||
const transactionsToday = stats?.transactions_today ?? null
|
||||
const networkUtilization =
|
||||
stats?.network_utilization_percentage != null ? Math.round(stats.network_utilization_percentage) : 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) || []
|
||||
const relayPageSize = 4
|
||||
const relayPageCount = relaySummary?.items.length ? Math.max(1, Math.ceil(relaySummary.items.length / relayPageSize)) : 1
|
||||
const relayVisibleItems = relaySummary?.items.slice((relayPage - 1) * relayPageSize, relayPage * relayPageSize) || []
|
||||
const chainStatus = bridgeStatus?.data?.chains?.['138'] || (bridgeStatus?.data?.chains ? Object.values(bridgeStatus.data.chains)[0] : null)
|
||||
const checkedAt = bridgeStatus?.data?.checked_at || null
|
||||
const missionHeadline = relaySummary
|
||||
? relaySummary.tone === 'danger'
|
||||
? 'Relay lanes need attention'
|
||||
: relaySummary.tone === 'warning'
|
||||
? 'Relay lanes are degraded'
|
||||
: 'Relay lanes are operational'
|
||||
: chainStatus?.status === 'operational'
|
||||
? 'Chain 138 public health is operational'
|
||||
: chainStatus?.status
|
||||
? `Chain 138 public health is ${chainStatus.status}`
|
||||
: 'Mission control snapshot available'
|
||||
const missionDescription = (() => {
|
||||
const parts: string[] = []
|
||||
if (checkedAt) parts.push(`Last checked ${formatTimestamp(checkedAt)}`)
|
||||
if (chainStatus?.head_age_sec != null) parts.push(`head age ${Math.round(chainStatus.head_age_sec)}s`)
|
||||
if (chainStatus?.latency_ms != null) parts.push(`RPC latency ${Math.round(chainStatus.latency_ms)}ms`)
|
||||
if (relaySummary?.items.length) {
|
||||
parts.push(`${relayOperationalCount} operational lanes`)
|
||||
if (relayAttentionCount > 0) parts.push(`${relayAttentionCount} flagged lanes`)
|
||||
} else {
|
||||
parts.push('relay inventory unavailable in the current snapshot')
|
||||
}
|
||||
return parts.join(' · ')
|
||||
})()
|
||||
const snapshotAgeLabel = checkedAt ? formatRelativeAge(checkedAt) : 'Unknown'
|
||||
const chainVisibilityState =
|
||||
chainStatus?.head_age_sec != null
|
||||
? chainStatus.head_age_sec <= 30
|
||||
? 'current'
|
||||
: chainStatus.head_age_sec <= 120
|
||||
? 'slightly delayed'
|
||||
: 'stale'
|
||||
: 'unknown'
|
||||
const snapshotReason =
|
||||
relayFeedState === 'fallback'
|
||||
? 'Live indexing or relay streaming is not currently attached to this homepage card.'
|
||||
: relayFeedState === 'live'
|
||||
? 'Receiving named live mission-control events.'
|
||||
: 'Negotiating the mission-control event stream.'
|
||||
const snapshotScope =
|
||||
bridgeStatus?.data?.mode?.scope
|
||||
? bridgeStatus.data.mode.scope.replaceAll('_', ' ')
|
||||
: relayFeedState === 'fallback'
|
||||
? 'This primarily affects relay-lane freshness on the homepage card. Core explorer pages and public RPC health can still be current.'
|
||||
: relayFeedState === 'live'
|
||||
? 'Relay and chain status are arriving through live mission-control events.'
|
||||
: 'Homepage status is waiting for the mission-control stream to settle.'
|
||||
const missionImpact = relayAttentionCount > 0
|
||||
? 'Some cross-chain relay lanes are degraded. Core Chain 138 operation remains visible through the public RPC and explorer surfaces.'
|
||||
: 'Core Chain 138 operation and the visible relay lanes are currently healthy.'
|
||||
const activityContext = summarizeChainActivity({
|
||||
blocks: recentBlocks,
|
||||
transactions: recentTransactions,
|
||||
latestBlockNumber: latestBlock,
|
||||
latestBlockTimestamp: recentBlocks[0]?.timestamp ?? null,
|
||||
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
||||
})
|
||||
const txCompleteness = stats?.completeness?.transactions_feed || bridgeStatus?.data?.subsystems?.tx_index?.completeness || null
|
||||
const blockCompleteness = stats?.completeness?.blocks_feed || null
|
||||
const statsGeneratedAt = stats?.sampling?.stats_generated_at || null
|
||||
const missionMode = bridgeStatus?.data?.mode || null
|
||||
const freshnessIssues = Object.entries({
|
||||
...(bridgeStatus?.data?.sampling?.issues || {}),
|
||||
...(stats?.sampling?.issues || {}),
|
||||
})
|
||||
const latestTransactionAgeLabel = activityContext.latest_transaction_timestamp
|
||||
? formatRelativeAge(activityContext.latest_transaction_timestamp)
|
||||
: 'Unknown'
|
||||
const latestTransactionFreshness =
|
||||
activityContext.latest_transaction_age_seconds == null
|
||||
? 'Latest transaction freshness is unavailable.'
|
||||
: activityContext.latest_transaction_age_seconds <= 15 * 60
|
||||
? 'Recent visible transactions are close to the chain head.'
|
||||
: activityContext.latest_transaction_age_seconds <= 3 * 60 * 60
|
||||
? 'The chain head is current, but visible transactions are older than the current tip.'
|
||||
: 'The chain head is current, but visible transactions are substantially older than the current tip.'
|
||||
const severityBreakdown = {
|
||||
down: relaySummary?.items.filter((item) => item.status === 'down').length || 0,
|
||||
degraded: relaySummary?.items.filter((item) => item.status === 'degraded').length || 0,
|
||||
warning:
|
||||
relaySummary?.items.filter((item) => ['paused', 'starting', 'unknown', 'snapshot-error'].includes(item.status)).length || 0,
|
||||
}
|
||||
const avgBlockTimeSummary = formatObservabilityValue(
|
||||
averageBlockTimeSeconds,
|
||||
(value) => `${value}s`,
|
||||
)
|
||||
const avgGasPriceSummary = formatObservabilityValue(
|
||||
averageGasPriceGwei,
|
||||
formatGasPriceGwei,
|
||||
)
|
||||
const transactionsTodaySummary = formatObservabilityValue(
|
||||
transactionsToday,
|
||||
(value) => value.toLocaleString(),
|
||||
)
|
||||
const networkUtilizationSummary =
|
||||
networkUtilization == null
|
||||
? { value: 'Unknown', note: 'Utilization is not reported by the current public stats payload.' }
|
||||
: networkUtilization === 0
|
||||
? { value: '0%', note: 'No utilization was reported in the latest visible stats sample.' }
|
||||
: { value: `${networkUtilization}%`, note: 'Current public stats payload.' }
|
||||
const missionCollapsedSummary = relaySummary
|
||||
? `${missionHeadline} · ${relayOperationalCount} operational`
|
||||
: `${missionHeadline}${chainStatus?.status ? ` · chain 138 ${chainStatus.status}` : ''}`
|
||||
|
||||
useEffect(() => {
|
||||
setRelayPage(1)
|
||||
}, [relaySummary?.items.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (relayPage > relayPageCount) {
|
||||
setRelayPage(relayPageCount)
|
||||
}
|
||||
}, [relayPage, relayPageCount])
|
||||
|
||||
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>
|
||||
{(relaySummary || bridgeStatus) && (
|
||||
<Card
|
||||
className={`border shadow-sm ${relayToneClasses} ${missionExpanded ? 'mb-6' : 'mb-4 !p-2 sm:!p-2'}`}
|
||||
>
|
||||
<div className={missionExpanded ? 'flex flex-col gap-5' : 'flex'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMissionExpanded((current) => !current)}
|
||||
aria-expanded={missionExpanded}
|
||||
className={`flex w-full items-center justify-between text-left shadow-sm backdrop-blur transition hover:bg-white/70 dark:border-white/10 dark:bg-black/10 dark:hover:bg-black/20 ${
|
||||
missionExpanded
|
||||
? 'gap-3 rounded-xl border border-white/40 bg-white/55 px-4 py-2.5'
|
||||
: 'gap-2 rounded-lg border border-white/35 bg-white/50 px-3 py-2'
|
||||
}`}
|
||||
>
|
||||
<div className={`min-w-0 opacity-90 ${missionExpanded ? 'text-sm leading-6 sm:text-base' : 'text-sm leading-5'}`}>
|
||||
<span className="font-semibold uppercase tracking-[0.22em] opacity-75">Mission Control</span>
|
||||
<span className={missionExpanded ? 'mx-2 opacity-40' : 'mx-1.5 opacity-40'}>•</span>
|
||||
<span>{missionCollapsedSummary}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`shrink-0 font-semibold opacity-80 ${mode === 'guided' ? 'text-sm' : 'text-lg leading-none'}`}
|
||||
aria-label={missionExpanded ? 'Hide details' : 'Show details'}
|
||||
title={missionExpanded ? 'Hide details' : 'Show details'}
|
||||
>
|
||||
{mode === 'guided' ? (missionExpanded ? 'Hide details' : 'Show details') : (missionExpanded ? '\u2303' : '\u2304')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<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>
|
||||
{missionExpanded ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<div className="mt-2 text-xl font-semibold sm:text-2xl">{missionHeadline}</div>
|
||||
<p className="mt-2 text-sm leading-6 opacity-90 sm:text-base">
|
||||
{missionDescription}.
|
||||
{mode === 'guided'
|
||||
? ' This surface summarizes the public chain and relay posture in a compact operator-friendly format.'
|
||||
: ' Public chain and relay posture.'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 opacity-90 sm:text-base">
|
||||
{missionImpact}
|
||||
</p>
|
||||
<Explain>
|
||||
<p className="mt-2 text-sm leading-6 opacity-90 sm:text-base">
|
||||
{latestTransactionFreshness}
|
||||
</p>
|
||||
</Explain>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<EntityBadge
|
||||
label={relayFeedState === 'live' ? 'live sse' : relayFeedState === 'fallback' ? 'snapshot' : 'connecting'}
|
||||
tone={relayFeedState === 'fallback' ? 'warning' : relayFeedState === 'connecting' ? 'info' : 'success'}
|
||||
/>
|
||||
{relaySummary ? (
|
||||
<EntityBadge
|
||||
label={relaySummary.tone === 'danger' ? 'attention needed' : relaySummary.tone === 'warning' ? 'degraded' : 'operational'}
|
||||
tone={relaySummary.tone === 'danger' ? 'warning' : relaySummary.tone === 'warning' ? 'info' : 'success'}
|
||||
/>
|
||||
) : null}
|
||||
{chainStatus?.status ? (
|
||||
<EntityBadge
|
||||
label={`chain 138 ${chainStatus.status}`}
|
||||
tone={chainStatus.status === 'operational' ? 'success' : 'warning'}
|
||||
/>
|
||||
) : null}
|
||||
<EntityBadge label={`${relayOperationalCount} operational`} tone="success" />
|
||||
{severityBreakdown.down > 0 ? <EntityBadge label={`${severityBreakdown.down} down`} tone="warning" /> : null}
|
||||
{severityBreakdown.degraded > 0 ? <EntityBadge label={`${severityBreakdown.degraded} degraded`} tone="info" /> : null}
|
||||
{severityBreakdown.warning > 0 ? <EntityBadge label={`${severityBreakdown.warning} warning`} tone="warning" /> : null}
|
||||
</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">
|
||||
{missionMode?.kind === 'live'
|
||||
? 'Streaming'
|
||||
: missionMode?.kind === 'snapshot' || relayFeedState === 'fallback'
|
||||
? 'Snapshot mode'
|
||||
: 'Connecting'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm opacity-80">
|
||||
{`${statsGeneratedAt ? `Snapshot updated ${formatRelativeAge(statsGeneratedAt)}.` : `Snapshot updated ${snapshotAgeLabel}.`} ${
|
||||
missionMode?.reason ? missionMode.reason.replaceAll('_', ' ') : snapshotReason
|
||||
}`}
|
||||
</div>
|
||||
<div className="mt-2 text-xs opacity-75">
|
||||
{snapshotScope}
|
||||
</div>
|
||||
{freshnessIssues.length > 0 ? (
|
||||
<div className="mt-2 text-xs opacity-75">
|
||||
Freshness diagnostics: {freshnessIssues.map(([key]) => key.replaceAll('_', ' ')).join(', ')}.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
href="/bridge"
|
||||
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 bridge monitoring
|
||||
</Link>
|
||||
<Link
|
||||
href="/operations"
|
||||
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 operations hub
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chainStatus ? (
|
||||
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-5">
|
||||
<div 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="text-xs font-semibold uppercase tracking-wide opacity-70">Chain 138 Status</div>
|
||||
<div className="mt-2 text-lg font-semibold">{chainStatus.status || 'unknown'}</div>
|
||||
<div className="mt-1 text-sm opacity-80">{chainStatus.name || 'Defi Oracle Meta Mainnet'}</div>
|
||||
</div>
|
||||
<div 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="text-xs font-semibold uppercase tracking-wide opacity-70">Head Age</div>
|
||||
<div className="mt-2 text-lg font-semibold">
|
||||
{relayFeedState === 'live' ? 'Streaming' : relayFeedState === 'fallback' ? 'Snapshot mode' : 'Connecting'}
|
||||
{chainStatus.head_age_sec != null ? `${Math.round(chainStatus.head_age_sec)}s` : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm opacity-80">Latest public RPC head freshness.</div>
|
||||
<div className="mt-2 text-xs opacity-75">Chain visibility is currently {chainVisibilityState}.</div>
|
||||
</div>
|
||||
<div 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="text-xs font-semibold uppercase tracking-wide opacity-70">RPC Latency</div>
|
||||
<div className="mt-2 text-lg font-semibold">
|
||||
{chainStatus.latency_ms != null ? `${Math.round(chainStatus.latency_ms)}ms` : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm opacity-80">Public Chain 138 RPC probe latency.</div>
|
||||
</div>
|
||||
<div 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="text-xs font-semibold uppercase tracking-wide opacity-70">Latest Transaction</div>
|
||||
<div className="mt-2 text-lg font-semibold">
|
||||
{activityContext.latest_transaction_block_number != null ? `#${activityContext.latest_transaction_block_number}` : 'Unknown'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm opacity-80">{latestTransactionAgeLabel}</div>
|
||||
<div className="mt-2 text-xs opacity-75">
|
||||
Latest visible transaction freshness{txCompleteness ? ` · ${txCompleteness}` : ''}.
|
||||
</div>
|
||||
</div>
|
||||
<div 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="text-xs font-semibold uppercase tracking-wide opacity-70">Last Non-Empty Block</div>
|
||||
<div className="mt-2 text-lg font-semibold">
|
||||
{activityContext.last_non_empty_block_number != null ? `#${activityContext.last_non_empty_block_number}` : 'Unknown'}
|
||||
</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.'}
|
||||
{activityContext.block_gap_to_latest_transaction != null
|
||||
? `${activityContext.block_gap_to_latest_transaction.toLocaleString()} blocks behind tip`
|
||||
: 'Gap unavailable'}
|
||||
</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>
|
||||
) : null}
|
||||
|
||||
{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>
|
||||
{relaySummary?.items.length ? (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRelayExpanded((current) => !current)}
|
||||
aria-expanded={relayExpanded}
|
||||
className="flex w-full items-center justify-between gap-4 rounded-2xl border border-white/40 bg-white/55 p-4 text-left shadow-sm backdrop-blur transition hover:bg-white/70 dark:border-white/10 dark:bg-black/10 dark:hover:bg-black/20"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Relay lane status</div>
|
||||
<p className="mt-1 text-sm leading-6 opacity-90">
|
||||
{relaySummary.text}. {relaySummary.items.length} configured lanes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-sm font-semibold opacity-80">
|
||||
{relayExpanded ? 'Hide lanes' : 'Show lanes'}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{relayExpanded ? (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-2">
|
||||
{relayVisibleItems.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={resolveRelaySeverityLabel(item.status, item.tone)} tone={resolveRelayBadgeTone(item.status, item.tone)} />
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 opacity-90">{item.text}</p>
|
||||
<p className="mt-2 text-xs opacity-75">
|
||||
{getLaneImpactNote(item.key, resolveRelaySeverityLabel(item.status, item.tone))}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{relayPageCount > 1 ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl border border-white/40 bg-white/40 px-4 py-3 text-sm shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRelayPage((current) => Math.max(1, current - 1))}
|
||||
disabled={relayPage === 1}
|
||||
className="rounded-lg border border-current/20 px-3 py-2 font-semibold disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<div className="text-center opacity-80">
|
||||
Page {relayPage} of {relayPageCount}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRelayPage((current) => Math.min(relayPageCount, current + 1))}
|
||||
disabled={relayPage === relayPageCount}
|
||||
className="rounded-lg border border-current/20 px-3 py-2 font-semibold disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/40 bg-white/55 p-4 text-sm opacity-90 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
|
||||
The current mission-control snapshot does not include per-lane relay inventory. Chain health is still shown above, and the bridge monitoring page remains the canonical operator view.
|
||||
</div>
|
||||
)}
|
||||
{relaySummary ? (
|
||||
<div className="flex flex-wrap gap-2 text-sm opacity-80">
|
||||
{severityBreakdown.down > 0 ? <EntityBadge label={`${severityBreakdown.down} down`} tone="warning" /> : null}
|
||||
{severityBreakdown.degraded > 0 ? <EntityBadge label={`${severityBreakdown.degraded} degraded`} tone="info" /> : null}
|
||||
{severityBreakdown.warning > 0 ? <EntityBadge label={`${severityBreakdown.warning} warning`} tone="warning" /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -253,22 +623,61 @@ export default function Home({
|
||||
<div className="text-xl font-bold sm:text-2xl">
|
||||
{latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{activityContext.latest_block_timestamp
|
||||
? `Head freshness ${formatRelativeAge(activityContext.latest_block_timestamp)}${blockCompleteness ? ` · ${blockCompleteness}` : ''}`
|
||||
: 'Head freshness 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>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">Visible public explorer block count.</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>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">Latest visible tx {latestTransactionAgeLabel}.</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>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">Current public explorer address count.</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Avg Block Time</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{avgBlockTimeSummary.value}</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{avgBlockTimeSummary.note}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Avg Gas Price</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{avgGasPriceSummary.value}</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{avgGasPriceSummary.note}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Transactions Today</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{transactionsTodaySummary.value}</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{transactionsTodaySummary.note}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Network Utilization</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{networkUtilizationSummary.value}</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{networkUtilizationSummary.note}</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<ActivityContextPanel context={activityContext} />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel="Homepage status combines chain freshness, transaction visibility, and mission-control posture."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!stats && (
|
||||
<Card className="mb-8">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -284,6 +693,11 @@ export default function Home({
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{shouldExplainEmptyHeadBlocks(recentBlocks, activityContext) ? (
|
||||
<p className="rounded-xl border border-amber-200 bg-amber-50/70 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
|
||||
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
|
||||
</p>
|
||||
) : null}
|
||||
{recentBlocks.map((block) => (
|
||||
<div key={block.number} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
@@ -315,7 +729,9 @@ export default function Home({
|
||||
<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.
|
||||
{mode === 'guided'
|
||||
? 'A concise public view of chain activity, index coverage, and recent execution patterns.'
|
||||
: 'Public chain activity and index posture.'}
|
||||
</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">
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from '@/components/wallet/AddToMetaMask'
|
||||
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
|
||||
import Link from 'next/link'
|
||||
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
||||
|
||||
interface WalletPageProps {
|
||||
initialNetworks?: NetworksCatalog | null
|
||||
@@ -17,19 +18,35 @@ interface WalletPageProps {
|
||||
}
|
||||
|
||||
export default function WalletPage(props: WalletPageProps) {
|
||||
const { mode } = useUiMode()
|
||||
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>
|
||||
<h1 className="mb-4 text-2xl font-bold sm:text-3xl">Wallet Tools</h1>
|
||||
<p className="mb-6 text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base">
|
||||
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.
|
||||
{mode === 'guided'
|
||||
? 'Use the explorer-served network catalog, token list, and capability metadata to connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets.'
|
||||
: 'Use explorer-served network and token metadata to connect Chain 138 and Ethereum Mainnet wallets.'}
|
||||
</p>
|
||||
<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.
|
||||
<Explain>
|
||||
<>
|
||||
Need swap and liquidity discovery too? Visit the{' '}
|
||||
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
|
||||
Liquidity Access
|
||||
</Link>{' '}
|
||||
page for live Chain 138 pools, route matrix links, partner payload templates, and the internal fallback execution plan endpoints.
|
||||
</>
|
||||
</Explain>
|
||||
{mode === 'expert' ? (
|
||||
<>
|
||||
Liquidity and planner posture lives on the{' '}
|
||||
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
|
||||
Liquidity Access
|
||||
</Link>{' '}
|
||||
surface.
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface ExplorerFeaturePage {
|
||||
actions: ExplorerFeatureAction[]
|
||||
}
|
||||
|
||||
const legacyNote =
|
||||
const sharedOperationsNote =
|
||||
'These pages collect the public monitoring, route, wallet, and topology surfaces that support Chain 138 operations and investigation.'
|
||||
|
||||
export const explorerFeaturePages = {
|
||||
@@ -23,7 +23,7 @@ export const explorerFeaturePages = {
|
||||
title: 'Bridge & Relay Monitoring',
|
||||
description:
|
||||
'Inspect the CCIP relay status, follow the live mission-control stream, trace bridge transactions, and review the managed Mainnet, BSC, Avalanche, Avalanche cW, and Avalanche to Chain 138 lanes.',
|
||||
note: legacyNote,
|
||||
note: sharedOperationsNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Mission-control live stream',
|
||||
@@ -73,7 +73,7 @@ export const explorerFeaturePages = {
|
||||
title: 'Routes, Pools, and Execution Access',
|
||||
description:
|
||||
'Surface the route matrix, live pool inventory, public liquidity endpoints, and bridge-adjacent execution paths from one public explorer surface.',
|
||||
note: legacyNote,
|
||||
note: sharedOperationsNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Liquidity access',
|
||||
@@ -81,12 +81,6 @@ export const explorerFeaturePages = {
|
||||
href: '/liquidity',
|
||||
label: 'Open liquidity access',
|
||||
},
|
||||
{
|
||||
title: 'Pools inventory',
|
||||
description: 'Jump to the pool overview page for quick PMM route and asset discovery.',
|
||||
href: '/pools',
|
||||
label: 'Open pools page',
|
||||
},
|
||||
{
|
||||
title: 'Pools inventory',
|
||||
description: 'Open the live pools page instead of dropping into a raw backend response.',
|
||||
@@ -112,7 +106,7 @@ export const explorerFeaturePages = {
|
||||
title: 'WETH Utilities & Bridge References',
|
||||
description:
|
||||
'Reach the WETH-focused tooling that operators use during support and bridge investigation without depending on the hidden legacy explorer navigation.',
|
||||
note: legacyNote,
|
||||
note: sharedOperationsNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Bridge monitoring',
|
||||
@@ -146,7 +140,7 @@ export const explorerFeaturePages = {
|
||||
title: 'Analytics & Network Activity',
|
||||
description:
|
||||
'Use the public explorer pages and live monitoring endpoints as the visible analytics surface for chain activity, recent blocks, and transaction flow.',
|
||||
note: legacyNote,
|
||||
note: sharedOperationsNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Blocks',
|
||||
@@ -176,11 +170,11 @@ export const explorerFeaturePages = {
|
||||
],
|
||||
},
|
||||
operator: {
|
||||
eyebrow: 'Operator Shortcuts',
|
||||
title: 'Operator Panel Shortcuts',
|
||||
eyebrow: 'Operator Surface',
|
||||
title: 'Operator Surface',
|
||||
description:
|
||||
'Expose the public operational shortcuts for bridge checks, route validation, liquidity entry points, and documentation.',
|
||||
note: legacyNote,
|
||||
'Expose the public operator surface for bridge checks, route validation, planner providers, liquidity entry points, and documentation.',
|
||||
note: sharedOperationsNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Bridge monitoring',
|
||||
@@ -220,7 +214,7 @@ export const explorerFeaturePages = {
|
||||
title: 'System & Topology',
|
||||
description:
|
||||
'Jump straight into the public topology and reference surfaces that describe how Chain 138, bridge monitoring, and adjacent systems fit together.',
|
||||
note: legacyNote,
|
||||
note: sharedOperationsNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Visual command center',
|
||||
@@ -254,7 +248,7 @@ export const explorerFeaturePages = {
|
||||
title: 'Operations Hub',
|
||||
description:
|
||||
'This hub exposes the public operational surfaces for bridge monitoring, routes, wrapped-asset references, analytics shortcuts, operator links, and topology views.',
|
||||
note: legacyNote,
|
||||
note: sharedOperationsNote,
|
||||
actions: [
|
||||
{
|
||||
title: 'Bridge & relay monitoring',
|
||||
|
||||
@@ -8,6 +8,12 @@ import { readWatchlistFromStorage } from '@/utils/watchlist'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { normalizeTransaction } from '@/services/api/blockscout'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
|
||||
function normalizeAddress(value: string) {
|
||||
const trimmed = value.trim()
|
||||
@@ -16,6 +22,9 @@ function normalizeAddress(value: string) {
|
||||
|
||||
interface AddressesPageProps {
|
||||
initialRecentTransactions: Transaction[]
|
||||
initialLatestBlocks: Array<{ number: number; timestamp: string }>
|
||||
initialStats: ExplorerStats | null
|
||||
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||
}
|
||||
|
||||
function serializeRecentTransactions(transactions: Transaction[]): Transaction[] {
|
||||
@@ -26,17 +35,43 @@ function serializeRecentTransactions(transactions: Transaction[]): Transaction[]
|
||||
block_number: transaction.block_number,
|
||||
from_address: transaction.from_address,
|
||||
to_address: transaction.to_address ?? null,
|
||||
created_at: transaction.created_at,
|
||||
})),
|
||||
),
|
||||
) as Transaction[]
|
||||
}
|
||||
|
||||
export default function AddressesPage({ initialRecentTransactions }: AddressesPageProps) {
|
||||
export default function AddressesPage({
|
||||
initialRecentTransactions,
|
||||
initialLatestBlocks,
|
||||
initialStats,
|
||||
initialBridgeStatus,
|
||||
}: AddressesPageProps) {
|
||||
const router = useRouter()
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const [query, setQuery] = useState('')
|
||||
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
|
||||
const [watchlist, setWatchlist] = useState<string[]>([])
|
||||
const activityContext = useMemo(
|
||||
() =>
|
||||
summarizeChainActivity({
|
||||
blocks: initialLatestBlocks.map((block) => ({
|
||||
chain_id: chainId,
|
||||
number: block.number,
|
||||
hash: '',
|
||||
timestamp: block.timestamp,
|
||||
miner: '',
|
||||
transaction_count: 0,
|
||||
gas_used: 0,
|
||||
gas_limit: 0,
|
||||
})),
|
||||
transactions: recentTransactions,
|
||||
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
|
||||
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
|
||||
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
||||
}),
|
||||
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, recentTransactions],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (initialRecentTransactions.length > 0) {
|
||||
@@ -111,6 +146,17 @@ export default function AddressesPage({ initialRecentTransactions }: AddressesPa
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Recent Address Activity Context" />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={initialStats}
|
||||
bridgeStatus={initialBridgeStatus}
|
||||
scopeLabel="Recently active addresses are derived from the latest visible indexed transactions."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6" title="Open An Address">
|
||||
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
|
||||
<input
|
||||
@@ -158,7 +204,7 @@ export default function AddressesPage({ initialRecentTransactions }: AddressesPa
|
||||
<Card title="Recently Active Addresses">
|
||||
{activeAddresses.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Recent address activity is unavailable right now. You can still open an address directly above.
|
||||
Recent address activity is unavailable in the latest visible transaction sample. You can still open an address directly above.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -177,14 +223,30 @@ export default function AddressesPage({ initialRecentTransactions }: AddressesPa
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<AddressesPageProps> = async () => {
|
||||
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null)
|
||||
const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([
|
||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null),
|
||||
fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null),
|
||||
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
|
||||
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
||||
])
|
||||
const initialRecentTransactions = Array.isArray(transactionsResult?.items)
|
||||
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
|
||||
: []
|
||||
const initialLatestBlocks = Array.isArray(blocksResult?.items)
|
||||
? blocksResult.items
|
||||
.map((item) => ({
|
||||
number: Number(item.height || 0),
|
||||
timestamp: item.timestamp || '',
|
||||
}))
|
||||
.filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp)
|
||||
: []
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialRecentTransactions: serializeRecentTransactions(initialRecentTransactions),
|
||||
initialLatestBlocks,
|
||||
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
|
||||
initialBridgeStatus: bridgeResult,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,55 @@
|
||||
import type { GetServerSideProps } from 'next'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { blocksApi, Block } from '@/services/api/blocks'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import { formatTimestamp } from '@/utils/format'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { normalizeBlock } from '@/services/api/blockscout'
|
||||
import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout'
|
||||
import type { Transaction } from '@/services/api/transactions'
|
||||
import { transactionsApi } from '@/services/api/transactions'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
|
||||
|
||||
interface BlocksPageProps {
|
||||
initialBlocks: Block[]
|
||||
initialRecentTransactions: Transaction[]
|
||||
initialStats: ExplorerStats | null
|
||||
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||
}
|
||||
|
||||
export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
|
||||
function serializeTransactions(transactions: Transaction[]): Transaction[] {
|
||||
return JSON.parse(
|
||||
JSON.stringify(
|
||||
transactions.map((transaction) => ({
|
||||
hash: transaction.hash,
|
||||
block_number: transaction.block_number,
|
||||
from_address: transaction.from_address,
|
||||
to_address: transaction.to_address ?? null,
|
||||
value: transaction.value,
|
||||
status: transaction.status ?? null,
|
||||
contract_address: transaction.contract_address ?? null,
|
||||
fee: transaction.fee ?? null,
|
||||
created_at: transaction.created_at,
|
||||
})),
|
||||
),
|
||||
) as Transaction[]
|
||||
}
|
||||
|
||||
export default function BlocksPage({
|
||||
initialBlocks,
|
||||
initialRecentTransactions,
|
||||
initialStats,
|
||||
initialBridgeStatus,
|
||||
}: BlocksPageProps) {
|
||||
const pageSize = 20
|
||||
const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
|
||||
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
|
||||
const [loading, setLoading] = useState(initialBlocks.length === 0)
|
||||
const [page, setPage] = useState(1)
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
@@ -47,8 +82,43 @@ export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
|
||||
void loadBlocks()
|
||||
}, [initialBlocks, loadBlocks, page])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialRecentTransactions.length > 0) {
|
||||
setRecentTransactions(initialRecentTransactions)
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
transactionsApi.listSafe(chainId, 1, 5)
|
||||
.then(({ ok, data }) => {
|
||||
if (active && ok && data.length > 0) {
|
||||
setRecentTransactions(data)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setRecentTransactions([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [chainId, initialRecentTransactions])
|
||||
|
||||
const showPagination = page > 1 || blocks.length > 0
|
||||
const canGoNext = blocks.length === pageSize
|
||||
const activityContext = useMemo(
|
||||
() =>
|
||||
summarizeChainActivity({
|
||||
blocks,
|
||||
transactions: recentTransactions,
|
||||
latestBlockNumber: blocks[0]?.number ?? null,
|
||||
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
|
||||
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
||||
}),
|
||||
[blocks, initialBridgeStatus, initialStats, recentTransactions],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
@@ -63,12 +133,30 @@ export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Block Production Context" />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={initialStats}
|
||||
bridgeStatus={initialBridgeStatus}
|
||||
scopeLabel="This page focuses on recent visible head blocks."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading blocks...</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{shouldExplainEmptyHeadBlocks(blocks, activityContext) ? (
|
||||
<Card className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/40 dark:bg-amber-950/20">
|
||||
<p className="text-sm text-amber-900 dark:text-amber-100">
|
||||
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
{blocks.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Recent blocks are unavailable right now.</p>
|
||||
@@ -161,13 +249,23 @@ export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<BlocksPageProps> = async () => {
|
||||
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const blocksResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null)
|
||||
const [blocksResult, transactionsResult, statsResult, bridgeResult] = await Promise.all([
|
||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null),
|
||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5').catch(() => null),
|
||||
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
|
||||
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
||||
])
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialBlocks: Array.isArray(blocksResult?.items)
|
||||
? blocksResult.items.map((item) => normalizeBlock(item as never, chainId))
|
||||
: [],
|
||||
initialRecentTransactions: Array.isArray(transactionsResult?.items)
|
||||
? serializeTransactions(transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId)))
|
||||
: [],
|
||||
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
|
||||
initialBridgeStatus: bridgeResult,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ import PageIntro from '@/components/common/PageIntro'
|
||||
|
||||
const docsCards = [
|
||||
{
|
||||
title: 'GRU guide',
|
||||
title: 'GRU Guide',
|
||||
href: '/docs/gru',
|
||||
description: 'Understand GRU standards, x402 readiness, wrapped transport posture, and forward-canonical versioning as surfaced by the explorer.',
|
||||
},
|
||||
{
|
||||
title: 'Transaction evidence matrix',
|
||||
title: 'Transaction Evidence Matrix',
|
||||
href: '/docs/transaction-review',
|
||||
description: 'See how the explorer scores transaction evidence quality, decode richness, asset posture, and counterparty traceability.',
|
||||
},
|
||||
|
||||
@@ -11,16 +11,21 @@ import {
|
||||
} from '@/services/api/stats'
|
||||
import {
|
||||
summarizeMissionControlRelay,
|
||||
type MissionControlBridgeStatusResponse,
|
||||
type MissionControlRelaySummary,
|
||||
} from '@/services/api/missionControl'
|
||||
import type { Block } from '@/services/api/blocks'
|
||||
import type { Transaction } from '@/services/api/transactions'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { normalizeTransaction } from '@/services/api/blockscout'
|
||||
|
||||
interface IndexPageProps {
|
||||
initialStats: ExplorerStats | null
|
||||
initialRecentBlocks: Block[]
|
||||
initialRecentTransactions: Transaction[]
|
||||
initialTransactionTrend: ExplorerTransactionTrendPoint[]
|
||||
initialActivitySnapshot: ExplorerRecentActivitySnapshot | null
|
||||
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||
initialRelaySummary: MissionControlRelaySummary | null
|
||||
}
|
||||
|
||||
@@ -28,10 +33,28 @@ export default function IndexPage(props: IndexPageProps) {
|
||||
return <HomePage {...props} />
|
||||
}
|
||||
|
||||
function serializeTransactions(transactions: Transaction[]): Transaction[] {
|
||||
return JSON.parse(
|
||||
JSON.stringify(
|
||||
transactions.map((transaction) => ({
|
||||
hash: transaction.hash,
|
||||
block_number: transaction.block_number,
|
||||
from_address: transaction.from_address,
|
||||
to_address: transaction.to_address ?? null,
|
||||
value: transaction.value,
|
||||
status: transaction.status ?? null,
|
||||
contract_address: transaction.contract_address ?? null,
|
||||
fee: transaction.fee ?? null,
|
||||
created_at: transaction.created_at,
|
||||
})),
|
||||
),
|
||||
) as Transaction[]
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () => {
|
||||
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
|
||||
const [statsResult, blocksResult, trendResult, activityResult, bridgeResult] = await Promise.allSettled([
|
||||
const [statsResult, blocksResult, transactionsResult, trendResult, activityResult, bridgeResult] = await Promise.allSettled([
|
||||
fetchPublicJson<{
|
||||
total_blocks?: number | string | null
|
||||
total_transactions?: number | string | null
|
||||
@@ -39,6 +62,7 @@ export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () =
|
||||
latest_block?: number | string | null
|
||||
}>('/api/v2/stats'),
|
||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=10'),
|
||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5'),
|
||||
fetchPublicJson<{ chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }> }>(
|
||||
'/api/v2/stats/charts/transactions'
|
||||
),
|
||||
@@ -60,10 +84,18 @@ export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () =
|
||||
blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items)
|
||||
? blocksResult.value.items.map((item) => normalizeBlock(item as never, chainId))
|
||||
: [],
|
||||
initialRecentTransactions:
|
||||
transactionsResult.status === 'fulfilled' && Array.isArray(transactionsResult.value?.items)
|
||||
? serializeTransactions(
|
||||
transactionsResult.value.items.map((item) => normalizeTransaction(item as never, chainId)),
|
||||
)
|
||||
: [],
|
||||
initialTransactionTrend:
|
||||
trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value) : [],
|
||||
initialActivitySnapshot:
|
||||
activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value) : null,
|
||||
initialBridgeStatus:
|
||||
bridgeResult.status === 'fulfilled' ? (bridgeResult.value as MissionControlBridgeStatusResponse) : null,
|
||||
initialRelaySummary:
|
||||
bridgeResult.status === 'fulfilled' ? summarizeMissionControlRelay(bridgeResult.value as never) : null,
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/utils/search'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { useUiMode } from '@/components/common/UiModeContext'
|
||||
|
||||
type SearchFilterMode = 'all' | 'gru' | 'x402' | 'wrapped'
|
||||
|
||||
@@ -28,6 +29,7 @@ export default function SearchPage({
|
||||
initialRawResults,
|
||||
initialCuratedTokens,
|
||||
}: SearchPageProps) {
|
||||
const { mode } = useUiMode()
|
||||
const router = useRouter()
|
||||
const routerQuery = typeof router.query.q === 'string' ? router.query.q : ''
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
@@ -193,7 +195,11 @@ export default function SearchPage({
|
||||
<PageIntro
|
||||
eyebrow="Explorer Lookup"
|
||||
title="Search"
|
||||
description="Search by address, transaction hash, block number, or token symbol. Direct identifiers can jump straight into detail pages, while broader terms fall back to indexed search."
|
||||
description={
|
||||
mode === 'guided'
|
||||
? 'Search by address, transaction hash, block number, or token symbol. Direct identifiers can jump straight into detail pages, while broader terms fall back to indexed search.'
|
||||
: 'Search address, tx hash, block, or token symbol. Direct identifiers jump straight to detail pages.'
|
||||
}
|
||||
actions={[
|
||||
{ href: '/tokens', label: 'Token shortcuts' },
|
||||
{ href: '/addresses', label: 'Browse addresses' },
|
||||
@@ -207,7 +213,7 @@ export default function SearchPage({
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by address, transaction hash, block number..."
|
||||
placeholder={mode === 'guided' ? 'Search by address, transaction hash, block number...' : 'Search tx / addr / block / token'}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<button
|
||||
@@ -237,7 +243,9 @@ export default function SearchPage({
|
||||
{!loading && tokenTarget && (
|
||||
<Card className="mb-6" title="Direct Token Match">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
This matches a curated Chain 138 token, so you can go straight to the token detail page instead of sifting through generic search results.
|
||||
{mode === 'guided'
|
||||
? 'This matches a curated Chain 138 token, so you can go straight to the token detail page instead of sifting through generic search results.'
|
||||
: 'Curated Chain 138 token match.'}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href={tokenTarget.href} className="text-primary-600 hover:underline">
|
||||
@@ -250,7 +258,9 @@ export default function SearchPage({
|
||||
{!loading && !tokenTarget && directTarget && (
|
||||
<Card className="mb-6" title="Direct Match">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
This looks like a direct explorer identifier. You can open it without waiting for indexed search results.
|
||||
{mode === 'guided'
|
||||
? 'This looks like a direct explorer identifier. You can open it without waiting for indexed search results.'
|
||||
: 'Direct explorer identifier detected.'}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href={directTarget.href} className="text-primary-600 hover:underline">
|
||||
|
||||
@@ -8,9 +8,18 @@ import EntityBadge from '@/components/common/EntityBadge'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { normalizeTransaction } from '@/services/api/blockscout'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
|
||||
interface TransactionsPageProps {
|
||||
initialTransactions: Transaction[]
|
||||
initialLatestBlocks: Array<{ number: number; timestamp: string }>
|
||||
initialStats: ExplorerStats | null
|
||||
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||
}
|
||||
|
||||
function serializeTransactionList(transactions: Transaction[]): Transaction[] {
|
||||
@@ -33,12 +42,37 @@ function serializeTransactionList(transactions: Transaction[]): Transaction[] {
|
||||
) as Transaction[]
|
||||
}
|
||||
|
||||
export default function TransactionsPage({ initialTransactions }: TransactionsPageProps) {
|
||||
export default function TransactionsPage({
|
||||
initialTransactions,
|
||||
initialLatestBlocks,
|
||||
initialStats,
|
||||
initialBridgeStatus,
|
||||
}: TransactionsPageProps) {
|
||||
const pageSize = 20
|
||||
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
|
||||
const [loading, setLoading] = useState(initialTransactions.length === 0)
|
||||
const [page, setPage] = useState(1)
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const activityContext = useMemo(
|
||||
() =>
|
||||
summarizeChainActivity({
|
||||
blocks: initialLatestBlocks.map((block) => ({
|
||||
chain_id: chainId,
|
||||
number: block.number,
|
||||
hash: '',
|
||||
timestamp: block.timestamp,
|
||||
miner: '',
|
||||
transaction_count: 0,
|
||||
gas_used: 0,
|
||||
gas_limit: 0,
|
||||
})),
|
||||
transactions,
|
||||
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
|
||||
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
|
||||
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
||||
}),
|
||||
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, transactions],
|
||||
)
|
||||
|
||||
const loadTransactions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -163,6 +197,17 @@ export default function TransactionsPage({ initialTransactions }: TransactionsPa
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Transaction Recency Context" />
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={initialStats}
|
||||
bridgeStatus={initialBridgeStatus}
|
||||
scopeLabel="This page reflects the latest indexed visible transaction activity."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!loading && transactions.length > 0 && (
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card>
|
||||
@@ -250,14 +295,30 @@ export default function TransactionsPage({ initialTransactions }: TransactionsPa
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<TransactionsPageProps> = async () => {
|
||||
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null)
|
||||
const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([
|
||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null),
|
||||
fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null),
|
||||
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
|
||||
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
||||
])
|
||||
const initialTransactions = Array.isArray(transactionsResult?.items)
|
||||
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
|
||||
: []
|
||||
const initialLatestBlocks = Array.isArray(blocksResult?.items)
|
||||
? blocksResult.items
|
||||
.map((item) => ({
|
||||
number: Number(item.height || 0),
|
||||
timestamp: item.timestamp || '',
|
||||
}))
|
||||
.filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp)
|
||||
: []
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialTransactions: serializeTransactionList(initialTransactions),
|
||||
initialLatestBlocks,
|
||||
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
|
||||
initialBridgeStatus: bridgeResult,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ export interface MissionControlRelayItemSummary {
|
||||
|
||||
export interface MissionControlRelaySnapshot {
|
||||
status?: string
|
||||
monitoring?: {
|
||||
delivery_enabled?: boolean
|
||||
shedding?: boolean
|
||||
}
|
||||
service?: {
|
||||
profile?: string
|
||||
}
|
||||
@@ -59,10 +63,40 @@ export interface MissionControlChainStatus {
|
||||
block_number?: string
|
||||
}
|
||||
|
||||
export interface MissionControlMode {
|
||||
kind?: string | null
|
||||
updated_at?: string | null
|
||||
age_seconds?: number | null
|
||||
reason?: string | null
|
||||
scope?: string | null
|
||||
source?: string | null
|
||||
confidence?: string | null
|
||||
provenance?: string | null
|
||||
}
|
||||
|
||||
export interface MissionControlSubsystemStatus {
|
||||
status?: string | null
|
||||
updated_at?: string | null
|
||||
age_seconds?: number | null
|
||||
source?: string | null
|
||||
confidence?: string | null
|
||||
provenance?: string | null
|
||||
completeness?: string | null
|
||||
}
|
||||
|
||||
export interface MissionControlBridgeStatusResponse {
|
||||
data?: {
|
||||
status?: string
|
||||
checked_at?: string
|
||||
freshness?: unknown
|
||||
sampling?: {
|
||||
stats_generated_at?: string | null
|
||||
rpc_probe_at?: string | null
|
||||
stats_window_seconds?: number | null
|
||||
issues?: Record<string, string> | null
|
||||
}
|
||||
mode?: MissionControlMode
|
||||
subsystems?: Record<string, MissionControlSubsystemStatus>
|
||||
chains?: Record<string, MissionControlChainStatus>
|
||||
ccip_relay?: MissionControlRelayPayload
|
||||
ccip_relays?: Record<string, MissionControlRelayPayload>
|
||||
@@ -100,6 +134,16 @@ function relativeAge(isoString?: string): string {
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
function describeRelayStatus(snapshot: MissionControlRelaySnapshot, status: string): string {
|
||||
if (status === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
|
||||
return snapshot.queue?.size && snapshot.queue.size > 0 ? 'delivery paused (queueing)' : 'delivery paused'
|
||||
}
|
||||
if (status === 'paused' && snapshot.monitoring?.shedding) {
|
||||
return 'paused (shedding)'
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
export function summarizeMissionControlRelay(
|
||||
response: MissionControlBridgeStatusResponse | null | undefined
|
||||
): MissionControlRelaySummary | null {
|
||||
@@ -142,11 +186,12 @@ export function summarizeMissionControlRelay(
|
||||
}
|
||||
|
||||
const status = String(snapshot.status || 'unknown').toLowerCase()
|
||||
const statusLabel = describeRelayStatus(snapshot, status)
|
||||
const destination = snapshot.destination?.chain_name
|
||||
const queueSize = snapshot.queue?.size
|
||||
const pollAge = relativeAge(snapshot.last_source_poll?.at)
|
||||
|
||||
let text = `${label}: ${status}`
|
||||
let text = `${label}: ${statusLabel}`
|
||||
if (destination) text += ` -> ${destination}`
|
||||
if (queueSize != null) text += ` · queue ${queueSize}`
|
||||
if (pollAge) text += ` · polled ${pollAge}`
|
||||
@@ -204,11 +249,6 @@ export const missionControlApi = {
|
||||
return (await response.json()) as MissionControlBridgeStatusResponse
|
||||
},
|
||||
|
||||
getRelaySummary: async (): Promise<MissionControlRelaySummary | null> => {
|
||||
const json = await missionControlApi.getBridgeStatus()
|
||||
return summarizeMissionControlRelay(json)
|
||||
},
|
||||
|
||||
subscribeBridgeStatus: (
|
||||
onStatus: (status: MissionControlBridgeStatusResponse) => void,
|
||||
onError?: (error: unknown) => void
|
||||
@@ -241,16 +281,4 @@ export const missionControlApi = {
|
||||
eventSource.close()
|
||||
}
|
||||
},
|
||||
|
||||
subscribeRelaySummary: (
|
||||
onSummary: (summary: MissionControlRelaySummary | null) => void,
|
||||
onError?: (error: unknown) => void
|
||||
): (() => void) => {
|
||||
return missionControlApi.subscribeBridgeStatus(
|
||||
(payload) => {
|
||||
onSummary(summarizeMissionControlRelay(payload))
|
||||
},
|
||||
onError
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ describe('normalizeExplorerStats', () => {
|
||||
total_transactions: 34,
|
||||
total_addresses: 56,
|
||||
latest_block: 78,
|
||||
average_block_time_ms: null,
|
||||
average_gas_price_gwei: null,
|
||||
network_utilization_percentage: null,
|
||||
transactions_today: null,
|
||||
freshness: null,
|
||||
completeness: null,
|
||||
sampling: null,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +41,49 @@ describe('normalizeExplorerStats', () => {
|
||||
total_transactions: 15788,
|
||||
total_addresses: 376,
|
||||
latest_block: null,
|
||||
average_block_time_ms: null,
|
||||
average_gas_price_gwei: null,
|
||||
network_utilization_percentage: null,
|
||||
transactions_today: null,
|
||||
freshness: null,
|
||||
completeness: null,
|
||||
sampling: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes freshness and completeness metadata when present', () => {
|
||||
expect(
|
||||
normalizeExplorerStats({
|
||||
total_blocks: '1',
|
||||
total_transactions: '2',
|
||||
total_addresses: '3',
|
||||
latest_block: '4',
|
||||
freshness: {
|
||||
chain_head: { block_number: '4', timestamp: '2026-04-10T22:10:15Z', age_seconds: '1', source: 'reported' },
|
||||
latest_indexed_block: { block_number: '4', timestamp: '2026-04-10T22:10:15Z', age_seconds: '1' },
|
||||
latest_indexed_transaction: { block_number: '3', timestamp: '2026-04-10T22:00:15Z', age_seconds: '600' },
|
||||
latest_non_empty_block: { block_number: '3', timestamp: '2026-04-10T22:00:15Z', age_seconds: '600', distance_from_head: '1' },
|
||||
},
|
||||
completeness: {
|
||||
transactions_feed: 'partial',
|
||||
blocks_feed: 'complete',
|
||||
},
|
||||
sampling: {
|
||||
stats_generated_at: '2026-04-10T22:10:16Z',
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
freshness: {
|
||||
chain_head: { block_number: 4, age_seconds: 1, source: 'reported' },
|
||||
latest_non_empty_block: { distance_from_head: 1 },
|
||||
},
|
||||
completeness: {
|
||||
transactions_feed: 'partial',
|
||||
blocks_feed: 'complete',
|
||||
},
|
||||
sampling: {
|
||||
stats_generated_at: '2026-04-10T22:10:16Z',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,46 @@ export interface ExplorerStats {
|
||||
total_transactions: number
|
||||
total_addresses: number
|
||||
latest_block: number | null
|
||||
average_block_time_ms: number | null
|
||||
average_gas_price_gwei: number | null
|
||||
network_utilization_percentage: number | null
|
||||
transactions_today: number | null
|
||||
freshness: ExplorerFreshnessSnapshot | null
|
||||
completeness: ExplorerStatsCompleteness | null
|
||||
sampling: ExplorerStatsSampling | null
|
||||
}
|
||||
|
||||
export interface ExplorerFreshnessReference {
|
||||
block_number: number | null
|
||||
timestamp: string | null
|
||||
age_seconds: number | null
|
||||
hash?: string | null
|
||||
distance_from_head?: number | null
|
||||
source?: string | null
|
||||
confidence?: string | null
|
||||
provenance?: string | null
|
||||
completeness?: string | null
|
||||
}
|
||||
|
||||
export interface ExplorerFreshnessSnapshot {
|
||||
chain_head: ExplorerFreshnessReference
|
||||
latest_indexed_block: ExplorerFreshnessReference
|
||||
latest_indexed_transaction: ExplorerFreshnessReference
|
||||
latest_non_empty_block: ExplorerFreshnessReference
|
||||
}
|
||||
|
||||
export interface ExplorerStatsCompleteness {
|
||||
transactions_feed?: string | null
|
||||
blocks_feed?: string | null
|
||||
gas_metrics?: string | null
|
||||
utilization_metrics?: string | null
|
||||
}
|
||||
|
||||
export interface ExplorerStatsSampling {
|
||||
stats_generated_at?: string | null
|
||||
rpc_probe_at?: string | null
|
||||
stats_window_seconds?: number | null
|
||||
issues?: Record<string, string> | null
|
||||
}
|
||||
|
||||
export interface ExplorerTransactionTrendPoint {
|
||||
@@ -31,6 +71,34 @@ interface RawExplorerStats {
|
||||
total_transactions?: number | string | null
|
||||
total_addresses?: number | string | null
|
||||
latest_block?: number | string | null
|
||||
average_block_time?: number | string | null
|
||||
gas_prices?: {
|
||||
slow?: number | string | null
|
||||
average?: number | string | null
|
||||
fast?: number | string | null
|
||||
} | null
|
||||
network_utilization_percentage?: number | string | null
|
||||
transactions_today?: number | string | null
|
||||
freshness?: {
|
||||
chain_head?: RawExplorerFreshnessReference | null
|
||||
latest_indexed_block?: RawExplorerFreshnessReference | null
|
||||
latest_indexed_transaction?: RawExplorerFreshnessReference | null
|
||||
latest_non_empty_block?: RawExplorerFreshnessReference | null
|
||||
} | null
|
||||
completeness?: ExplorerStatsCompleteness | null
|
||||
sampling?: ExplorerStatsSampling | null
|
||||
}
|
||||
|
||||
interface RawExplorerFreshnessReference {
|
||||
block_number?: number | string | null
|
||||
timestamp?: string | null
|
||||
age_seconds?: number | string | null
|
||||
hash?: string | null
|
||||
distance_from_head?: number | string | null
|
||||
source?: string | null
|
||||
confidence?: string | null
|
||||
provenance?: string | null
|
||||
completeness?: string | null
|
||||
}
|
||||
|
||||
function toNumber(value: number | string | null | undefined): number {
|
||||
@@ -39,8 +107,40 @@ function toNumber(value: number | string | null | undefined): number {
|
||||
return 0
|
||||
}
|
||||
|
||||
function normalizeFreshnessReference(raw?: RawExplorerFreshnessReference | null): ExplorerFreshnessReference {
|
||||
return {
|
||||
block_number:
|
||||
raw?.block_number == null || raw.block_number === '' ? null : toNumber(raw.block_number),
|
||||
timestamp: raw?.timestamp || null,
|
||||
age_seconds: raw?.age_seconds == null || raw.age_seconds === '' ? null : toNumber(raw.age_seconds),
|
||||
hash: raw?.hash || null,
|
||||
distance_from_head:
|
||||
raw?.distance_from_head == null || raw.distance_from_head === ''
|
||||
? null
|
||||
: toNumber(raw.distance_from_head),
|
||||
source: raw?.source || null,
|
||||
confidence: raw?.confidence || null,
|
||||
provenance: raw?.provenance || null,
|
||||
completeness: raw?.completeness || null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFreshnessSnapshot(raw?: RawExplorerStats['freshness'] | null): ExplorerFreshnessSnapshot | null {
|
||||
if (!raw) return null
|
||||
return {
|
||||
chain_head: normalizeFreshnessReference(raw.chain_head),
|
||||
latest_indexed_block: normalizeFreshnessReference(raw.latest_indexed_block),
|
||||
latest_indexed_transaction: normalizeFreshnessReference(raw.latest_indexed_transaction),
|
||||
latest_non_empty_block: normalizeFreshnessReference(raw.latest_non_empty_block),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
|
||||
const latestBlockValue = raw.latest_block
|
||||
const averageBlockTimeValue = raw.average_block_time
|
||||
const gasPriceAverageValue = raw.gas_prices?.average
|
||||
const networkUtilizationValue = raw.network_utilization_percentage
|
||||
const transactionsTodayValue = raw.transactions_today
|
||||
|
||||
return {
|
||||
total_blocks: toNumber(raw.total_blocks),
|
||||
@@ -50,6 +150,25 @@ export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
|
||||
latestBlockValue == null || latestBlockValue === ''
|
||||
? null
|
||||
: toNumber(latestBlockValue),
|
||||
average_block_time_ms:
|
||||
averageBlockTimeValue == null || averageBlockTimeValue === ''
|
||||
? null
|
||||
: toNumber(averageBlockTimeValue),
|
||||
average_gas_price_gwei:
|
||||
gasPriceAverageValue == null || gasPriceAverageValue === ''
|
||||
? null
|
||||
: toNumber(gasPriceAverageValue),
|
||||
network_utilization_percentage:
|
||||
networkUtilizationValue == null || networkUtilizationValue === ''
|
||||
? null
|
||||
: toNumber(networkUtilizationValue),
|
||||
transactions_today:
|
||||
transactionsTodayValue == null || transactionsTodayValue === ''
|
||||
? null
|
||||
: toNumber(transactionsTodayValue),
|
||||
freshness: normalizeFreshnessSnapshot(raw.freshness),
|
||||
completeness: raw.completeness || null,
|
||||
sampling: raw.sampling || null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
111
frontend/src/utils/activityContext.ts
Normal file
111
frontend/src/utils/activityContext.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Block } from '@/services/api/blocks'
|
||||
import type { Transaction } from '@/services/api/transactions'
|
||||
import type { ExplorerFreshnessSnapshot } from '@/services/api/stats'
|
||||
|
||||
export type ChainActivityState = 'active' | 'low' | 'inactive' | 'unknown'
|
||||
|
||||
export interface ChainActivityContext {
|
||||
latest_block_number: number | null
|
||||
latest_block_timestamp: string | null
|
||||
latest_transaction_block_number: number | null
|
||||
latest_transaction_timestamp: string | null
|
||||
last_non_empty_block_number: number | null
|
||||
last_non_empty_block_timestamp: string | null
|
||||
block_gap_to_latest_transaction: number | null
|
||||
latest_transaction_age_seconds: number | null
|
||||
state: ChainActivityState
|
||||
head_is_idle: boolean
|
||||
transaction_visibility_unavailable: boolean
|
||||
}
|
||||
|
||||
function sortDescending(values: number[]): number[] {
|
||||
return [...values].sort((left, right) => right - left)
|
||||
}
|
||||
|
||||
function toTimestamp(value?: string | null): number | null {
|
||||
if (!value) return null
|
||||
const parsed = Date.parse(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
export function summarizeChainActivity(input: {
|
||||
blocks?: Block[]
|
||||
transactions?: Transaction[]
|
||||
latestBlockNumber?: number | null
|
||||
latestBlockTimestamp?: string | null
|
||||
freshness?: ExplorerFreshnessSnapshot | null
|
||||
}): ChainActivityContext {
|
||||
const freshness = input.freshness || null
|
||||
const blocks = Array.isArray(input.blocks) ? input.blocks : []
|
||||
const transactions = Array.isArray(input.transactions) ? input.transactions : []
|
||||
|
||||
const latestBlockFromList = sortDescending(blocks.map((block) => block.number).filter((value) => Number.isFinite(value)))[0] ?? null
|
||||
const latestBlock = freshness?.chain_head.block_number ?? input.latestBlockNumber ?? latestBlockFromList
|
||||
const latestBlockTimestamp =
|
||||
freshness?.chain_head.timestamp ??
|
||||
input.latestBlockTimestamp ??
|
||||
blocks.find((block) => block.number === latestBlock)?.timestamp ??
|
||||
blocks[0]?.timestamp ??
|
||||
null
|
||||
|
||||
const latestTransaction = freshness?.latest_indexed_transaction.block_number ?? sortDescending(
|
||||
transactions.map((transaction) => transaction.block_number).filter((value) => Number.isFinite(value)),
|
||||
)[0] ?? null
|
||||
const latestTransactionRecord =
|
||||
transactions.find((transaction) => transaction.block_number === latestTransaction) ?? transactions[0] ?? null
|
||||
|
||||
const nonEmptyBlock =
|
||||
freshness?.latest_non_empty_block.block_number ??
|
||||
sortDescending(blocks.filter((block) => block.transaction_count > 0).map((block) => block.number))[0] ?? latestTransaction
|
||||
const nonEmptyBlockTimestamp =
|
||||
freshness?.latest_non_empty_block.timestamp ??
|
||||
blocks.find((block) => block.number === nonEmptyBlock)?.timestamp ??
|
||||
latestTransactionRecord?.created_at ??
|
||||
null
|
||||
|
||||
const latestTransactionTimestamp = freshness?.latest_indexed_transaction.timestamp ?? latestTransactionRecord?.created_at ?? null
|
||||
const transactionVisibilityUnavailable =
|
||||
freshness?.latest_indexed_transaction.source === 'unavailable' ||
|
||||
freshness?.latest_indexed_transaction.completeness === 'unavailable'
|
||||
const latestTransactionAgeSeconds =
|
||||
freshness?.latest_indexed_transaction.age_seconds ??
|
||||
(() => {
|
||||
const timestamp = toTimestamp(latestTransactionTimestamp)
|
||||
if (timestamp == null) return null
|
||||
return Math.max(0, Math.round((Date.now() - timestamp) / 1000))
|
||||
})()
|
||||
|
||||
const gap = freshness?.latest_non_empty_block.distance_from_head ??
|
||||
(latestBlock != null && latestTransaction != null
|
||||
? Math.max(0, latestBlock - latestTransaction)
|
||||
: null)
|
||||
|
||||
const state: ChainActivityState =
|
||||
latestTransactionAgeSeconds == null
|
||||
? 'unknown'
|
||||
: latestTransactionAgeSeconds <= 15 * 60
|
||||
? 'active'
|
||||
: latestTransactionAgeSeconds <= 3 * 60 * 60
|
||||
? 'low'
|
||||
: 'inactive'
|
||||
|
||||
const headIsIdle =
|
||||
gap != null &&
|
||||
gap > 0 &&
|
||||
latestTransactionAgeSeconds != null &&
|
||||
latestTransactionAgeSeconds > 0
|
||||
|
||||
return {
|
||||
latest_block_number: latestBlock,
|
||||
latest_block_timestamp: latestBlockTimestamp,
|
||||
latest_transaction_block_number: latestTransaction,
|
||||
latest_transaction_timestamp: latestTransactionTimestamp,
|
||||
last_non_empty_block_number: nonEmptyBlock,
|
||||
last_non_empty_block_timestamp: nonEmptyBlockTimestamp,
|
||||
block_gap_to_latest_transaction: gap,
|
||||
latest_transaction_age_seconds: latestTransactionAgeSeconds,
|
||||
state,
|
||||
head_is_idle: headIsIdle,
|
||||
transaction_visibility_unavailable: transactionVisibilityUnavailable,
|
||||
}
|
||||
}
|
||||
132
frontend/src/utils/explorerFreshness.test.ts
Normal file
132
frontend/src/utils/explorerFreshness.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveEffectiveFreshness, summarizeFreshnessConfidence } from './explorerFreshness'
|
||||
|
||||
describe('resolveEffectiveFreshness', () => {
|
||||
it('prefers stats freshness when it is present', () => {
|
||||
expect(
|
||||
resolveEffectiveFreshness(
|
||||
{
|
||||
total_blocks: 1,
|
||||
total_transactions: 2,
|
||||
total_addresses: 3,
|
||||
latest_block: 4,
|
||||
average_block_time_ms: null,
|
||||
average_gas_price_gwei: null,
|
||||
network_utilization_percentage: null,
|
||||
transactions_today: null,
|
||||
freshness: {
|
||||
chain_head: { block_number: 10, timestamp: '2026-04-11T07:00:00Z', age_seconds: 1 },
|
||||
latest_indexed_block: { block_number: 10, timestamp: '2026-04-11T07:00:00Z', age_seconds: 1 },
|
||||
latest_indexed_transaction: { block_number: 9, timestamp: '2026-04-11T06:59:50Z', age_seconds: 11 },
|
||||
latest_non_empty_block: { block_number: 9, timestamp: '2026-04-11T06:59:50Z', age_seconds: 11, distance_from_head: 1 },
|
||||
},
|
||||
completeness: null,
|
||||
sampling: null,
|
||||
},
|
||||
{
|
||||
data: {
|
||||
freshness: {
|
||||
chain_head: { block_number: 20 },
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
chain_head: { block_number: 10 },
|
||||
latest_non_empty_block: { distance_from_head: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to mission-control freshness when stats freshness is unavailable', () => {
|
||||
expect(
|
||||
resolveEffectiveFreshness(
|
||||
{
|
||||
total_blocks: 1,
|
||||
total_transactions: 2,
|
||||
total_addresses: 3,
|
||||
latest_block: 4,
|
||||
average_block_time_ms: null,
|
||||
average_gas_price_gwei: null,
|
||||
network_utilization_percentage: null,
|
||||
transactions_today: null,
|
||||
freshness: null,
|
||||
completeness: null,
|
||||
sampling: null,
|
||||
},
|
||||
{
|
||||
data: {
|
||||
freshness: {
|
||||
chain_head: { block_number: '20', timestamp: '2026-04-11T07:00:00Z', age_seconds: '2' },
|
||||
latest_indexed_block: { block_number: '20', timestamp: '2026-04-11T07:00:00Z', age_seconds: '2' },
|
||||
latest_indexed_transaction: { block_number: '19', timestamp: '2026-04-11T06:59:59Z', age_seconds: '3' },
|
||||
latest_non_empty_block: { block_number: '19', distance_from_head: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
chain_head: { block_number: 20, age_seconds: 2 },
|
||||
latest_indexed_transaction: { block_number: 19, age_seconds: 3 },
|
||||
latest_non_empty_block: { block_number: 19, distance_from_head: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('summarizes confidence in user-facing trust language', () => {
|
||||
expect(
|
||||
summarizeFreshnessConfidence(
|
||||
{
|
||||
total_blocks: 1,
|
||||
total_transactions: 2,
|
||||
total_addresses: 3,
|
||||
latest_block: 4,
|
||||
average_block_time_ms: null,
|
||||
average_gas_price_gwei: null,
|
||||
network_utilization_percentage: null,
|
||||
transactions_today: null,
|
||||
freshness: {
|
||||
chain_head: {
|
||||
block_number: 10,
|
||||
timestamp: '2026-04-11T07:00:00Z',
|
||||
age_seconds: 1,
|
||||
confidence: 'high',
|
||||
completeness: 'complete',
|
||||
source: 'reported',
|
||||
},
|
||||
latest_indexed_block: {
|
||||
block_number: 10,
|
||||
timestamp: '2026-04-11T07:00:00Z',
|
||||
age_seconds: 1,
|
||||
},
|
||||
latest_indexed_transaction: {
|
||||
block_number: 9,
|
||||
timestamp: '2026-04-11T06:59:50Z',
|
||||
age_seconds: 11,
|
||||
confidence: 'high',
|
||||
completeness: 'partial',
|
||||
source: 'reported',
|
||||
},
|
||||
latest_non_empty_block: {
|
||||
block_number: 9,
|
||||
timestamp: '2026-04-11T06:59:50Z',
|
||||
age_seconds: 11,
|
||||
distance_from_head: 1,
|
||||
},
|
||||
},
|
||||
completeness: null,
|
||||
sampling: null,
|
||||
},
|
||||
{
|
||||
data: {
|
||||
mode: {
|
||||
kind: 'snapshot',
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).toEqual([
|
||||
'Head: directly reported',
|
||||
'Transactions: partial visibility',
|
||||
'Feed: snapshot',
|
||||
])
|
||||
})
|
||||
})
|
||||
104
frontend/src/utils/explorerFreshness.ts
Normal file
104
frontend/src/utils/explorerFreshness.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Block } from '@/services/api/blocks'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import {
|
||||
normalizeExplorerStats,
|
||||
type ExplorerFreshnessReference,
|
||||
type ExplorerFreshnessSnapshot,
|
||||
type ExplorerStats,
|
||||
} from '@/services/api/stats'
|
||||
import type { ChainActivityContext } from '@/utils/activityContext'
|
||||
|
||||
export function resolveEffectiveFreshness(
|
||||
stats: ExplorerStats | null | undefined,
|
||||
bridgeStatus: MissionControlBridgeStatusResponse | null | undefined,
|
||||
): ExplorerFreshnessSnapshot | null {
|
||||
if (stats?.freshness) {
|
||||
return stats.freshness
|
||||
}
|
||||
|
||||
const missionFreshness = bridgeStatus?.data?.freshness
|
||||
if (!missionFreshness || typeof missionFreshness !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
return normalizeExplorerStats({
|
||||
freshness: missionFreshness as Record<string, unknown>,
|
||||
}).freshness
|
||||
}
|
||||
|
||||
export function resolveFreshnessSourceLabel(
|
||||
stats: ExplorerStats | null | undefined,
|
||||
bridgeStatus: MissionControlBridgeStatusResponse | null | undefined,
|
||||
): string {
|
||||
if (stats?.freshness) {
|
||||
return 'Based on public stats and indexed explorer freshness.'
|
||||
}
|
||||
|
||||
if (bridgeStatus?.data?.freshness) {
|
||||
return 'Based on mission-control freshness and latest visible public data.'
|
||||
}
|
||||
|
||||
return 'Based on the latest visible public explorer data.'
|
||||
}
|
||||
|
||||
export function summarizeFreshnessConfidence(
|
||||
stats: ExplorerStats | null | undefined,
|
||||
bridgeStatus: MissionControlBridgeStatusResponse | null | undefined,
|
||||
): string[] {
|
||||
const effectiveFreshness = resolveEffectiveFreshness(stats, bridgeStatus)
|
||||
if (!effectiveFreshness) {
|
||||
return ['Freshness: unavailable']
|
||||
}
|
||||
|
||||
const chainConfidence = describeFreshnessReference('Head', effectiveFreshness.chain_head)
|
||||
const txConfidence = describeFreshnessReference('Transactions', effectiveFreshness.latest_indexed_transaction)
|
||||
const snapshotMode = bridgeStatus?.data?.mode?.kind || null
|
||||
|
||||
return [
|
||||
chainConfidence,
|
||||
txConfidence,
|
||||
snapshotMode ? `Feed: ${snapshotMode}` : 'Feed: direct',
|
||||
]
|
||||
}
|
||||
|
||||
function describeFreshnessReference(label: string, reference: ExplorerFreshnessReference): string {
|
||||
const completeness = String(reference.completeness || '').toLowerCase()
|
||||
const confidence = String(reference.confidence || '').toLowerCase()
|
||||
const source = String(reference.source || '').toLowerCase()
|
||||
|
||||
if (source === 'unavailable' || completeness === 'unavailable') {
|
||||
return `${label}: unavailable`
|
||||
}
|
||||
|
||||
if (completeness === 'partial') {
|
||||
return `${label}: partial visibility`
|
||||
}
|
||||
|
||||
if (confidence === 'high') {
|
||||
return `${label}: directly reported`
|
||||
}
|
||||
|
||||
if (confidence === 'medium') {
|
||||
return `${label}: reported sample`
|
||||
}
|
||||
|
||||
if (confidence === 'low') {
|
||||
return `${label}: limited confidence`
|
||||
}
|
||||
|
||||
return `${label}: reported`
|
||||
}
|
||||
|
||||
export function shouldExplainEmptyHeadBlocks(
|
||||
blocks: Pick<Block, 'transaction_count'>[],
|
||||
context: ChainActivityContext,
|
||||
): boolean {
|
||||
if (!Array.isArray(blocks) || blocks.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const visibleBlocks = blocks.slice(0, Math.min(blocks.length, 5))
|
||||
const allVisibleBlocksEmpty = visibleBlocks.every((block) => Number(block.transaction_count || 0) === 0)
|
||||
|
||||
return allVisibleBlocksEmpty && Boolean(context.head_is_idle && (context.block_gap_to_latest_transaction || 0) > 0)
|
||||
}
|
||||
@@ -68,3 +68,17 @@ export function formatTimestamp(value?: string | null): string {
|
||||
}
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
export function formatRelativeAge(value?: string | null): string {
|
||||
if (!value) return 'Unknown'
|
||||
const parsed = Date.parse(value)
|
||||
if (!Number.isFinite(parsed)) return 'Unknown'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
|
||||
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)
|
||||
if (hours < 48) return `${hours}h ago`
|
||||
const days = Math.round(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user