Freshness diagnostics API, UI trust notes, mission control/stats updates, and deploy scripts.

Made-with: Cursor
This commit is contained in:
defiQUG
2026-04-12 06:33:54 -07:00
parent f46bd213ba
commit ee71f098ab
63 changed files with 5163 additions and 826 deletions

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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

View 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}</>
}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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>
)

View File

@@ -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',

View File

@@ -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,
},
}
}

View File

@@ -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,
},
}
}

View File

@@ -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.',
},

View File

@@ -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,
},

View File

@@ -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">

View File

@@ -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,
},
}
}

View File

@@ -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
)
},
}

View File

@@ -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',
},
})
})

View File

@@ -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,
}
}

View 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,
}
}

View 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',
])
})
})

View 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)
}

View File

@@ -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`
}