feat(freshness): enhance diagnostics and update snapshot structure

- Introduced a new Diagnostics struct to capture transaction visibility state and activity state.
- Updated BuildSnapshot function to return diagnostics alongside snapshot, completeness, and sampling.
- Enhanced test cases to validate the new diagnostics data.
- Updated frontend components to utilize the new diagnostics information for improved user feedback on freshness context.

This change improves the observability of transaction activity and enhances the user experience by providing clearer insights into the freshness of data.
This commit is contained in:
defiQUG
2026-04-12 18:22:08 -07:00
parent 50c5435c0c
commit b5a2e0c0a4
34 changed files with 1328 additions and 165 deletions

View File

@@ -6,6 +6,7 @@ import {
summarizeFreshnessConfidence,
} from '@/utils/explorerFreshness'
import { formatRelativeAge } from '@/utils/format'
import { useUiMode } from './UiModeContext'
function buildSummary(context: ChainActivityContext) {
if (context.transaction_visibility_unavailable) {
@@ -27,7 +28,11 @@ function buildSummary(context: ChainActivityContext) {
return 'Freshness context is based on the latest visible public explorer evidence.'
}
function buildDetail(context: ChainActivityContext) {
function buildDetail(context: ChainActivityContext, diagnosticExplanation?: string | null) {
if (diagnosticExplanation) {
return diagnosticExplanation
}
if (context.transaction_visibility_unavailable) {
return 'Use chain-head visibility and the last non-empty block as the current trust anchors.'
}
@@ -60,15 +65,38 @@ export default function FreshnessTrustNote({
scopeLabel?: string
className?: string
}) {
const { mode } = useUiMode()
const sourceLabel = resolveFreshnessSourceLabel(stats, bridgeStatus)
const confidenceBadges = summarizeFreshnessConfidence(stats, bridgeStatus)
const diagnosticExplanation = stats?.diagnostics?.explanation || bridgeStatus?.data?.diagnostics?.explanation || null
const normalizedClassName = className ? ` ${className}` : ''
if (mode === 'expert') {
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="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context)}</div>
<div className="text-xs text-gray-600 dark:text-gray-400">{sourceLabel}</div>
</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>
)
}
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}
{buildDetail(context, diagnosticExplanation)} {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) => (

View File

@@ -133,6 +133,7 @@ export default function AnalyticsOperationsPage({
latestBlockNumber: stats?.latest_block ?? blocks[0]?.number ?? null,
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
}),
[blocks, bridgeStatus, stats, transactions],
)

View File

@@ -9,7 +9,12 @@ import {
type MissionControlRelayPayload,
type MissionControlRelaySnapshot,
} from '@/services/api/missionControl'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
import { explorerFeaturePages } from '@/data/explorerOperations'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
type FeedState = 'connecting' | 'live' | 'fallback'
@@ -61,6 +66,9 @@ function relayPolicyCue(snapshot: MissionControlRelaySnapshot | null): string |
if (snapshot.last_error?.scope === 'bridge_inventory') {
return 'Queued release waiting on bridge inventory'
}
if (snapshot.last_error?.scope === 'bridge_inventory_probe') {
return 'Bridge inventory check is temporarily unavailable'
}
if (String(snapshot.status || '').toLowerCase() === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
return 'Delivery disabled by policy'
}
@@ -130,10 +138,13 @@ function ActionLink({
export default function BridgeMonitoringPage({
initialBridgeStatus = null,
initialStats = null,
}: {
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
initialStats?: ExplorerStats | null
}) {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [feedState, setFeedState] = useState<FeedState>(initialBridgeStatus ? 'fallback' : 'connecting')
const page = explorerFeaturePages.bridge
@@ -142,9 +153,15 @@ export default function BridgeMonitoringPage({
const loadSnapshot = async () => {
try {
const snapshot = await missionControlApi.getBridgeStatus()
const [snapshot, latestStats] = await Promise.all([
missionControlApi.getBridgeStatus(),
statsApi.get().catch(() => null),
])
if (!cancelled) {
setBridgeStatus(snapshot)
if (latestStats) {
setStats(latestStats)
}
}
} catch (error) {
if (!cancelled && process.env.NODE_ENV !== 'production') {
@@ -178,6 +195,19 @@ export default function BridgeMonitoringPage({
}
}, [])
const activityContext = useMemo(
() =>
summarizeChainActivity({
blocks: [],
transactions: [],
latestBlockNumber: stats?.latest_block,
latestBlockTimestamp: null,
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
}),
[bridgeStatus, stats],
)
const relayLanes = useMemo((): RelayLaneCard[] => {
const relays = getMissionControlRelays(bridgeStatus)
if (!relays) return []
@@ -191,7 +221,12 @@ export default function BridgeMonitoringPage({
return {
key,
label: getMissionControlRelayLabel(key),
status: snapshot?.last_error?.scope === 'bridge_inventory' ? 'underfunded' : status,
status:
snapshot?.last_error?.scope === 'bridge_inventory'
? 'underfunded'
: snapshot?.last_error?.scope === 'bridge_inventory_probe'
? 'warning'
: status,
profile: snapshot?.service?.profile || key,
sourceChain: snapshot?.source?.chain_name || 'Unknown',
destinationChain: snapshot?.destination?.chain_name || 'Unknown',
@@ -244,6 +279,17 @@ export default function BridgeMonitoringPage({
</Card>
) : null}
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Bridge Freshness Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel="Bridge relay posture is shown alongside the same explorer freshness model used on the homepage and core explorer routes"
/>
</div>
<div className="mb-6 grid gap-4 lg:grid-cols-3">
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">

View File

@@ -15,6 +15,12 @@ import {
} from '@/services/api/liquidity'
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } from '@/services/api/routes'
import { missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import {
formatCurrency,
formatNumber,
@@ -43,6 +49,8 @@ interface LiquidityOperationsPageProps {
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
initialInternalPlan?: InternalExecutionPlanResponse | null
initialTokenPoolRecords?: TokenPoolRecord[]
initialStats?: ExplorerStats | null
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
}
function routePairLabel(routeId: string, routeLabel: string, tokenIn?: string, tokenOut?: string): string {
@@ -55,12 +63,16 @@ export default function LiquidityOperationsPage({
initialPlannerCapabilities = null,
initialInternalPlan = null,
initialTokenPoolRecords = [],
initialStats = null,
initialBridgeStatus = null,
}: LiquidityOperationsPageProps) {
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>(initialTokenPoolRecords)
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [loadingError, setLoadingError] = useState<string | null>(null)
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
@@ -72,7 +84,9 @@ export default function LiquidityOperationsPage({
initialRouteMatrix &&
initialPlannerCapabilities &&
initialInternalPlan &&
initialTokenPoolRecords.length > 0
initialTokenPoolRecords.length > 0 &&
initialStats &&
initialBridgeStatus
) {
return () => {
cancelled = true
@@ -80,12 +94,14 @@ export default function LiquidityOperationsPage({
}
const load = async () => {
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult] =
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult, statsResult, bridgeResult] =
await Promise.allSettled([
configApi.getTokenList(),
routesApi.getRouteMatrix(),
plannerApi.getCapabilities(),
plannerApi.getInternalExecutionPlan(),
statsApi.get(),
missionControlApi.getBridgeStatus(),
])
if (cancelled) return
@@ -94,6 +110,8 @@ export default function LiquidityOperationsPage({
if (routeMatrixResult.status === 'fulfilled') setRouteMatrix(routeMatrixResult.value)
if (plannerCapabilitiesResult.status === 'fulfilled') setPlannerCapabilities(plannerCapabilitiesResult.value)
if (planResult.status === 'fulfilled') setInternalPlan(planResult.value)
if (statsResult.status === 'fulfilled') setStats(statsResult.value)
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
if (tokenListResult.status === 'fulfilled') {
const featuredTokens = selectFeaturedLiquidityTokens(tokenListResult.value.tokens || [])
@@ -113,14 +131,10 @@ export default function LiquidityOperationsPage({
}
}
const failedCount = [
tokenListResult,
routeMatrixResult,
plannerCapabilitiesResult,
planResult,
].filter((result) => result.status === 'rejected').length
const results = [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult, statsResult, bridgeResult] as const
const failedCount = results.filter((result) => result.status === 'rejected').length
if (failedCount === 4) {
if (failedCount === results.length) {
setLoadingError('Live liquidity data is temporarily unavailable from the public explorer APIs.')
}
}
@@ -137,9 +151,11 @@ export default function LiquidityOperationsPage({
cancelled = true
}
}, [
initialBridgeStatus,
initialInternalPlan,
initialPlannerCapabilities,
initialRouteMatrix,
initialStats,
initialTokenList,
initialTokenPoolRecords,
])
@@ -168,6 +184,18 @@ export default function LiquidityOperationsPage({
() => new Set(aggregatedPools.map((pool) => pool.dex).filter(Boolean)).size,
[aggregatedPools]
)
const activityContext = useMemo(
() =>
summarizeChainActivity({
blocks: [],
transactions: [],
latestBlockNumber: stats?.latest_block,
latestBlockTimestamp: null,
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
}),
[bridgeStatus, stats],
)
const insightLines = useMemo(
() => [
@@ -240,6 +268,17 @@ export default function LiquidityOperationsPage({
</Card>
) : null}
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Liquidity Freshness Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel="Liquidity inventory and planner posture are shown alongside the same explorer freshness model used on the homepage and core explorer routes"
/>
</div>
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">

View File

@@ -10,6 +10,7 @@ import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
function relativeAge(isoString?: string): string {
if (!isoString) return 'Unknown'
@@ -56,6 +57,7 @@ interface OperationsHubPageProps {
initialNetworksConfig?: NetworksConfigResponse | null
initialTokenList?: TokenListResponse | null
initialCapabilities?: CapabilitiesResponse | null
initialStats?: ExplorerStats | null
}
export default function OperationsHubPage({
@@ -64,6 +66,7 @@ export default function OperationsHubPage({
initialNetworksConfig = null,
initialTokenList = null,
initialCapabilities = null,
initialStats = null,
}: OperationsHubPageProps) {
const { mode } = useUiMode()
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
@@ -71,6 +74,7 @@ export default function OperationsHubPage({
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(initialNetworksConfig)
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(initialCapabilities)
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.operations
@@ -78,13 +82,14 @@ export default function OperationsHubPage({
let cancelled = false
const load = async () => {
const [bridgeResult, routesResult, networksResult, tokenListResult, capabilitiesResult] =
const [bridgeResult, routesResult, networksResult, tokenListResult, capabilitiesResult, statsResult] =
await Promise.allSettled([
missionControlApi.getBridgeStatus(),
routesApi.getRouteMatrix(),
configApi.getNetworks(),
configApi.getTokenList(),
configApi.getCapabilities(),
statsApi.get(),
])
if (cancelled) return
@@ -94,6 +99,7 @@ export default function OperationsHubPage({
if (networksResult.status === 'fulfilled') setNetworksConfig(networksResult.value)
if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value)
if (capabilitiesResult.status === 'fulfilled') setCapabilities(capabilitiesResult.value)
if (statsResult.status === 'fulfilled') setStats(statsResult.value)
const failedCount = [
bridgeResult,
@@ -101,9 +107,10 @@ export default function OperationsHubPage({
networksResult,
tokenListResult,
capabilitiesResult,
statsResult,
].filter((result) => result.status === 'rejected').length
if (failedCount === 5) {
if (failedCount === 6) {
setLoadingError('Public explorer operations data is temporarily unavailable.')
}
}
@@ -153,9 +160,10 @@ export default function OperationsHubPage({
? Number(bridgeStatus.data.chains['138'].block_number)
: null,
latestBlockTimestamp: null,
freshness: resolveEffectiveFreshness(null, bridgeStatus),
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
}),
[bridgeStatus],
[bridgeStatus, stats],
)
return (
@@ -191,6 +199,7 @@ export default function OperationsHubPage({
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel="This page reflects mission-control freshness, public bridge status, and explorer-served config surfaces."
/>

View File

@@ -9,11 +9,19 @@ import {
type RouteMatrixRoute,
type RouteMatrixResponse,
} from '@/services/api/routes'
import { missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
interface RoutesMonitoringPageProps {
initialRouteMatrix?: RouteMatrixResponse | null
initialNetworks?: ExplorerNetwork[]
initialPools?: MissionControlLiquidityPool[]
initialStats?: ExplorerStats | null
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
}
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
@@ -90,10 +98,14 @@ export default function RoutesMonitoringPage({
initialRouteMatrix = null,
initialNetworks = [],
initialPools = [],
initialStats = null,
initialBridgeStatus = null,
}: RoutesMonitoringPageProps) {
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [networks, setNetworks] = useState<ExplorerNetwork[]>(initialNetworks)
const [pools, setPools] = useState<MissionControlLiquidityPool[]>(initialPools)
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.routes
@@ -101,10 +113,12 @@ export default function RoutesMonitoringPage({
let cancelled = false
const load = async () => {
const [matrixResult, networksResult, poolsResult] = await Promise.allSettled([
const [matrixResult, networksResult, poolsResult, statsResult, bridgeResult] = await Promise.allSettled([
routesApi.getRouteMatrix(),
routesApi.getNetworks(),
routesApi.getTokenPools(canonicalLiquidityToken),
statsApi.get(),
missionControlApi.getBridgeStatus(),
])
if (cancelled) return
@@ -118,11 +132,19 @@ export default function RoutesMonitoringPage({
if (poolsResult.status === 'fulfilled') {
setPools(poolsResult.value.pools || [])
}
if (statsResult.status === 'fulfilled') {
setStats(statsResult.value)
}
if (bridgeResult.status === 'fulfilled') {
setBridgeStatus(bridgeResult.value)
}
if (
matrixResult.status === 'rejected' &&
networksResult.status === 'rejected' &&
poolsResult.status === 'rejected'
poolsResult.status === 'rejected' &&
statsResult.status === 'rejected' &&
bridgeResult.status === 'rejected'
) {
setLoadingError('Live route inventory is temporarily unavailable.')
}
@@ -166,6 +188,18 @@ export default function RoutesMonitoringPage({
.filter((network) => [138, 1, 651940, 56, 43114].includes(network.chainIdDecimal || 0))
.sort((left, right) => (left.chainIdDecimal || 0) - (right.chainIdDecimal || 0))
}, [networks])
const activityContext = useMemo(
() =>
summarizeChainActivity({
blocks: [],
transactions: [],
latestBlockNumber: stats?.latest_block,
latestBlockTimestamp: null,
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
}),
[bridgeStatus, stats],
)
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
@@ -195,6 +229,17 @@ export default function RoutesMonitoringPage({
</Card>
) : null}
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Routes Freshness Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel="Route availability reflects the current public route matrix and the same explorer freshness model used on the core explorer pages"
/>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">

View File

@@ -88,6 +88,10 @@ function formatGasPriceGwei(value: number) {
return `${value.toFixed(3)} gwei`
}
function compactStatNote(guided: string, expert: string, mode: 'guided' | 'expert') {
return mode === 'guided' ? guided : expert
}
export default function Home({
initialStats = null,
initialRecentBlocks = [],
@@ -311,6 +315,7 @@ export default function Home({
latestBlockNumber: latestBlock,
latestBlockTimestamp: recentBlocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
})
const txCompleteness = stats?.completeness?.transactions_feed || bridgeStatus?.data?.subsystems?.tx_index?.completeness || null
const blockCompleteness = stats?.completeness?.blocks_feed || null
@@ -358,6 +363,56 @@ export default function Home({
const missionCollapsedSummary = relaySummary
? `${missionHeadline} · ${relayOperationalCount} operational`
: `${missionHeadline}${chainStatus?.status ? ` · chain 138 ${chainStatus.status}` : ''}`
const primaryMetricCards = [
{
label: 'Latest Block',
value: latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable',
note: activityContext.latest_block_timestamp
? compactStatNote(
`Head freshness ${formatRelativeAge(activityContext.latest_block_timestamp)}${blockCompleteness ? ` · ${blockCompleteness}` : ''}`,
formatRelativeAge(activityContext.latest_block_timestamp),
mode,
)
: compactStatNote('Head freshness unavailable.', 'Unavailable', mode),
},
{
label: 'Total Blocks',
value: stats ? stats.total_blocks.toLocaleString() : 'Unavailable',
note: compactStatNote('Visible public explorer block count.', 'Explorer block count', mode),
},
{
label: 'Total Transactions',
value: stats ? stats.total_transactions.toLocaleString() : 'Unavailable',
note: compactStatNote('Visible indexed explorer transaction count.', 'Indexed tx count', mode),
},
{
label: 'Total Addresses',
value: stats ? stats.total_addresses.toLocaleString() : 'Unavailable',
note: compactStatNote('Current public explorer address count.', 'Address count', mode),
},
]
const secondaryMetricCards = [
{
label: 'Avg Block Time',
value: avgBlockTimeSummary.value,
note: compactStatNote(avgBlockTimeSummary.note, averageBlockTimeSeconds != null ? 'Reported' : 'Unavailable', mode),
},
{
label: 'Avg Gas Price',
value: avgGasPriceSummary.value,
note: compactStatNote(avgGasPriceSummary.note, averageGasPriceGwei != null ? 'Reported' : 'Unavailable', mode),
},
{
label: 'Transactions Today',
value: transactionsTodaySummary.value,
note: compactStatNote(transactionsTodaySummary.note, transactionsToday != null ? 'Reported' : 'Unavailable', mode),
},
{
label: 'Network Utilization',
value: networkUtilizationSummary.value,
note: compactStatNote(networkUtilizationSummary.note, networkUtilization != null ? 'Latest stats sample' : 'Unavailable', mode),
},
]
useEffect(() => {
setRelayPage(1)
@@ -617,64 +672,63 @@ export default function Home({
)}
{stats && (
<div className="mb-8 grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Latest Block</div>
<div className="text-xl font-bold sm:text-2xl">
{latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'}
<div className="mb-8 space-y-4">
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
{primaryMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</Card>
))}
</div>
{mode === 'guided' ? (
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
{secondaryMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</Card>
))}
</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>
) : (
<Card>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">Telemetry Snapshot</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Secondary public stats in a denser expert layout.
</div>
</div>
<div className="grid flex-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{secondaryMetricCards.map((card) => (
<div key={card.label} className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{card.label}</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{card.value}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</div>
))}
</div>
</div>
</Card>
)}
</div>
)}
<div className="mb-8">
<ActivityContextPanel context={activityContext} />
<ActivityContextPanel context={activityContext} title={mode === 'guided' ? 'Chain Activity Context' : 'Freshness & Activity'} />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel="Homepage status combines chain freshness, transaction visibility, and mission-control posture."
scopeLabel={
mode === 'guided'
? 'Homepage status combines chain freshness, transaction visibility, and mission-control posture.'
: 'Homepage freshness view aligns chain, transaction, and mission-control posture.'
}
/>
</div>

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import type {
CapabilitiesCatalog,
FetchMetadata,
@@ -7,6 +8,15 @@ import type {
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
import Link from 'next/link'
import { Explain, useUiMode } from '@/components/common/UiModeContext'
import { accessApi, type WalletAccessSession } from '@/services/api/access'
import EntityBadge from '@/components/common/EntityBadge'
import { addressesApi, type AddressInfo, type TransactionSummary } from '@/services/api/addresses'
import {
isWatchlistEntry,
readWatchlistFromStorage,
toggleWatchlistEntry,
writeWatchlistToStorage,
} from '@/utils/watchlist'
interface WalletPageProps {
initialNetworks?: NetworksCatalog | null
@@ -17,8 +27,111 @@ interface WalletPageProps {
initialCapabilitiesMeta?: FetchMetadata | null
}
function shortAddress(value?: string | null): string {
if (!value) return 'Unknown'
if (value.length <= 14) return value
return `${value.slice(0, 6)}...${value.slice(-4)}`
}
export default function WalletPage(props: WalletPageProps) {
const { mode } = useUiMode()
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
const [connectingWallet, setConnectingWallet] = useState(false)
const [walletError, setWalletError] = useState<string | null>(null)
const [copiedAddress, setCopiedAddress] = useState(false)
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
const [recentAddressTransactions, setRecentAddressTransactions] = useState<TransactionSummary[]>([])
useEffect(() => {
if (typeof window === 'undefined') return
const syncSession = () => {
setWalletSession(accessApi.getStoredWalletSession())
}
const syncWatchlist = () => {
setWatchlistEntries(readWatchlistFromStorage(window.localStorage))
}
syncSession()
syncWatchlist()
window.addEventListener('explorer-access-session-changed', syncSession)
window.addEventListener('storage', syncWatchlist)
return () => {
window.removeEventListener('explorer-access-session-changed', syncSession)
window.removeEventListener('storage', syncWatchlist)
}
}, [])
const handleConnectWallet = async () => {
setConnectingWallet(true)
setWalletError(null)
try {
const session = await accessApi.connectWalletSession()
setWalletSession(session)
} catch (error) {
setWalletError(error instanceof Error ? error.message : 'Wallet connection failed.')
} finally {
setConnectingWallet(false)
}
}
const handleDisconnectWallet = () => {
accessApi.clearSession()
accessApi.clearWalletSession()
setWalletSession(null)
}
const handleCopyAddress = async () => {
if (!walletSession?.address || typeof navigator === 'undefined' || !navigator.clipboard) return
await navigator.clipboard.writeText(walletSession.address)
setCopiedAddress(true)
window.setTimeout(() => setCopiedAddress(false), 1500)
}
const handleToggleWatchlist = () => {
if (!walletSession?.address || typeof window === 'undefined') return
const nextEntries = toggleWatchlistEntry(watchlistEntries, walletSession.address)
writeWatchlistToStorage(window.localStorage, nextEntries)
setWatchlistEntries(nextEntries)
}
const isSavedToWatchlist = walletSession?.address
? isWatchlistEntry(watchlistEntries, walletSession.address)
: false
useEffect(() => {
let cancelled = false
if (!walletSession?.address) {
setAddressInfo(null)
setRecentAddressTransactions([])
return () => {
cancelled = true
}
}
Promise.all([
addressesApi.getSafe(138, walletSession.address),
addressesApi.getTransactionsSafe(138, walletSession.address, 1, 3),
])
.then(([infoResponse, transactionsResponse]) => {
if (cancelled) return
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
})
.catch(() => {
if (cancelled) return
setAddressInfo(null)
setRecentAddressTransactions([])
})
return () => {
cancelled = true
}
}, [walletSession?.address])
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 Tools</h1>
@@ -27,6 +140,189 @@ export default function WalletPage(props: WalletPageProps) {
? '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>
<div className="mb-6 rounded-2xl border border-sky-200 bg-sky-50/60 p-5 dark:border-sky-900/40 dark:bg-sky-950/20">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-lg font-semibold text-gray-900 dark:text-white">Wallet session</div>
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
{walletSession
? mode === 'guided'
? 'This wallet is connected to the same account/access session used by the header. You can jump straight into your explorer address view or the access console from here.'
: 'Connected wallet session is active for explorer and access surfaces.'
: mode === 'guided'
? 'Connect a browser wallet to make this page useful beyond setup: copy your address, open your on-explorer address page, and continue into the access console with the same session.'
: 'Connect a wallet to activate account-linked explorer actions.'}
</p>
</div>
<div className="flex flex-wrap gap-2">
<EntityBadge label={walletSession ? 'connected' : 'disconnected'} tone={walletSession ? 'success' : 'neutral'} />
{walletSession ? <EntityBadge label={walletSession.track} tone="info" /> : null}
</div>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div className="rounded-2xl border border-white/60 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">Current wallet</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{walletSession ? shortAddress(walletSession.address) : 'No wallet connected'}
</div>
<div className="mt-1 break-all text-sm text-gray-600 dark:text-gray-400">
{walletSession?.address || 'Use Connect Wallet to start a browser-wallet session.'}
</div>
{walletSession?.expiresAt ? (
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Session expires {new Date(walletSession.expiresAt).toLocaleString()}
</div>
) : null}
</div>
<div className="rounded-2xl border border-white/60 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">Quick actions</div>
<div className="mt-3 flex flex-wrap gap-2">
{walletSession ? (
<>
<button
type="button"
onClick={handleCopyAddress}
className="rounded-lg border border-primary-300 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-primary-800 dark:text-primary-300 dark:hover:bg-primary-950/20"
>
{copiedAddress ? 'Address copied' : 'Copy address'}
</button>
<Link
href={`/addresses/${walletSession.address}`}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
>
Open address
</Link>
<Link
href="/access"
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
>
Open access console
</Link>
<button
type="button"
onClick={handleToggleWatchlist}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
>
{isSavedToWatchlist ? 'Remove from watchlist' : 'Save to watchlist'}
</button>
<Link
href="/watchlist"
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
>
Open watchlist
</Link>
<button
type="button"
onClick={handleDisconnectWallet}
className="rounded-lg border border-red-200 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 dark:border-red-900/60 dark:text-red-200 dark:hover:bg-red-950/30"
>
Disconnect wallet
</button>
</>
) : (
<>
<button
type="button"
onClick={handleConnectWallet}
disabled={connectingWallet}
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-70"
>
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
</button>
<Link
href="/access"
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
>
Open access console
</Link>
</>
)}
</div>
{walletError ? (
<div className="mt-3 text-sm text-red-700 dark:text-red-300">{walletError}</div>
) : null}
{walletSession ? (
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
{isSavedToWatchlist
? 'This wallet is already saved in the shared explorer watchlist.'
: 'Save this wallet into the shared explorer watchlist to revisit it from addresses and transaction workflows.'}
</div>
) : null}
</div>
</div>
{walletSession ? (
<div className="mt-4 rounded-2xl border border-white/60 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Connected Address Snapshot
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{mode === 'guided'
? 'A quick explorer view of the connected wallet so you can jump from connection into browsing and monitoring.'
: 'Current explorer snapshot for the connected wallet.'}
</div>
</div>
<Link href={`/addresses/${walletSession.address}`} className="text-sm font-medium text-primary-600 hover:underline">
Open full address page
</Link>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Transactions</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{addressInfo ? addressInfo.transaction_count.toLocaleString() : 'Unknown'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token Holdings</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{addressInfo ? addressInfo.token_count.toLocaleString() : 'Unknown'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Address Type</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{addressInfo ? (addressInfo.is_contract ? 'Contract' : 'EOA') : 'Unknown'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent Indexed Tx</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{recentAddressTransactions[0] ? `#${recentAddressTransactions[0].block_number}` : 'None visible'}
</div>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-3">
{recentAddressTransactions.length === 0 ? (
<div className="text-sm text-gray-600 dark:text-gray-400 lg:col-span-3">
No recent indexed transactions are currently visible for this connected wallet.
</div>
) : (
recentAddressTransactions.map((transaction) => (
<Link
key={transaction.hash}
href={`/transactions/${transaction.hash}`}
className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 text-sm hover:border-primary-300 hover:bg-primary-50/60 dark:border-gray-800 dark:bg-gray-900/40 dark:hover:border-primary-700 dark:hover:bg-primary-950/20"
>
<div className="font-semibold text-gray-900 dark:text-white">
{transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
</div>
<div className="mt-1 text-gray-600 dark:text-gray-400">
Block #{transaction.block_number.toLocaleString()}
</div>
</Link>
))
)}
</div>
</div>
) : null}
</div>
<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">
<Explain>