diff --git a/frontend/src/components/common/FreshnessTrustNote.tsx b/frontend/src/components/common/FreshnessTrustNote.tsx index 5e8b87c..99c496e 100644 --- a/frontend/src/components/common/FreshnessTrustNote.tsx +++ b/frontend/src/components/common/FreshnessTrustNote.tsx @@ -52,6 +52,11 @@ function buildDetail(context: ChainActivityContext, diagnosticExplanation?: stri return `Latest visible transaction: ${latestTxAge}. Recent head blocks may be quiet even while the chain remains current.` } +function normalizeSentence(value?: string | null): string { + if (!value) return '' + return value.trim().replace(/[.\s]+$/, '') +} + export default function FreshnessTrustNote({ context, stats, @@ -96,7 +101,9 @@ export default function FreshnessTrustNote({
{buildSummary(context)}
- {buildDetail(context, diagnosticExplanation)} {scopeLabel ? `${scopeLabel}. ` : ''}{sourceLabel} + {normalizeSentence(buildDetail(context, diagnosticExplanation))}.{' '} + {scopeLabel ? `${normalizeSentence(scopeLabel)}. ` : ''} + {normalizeSentence(sourceLabel)}.
{confidenceBadges.map((badge) => ( diff --git a/frontend/src/components/common/SubsystemPosturePanel.tsx b/frontend/src/components/common/SubsystemPosturePanel.tsx new file mode 100644 index 0000000..0f21188 --- /dev/null +++ b/frontend/src/components/common/SubsystemPosturePanel.tsx @@ -0,0 +1,127 @@ +import { Card } from '@/libs/frontend-ui-primitives' +import { useUiMode } from './UiModeContext' +import type { MissionControlSubsystemStatus } from '@/services/api/missionControl' +import { formatRelativeAge } from '@/utils/format' + +function subsystemLabel(key: string): string { + const labels: Record = { + rpc_head: 'RPC head', + tx_index: 'Transaction index', + stats_summary: 'Stats summary', + bridge_relay_monitoring: 'Bridge relay monitoring', + freshness_queries: 'Freshness queries', + } + + return labels[key] || key.replace(/_/g, ' ') +} + +function normalizeStatus(status?: string | null): string { + const normalized = String(status || '').toLowerCase() + if (!normalized) return 'unknown' + if (normalized === 'ok') return 'operational' + return normalized +} + +function statusClasses(status?: string | null): string { + const normalized = normalizeStatus(status) + if (['degraded', 'down', 'stale'].includes(normalized)) { + return 'border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-200' + } + if (['warning', 'partial', 'paused'].includes(normalized)) { + return 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-200' + } + if (normalized === 'operational') { + return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200' + } + return 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900/50 dark:text-gray-200' +} + +function compactMeta(subsystem: MissionControlSubsystemStatus): string { + const parts = [ + subsystem.source || null, + subsystem.completeness || null, + subsystem.confidence || null, + ].filter(Boolean) + + return parts.length > 0 ? parts.join(' · ') : 'No supporting freshness metadata' +} + +export default function SubsystemPosturePanel({ + subsystems, + title = 'Subsystem Posture', + scopeLabel, + preferredKeys, + className = '', +}: { + subsystems?: Record | null + title?: string + scopeLabel?: string + preferredKeys?: string[] + className?: string +}) { + const { mode } = useUiMode() + + const orderedEntries = Object.entries(subsystems || {}) + .filter(([key]) => !preferredKeys || preferredKeys.includes(key)) + .sort(([leftKey], [rightKey]) => { + const leftIndex = preferredKeys ? preferredKeys.indexOf(leftKey) : -1 + const rightIndex = preferredKeys ? preferredKeys.indexOf(rightKey) : -1 + if (leftIndex !== -1 || rightIndex !== -1) { + return (leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex) - (rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex) + } + return leftKey.localeCompare(rightKey) + }) + + if (orderedEntries.length === 0) { + return null + } + + const normalizedClassName = className ? ` ${className}` : '' + + return ( + +
+
+ {scopeLabel || 'These subsystem signals come from the same backend freshness model used to explain chain and transaction visibility.'} +
+ +
+ {orderedEntries.map(([key, subsystem]) => { + const status = normalizeStatus(subsystem.status) + return ( +
+
+
+
+ {subsystemLabel(key)} +
+
+ {status} +
+
+ + {status} + +
+
+ {subsystem.updated_at ? `Updated ${formatRelativeAge(subsystem.updated_at)}` : 'Update time unavailable'} +
+
+ {compactMeta(subsystem)} +
+ {mode === 'guided' && subsystem.provenance ? ( +
+ Provenance: {subsystem.provenance.replace(/_/g, ' ')} +
+ ) : null} +
+ ) + })} +
+
+
+ ) +} diff --git a/frontend/src/components/explorer/AnalyticsOperationsPage.tsx b/frontend/src/components/explorer/AnalyticsOperationsPage.tsx index f4efe58..c8a7e04 100644 --- a/frontend/src/components/explorer/AnalyticsOperationsPage.tsx +++ b/frontend/src/components/explorer/AnalyticsOperationsPage.tsx @@ -19,6 +19,7 @@ import { formatWeiAsEth } from '@/utils/format' import { summarizeChainActivity } from '@/utils/activityContext' import ActivityContextPanel from '@/components/common/ActivityContextPanel' import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness' import OperationsPageShell, { MetricCard, @@ -155,6 +156,13 @@ export default function AnalyticsOperationsPage({ bridgeStatus={bridgeStatus} scopeLabel="This page combines public stats, recent block samples, and indexed transactions." /> +
diff --git a/frontend/src/components/explorer/BridgeMonitoringPage.tsx b/frontend/src/components/explorer/BridgeMonitoringPage.tsx index 79dc35c..ea7c803 100644 --- a/frontend/src/components/explorer/BridgeMonitoringPage.tsx +++ b/frontend/src/components/explorer/BridgeMonitoringPage.tsx @@ -14,6 +14,7 @@ import { explorerFeaturePages } from '@/data/explorerOperations' import { summarizeChainActivity } from '@/utils/activityContext' import ActivityContextPanel from '@/components/common/ActivityContextPanel' import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' type FeedState = 'connecting' | 'live' | 'fallback' @@ -288,6 +289,13 @@ export default function BridgeMonitoringPage({ bridgeStatus={bridgeStatus} scopeLabel="Bridge relay posture is shown alongside the same explorer freshness model used on the homepage and core explorer routes" /> +
diff --git a/frontend/src/components/explorer/LiquidityOperationsPage.tsx b/frontend/src/components/explorer/LiquidityOperationsPage.tsx index a45916f..beaab0a 100644 --- a/frontend/src/components/explorer/LiquidityOperationsPage.tsx +++ b/frontend/src/components/explorer/LiquidityOperationsPage.tsx @@ -20,6 +20,7 @@ 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 SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' import { formatCurrency, @@ -277,6 +278,13 @@ export default function LiquidityOperationsPage({ bridgeStatus={bridgeStatus} scopeLabel="Liquidity inventory and planner posture are shown alongside the same explorer freshness model used on the homepage and core explorer routes" /> +
diff --git a/frontend/src/components/explorer/OperationsHubPage.tsx b/frontend/src/components/explorer/OperationsHubPage.tsx index 3fff1fe..342b5f9 100644 --- a/frontend/src/components/explorer/OperationsHubPage.tsx +++ b/frontend/src/components/explorer/OperationsHubPage.tsx @@ -9,6 +9,7 @@ import { useUiMode } from '@/components/common/UiModeContext' import { summarizeChainActivity } from '@/utils/activityContext' import ActivityContextPanel from '@/components/common/ActivityContextPanel' import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' import { statsApi, type ExplorerStats } from '@/services/api/stats' @@ -203,6 +204,13 @@ export default function OperationsHubPage({ bridgeStatus={bridgeStatus} scopeLabel="This page reflects mission-control freshness, public bridge status, and explorer-served config surfaces." /> +
diff --git a/frontend/src/components/explorer/RoutesMonitoringPage.tsx b/frontend/src/components/explorer/RoutesMonitoringPage.tsx index 9b1f3f8..c9653dd 100644 --- a/frontend/src/components/explorer/RoutesMonitoringPage.tsx +++ b/frontend/src/components/explorer/RoutesMonitoringPage.tsx @@ -14,6 +14,7 @@ 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 SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' interface RoutesMonitoringPageProps { @@ -238,6 +239,13 @@ export default function RoutesMonitoringPage({ bridgeStatus={bridgeStatus} scopeLabel="Route availability reflects the current public route matrix and the same explorer freshness model used on the core explorer pages" /> +
diff --git a/frontend/src/components/wallet/WalletPage.tsx b/frontend/src/components/wallet/WalletPage.tsx index 562201e..401dbc2 100644 --- a/frontend/src/components/wallet/WalletPage.tsx +++ b/frontend/src/components/wallet/WalletPage.tsx @@ -10,7 +10,14 @@ 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 { + addressesApi, + type AddressInfo, + type AddressTokenBalance, + type AddressTokenTransfer, + type TransactionSummary, +} from '@/services/api/addresses' +import { formatRelativeAge, formatTokenAmount } from '@/utils/format' import { isWatchlistEntry, readWatchlistFromStorage, @@ -42,6 +49,8 @@ export default function WalletPage(props: WalletPageProps) { const [watchlistEntries, setWatchlistEntries] = useState([]) const [addressInfo, setAddressInfo] = useState(null) const [recentAddressTransactions, setRecentAddressTransactions] = useState([]) + const [tokenBalances, setTokenBalances] = useState([]) + const [tokenTransfers, setTokenTransfers] = useState([]) useEffect(() => { if (typeof window === 'undefined') return @@ -107,6 +116,8 @@ export default function WalletPage(props: WalletPageProps) { if (!walletSession?.address) { setAddressInfo(null) setRecentAddressTransactions([]) + setTokenBalances([]) + setTokenTransfers([]) return () => { cancelled = true } @@ -115,16 +126,41 @@ export default function WalletPage(props: WalletPageProps) { Promise.all([ addressesApi.getSafe(138, walletSession.address), addressesApi.getTransactionsSafe(138, walletSession.address, 1, 3), + addressesApi.getTokenBalancesSafe(walletSession.address), + addressesApi.getTokenTransfersSafe(walletSession.address, 1, 4), ]) - .then(([infoResponse, transactionsResponse]) => { + .then(([infoResponse, transactionsResponse, balancesResponse, transfersResponse]) => { if (cancelled) return setAddressInfo(infoResponse.ok ? infoResponse.data : null) setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : []) + setTokenBalances( + balancesResponse.ok + ? [...balancesResponse.data] + .filter((balance) => { + try { + return BigInt(balance.value || '0') > 0n + } catch { + return Boolean(balance.value) + } + }) + .sort((left, right) => { + try { + return Number(BigInt(right.value || '0') - BigInt(left.value || '0')) + } catch { + return 0 + } + }) + .slice(0, 4) + : [], + ) + setTokenTransfers(transfersResponse.ok ? transfersResponse.data : []) }) .catch(() => { if (cancelled) return setAddressInfo(null) setRecentAddressTransactions([]) + setTokenBalances([]) + setTokenTransfers([]) }) return () => { @@ -320,6 +356,108 @@ export default function WalletPage(props: WalletPageProps) { )) )}
+ +
+
+
+
+
+ Visible Token Balances +
+
+ {mode === 'guided' + ? 'These are the first visible non-zero token balances currently indexed for your connected wallet.' + : 'Indexed non-zero token balances for this wallet.'} +
+
+ + Full address detail → + +
+ +
+ {tokenBalances.length === 0 ? ( +
+ No indexed token balances are currently visible for this wallet. +
+ ) : ( + tokenBalances.map((balance) => ( + +
+
+
+ {balance.token_symbol || balance.token_name || 'Token'} +
+
+ {balance.token_type || 'token'} · {balance.token_name || balance.token_address} +
+
+
+ {formatTokenAmount(balance.value, balance.token_decimals, balance.token_symbol, 6)} +
+
+ + )) + )} +
+
+ +
+
+ Recent Token Transfers +
+
+ {mode === 'guided' + ? 'Use these token transfers to jump directly into recent visible asset movement for the connected wallet.' + : 'Recent indexed token transfer activity for this wallet.'} +
+ +
+ {tokenTransfers.length === 0 ? ( +
+ No recent token transfers are currently visible for this connected wallet. +
+ ) : ( + tokenTransfers.map((transfer) => { + const incoming = transfer.to_address.toLowerCase() === walletSession.address.toLowerCase() + const counterparty = incoming ? transfer.from_address : transfer.to_address + return ( +
+
+
+
+ {incoming ? 'Incoming' : 'Outgoing'} {transfer.token_symbol || 'token'} transfer +
+
+ {formatTokenAmount(transfer.value, transfer.token_decimals, transfer.token_symbol, 6)} +
+
+ Counterparty: {counterparty.slice(0, 6)}...{counterparty.slice(-4)} · {formatRelativeAge(transfer.timestamp)} +
+
+
+ + Open tx → + + + Counterparty → + +
+
+
+ ) + }) + )} +
+
+
) : null}