diff --git a/.env.example b/.env.example index af9ab2d..fa2f775 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,8 @@ WEBSOCKET_ENABLED=true # Networks the realtime monitor polls (comma-separated: mainnet,testnet). # Only list networks you actually run a node for. WEBSOCKET_NETWORKS=mainnet -BLOCKCHAIN_POLL_INTERVAL=10000 +# Block + mempool poll interval (ms). Default 4000. +BLOCKCHAIN_POLL_INTERVAL=4000 WEBSOCKET_HEARTBEAT_INTERVAL=30000 WEBSOCKET_MAX_CONNECTIONS_PER_IP=5 WEBSOCKET_MAX_PAYLOAD_BYTES=65536 diff --git a/README.md b/README.md index bdc8b01..22386fd 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Modern, responsive block explorer for **FairCoin**. Vite + React SPA frontend with an Express API server (run with Bun) that talks JSON-RPC to a FairCoin node and caches responses in MongoDB. Real-time updates are pushed over WebSocket. +## Realtime model + +WebSocket (`WS /api/ws`) pushes **change notifications** (new blocks, mempool updates, network stats, transaction confirmations). Canonical blockchain data is always loaded over **HTTP** (`GET /api/*`) via React Query: the client invalidates (and optionally paints) caches on push, then refetches the full API shape. When the socket is down, live hooks fall back to a 30s HTTP poll. In local dev, Vite proxies `/api` with `ws: true` so the browser can upgrade `/api/ws` to the API server. + ## Stack - **Frontend**: Vite, React 18, TypeScript, TanStack Query, Tailwind CSS 4, shadcn/Radix UI, react-router @@ -62,7 +66,7 @@ MONGODB_URI=mongodb://localhost:27017/faircoin-explorer # WebSocket / realtime monitor WEBSOCKET_ENABLED=true WEBSOCKET_NETWORKS=mainnet # comma-separated; add testnet if you run a testnet node -BLOCKCHAIN_POLL_INTERVAL=10000 +BLOCKCHAIN_POLL_INTERVAL=4000 # block/mempool poll (ms); default 4s WEBSOCKET_HEARTBEAT_INTERVAL=30000 WEBSOCKET_MAX_CONNECTIONS_PER_IP=5 WEBSOCKET_MAX_PAYLOAD_BYTES=65536 @@ -88,7 +92,7 @@ The Express server exposes a read-only JSON API under `/api`: - `GET /api/validate-address?address=`, `/api/fee-estimate` - `GET /api/price`, `/api/price/history`, `/api/stats/history` - `GET /api/bridge/reserves` (proxied WFAIR bridge reserves) -- `WS /api/ws` (new blocks, mempool updates, network stats) +- `WS /api/ws` — push of chain changes (`new-block`, `block-count`, `mempool-update` with top-N txs, `transaction-confirmed`, `network-stats`). Canonical data still comes from HTTP `/api/*`; the socket tells the client when to refetch. ## MCP server (for AI assistants) @@ -149,5 +153,6 @@ Only key generation is done in-process (using the audited `@noble/curves` secp25 ## Notes +- Realtime model: the server polls the FairCoin RPC (default every 4s) and pushes change events on `WS /api/ws`. Clients should treat HTTP `/api/*` as the source of truth and use the socket to invalidate/refetch. - Address balances/history require a FairCoin node with `addressindex=1`; without it the explorer degrades gracefully to validation-only data. -- The MongoDB cache populates on demand; `npm run sync-db` (full historical sync) is optional. +- The MongoDB cache populates on demand; `bun run sync-db` (full historical sync) is optional. diff --git a/server/lib/blockchain-monitor.ts b/server/lib/blockchain-monitor.ts index cdc6ae6..4bca77e 100644 --- a/server/lib/blockchain-monitor.ts +++ b/server/lib/blockchain-monitor.ts @@ -1,6 +1,7 @@ // Blockchain Monitor Service for FairCoin Explorer // Polls RPC endpoints and broadcasts changes via WebSocket +import { rpcWithNetwork } from '@fairco.in/rpc-client' import { WebSocketManager } from './websocket-manager' import { NetworkType, @@ -9,11 +10,65 @@ import { NewBlockEvent, BlockCountEvent, MempoolUpdateEvent, - NetworkStatsEvent + MempoolTransaction, + NetworkStatsEvent, + TransactionConfirmedEvent, } from '../../shared/websocket-types' import { blockCache } from './cache' import { logger } from './logger' +/** Default block/mempool poll interval (ms). Overridable via BLOCKCHAIN_POLL_INTERVAL. */ +const DEFAULT_POLL_INTERVAL_MS = 4000 + +/** Cap on `transaction-confirmed` events emitted per new block. */ +const TRANSACTION_CONFIRMED_CAP = 50 + +/** Top-N mempool txs included in `mempool-update` (aligned with GET /api/mempool). */ +const MEMPOOL_TX_SUMMARY_LIMIT = 20 + +interface MempoolInfoRpc { + size?: number + bytes?: number + usage?: number + maxmempool?: number + mempoolminfee?: number +} + +interface MempoolEntryRpc { + size?: number + fee?: number + ancestorfees?: number + ancestorsize?: number + time?: number + depends?: unknown +} + +function mempoolTransactionFromEntry(txid: string, entry: MempoolEntryRpc | null): MempoolTransaction { + if (!entry) { + // Entry can disappear between getrawmempool and getmempoolentry. + return { + txid, + size: 0, + fee: 0, + feeRate: 0, + time: Date.now() / 1000, + depends: [], + } + } + + const ancestorfees = Number(entry.ancestorfees ?? 0) + const ancestorsize = Number(entry.ancestorsize ?? 0) + + return { + txid, + size: Number(entry.size ?? 0), + fee: Number(entry.fee ?? 0), + feeRate: ancestorfees && ancestorsize ? ancestorfees / ancestorsize : 0, + time: Number(entry.time ?? Date.now() / 1000), + depends: Array.isArray(entry.depends) ? entry.depends.map(String) : [], + } +} + export class BlockchainMonitor { private wsManager: WebSocketManager private networkStates: Map @@ -30,14 +85,15 @@ export class BlockchainMonitor { this.wsManager = wsManager this.config = { - pollInterval: config?.pollInterval ?? parseInt(process.env.BLOCKCHAIN_POLL_INTERVAL || '10000'), + pollInterval: + config?.pollInterval ?? + parseInt(process.env.BLOCKCHAIN_POLL_INTERVAL || String(DEFAULT_POLL_INTERVAL_MS), 10), networks: config?.networks ?? ['mainnet', 'testnet'], - enabled: config?.enabled ?? (process.env.WEBSOCKET_ENABLED !== 'false') + enabled: config?.enabled ?? (process.env.WEBSOCKET_ENABLED !== 'false'), } this.networkStates = new Map() - // Initialize network states this.config.networks.forEach(network => { this.networkStates.set(network, { network, @@ -48,16 +104,13 @@ export class BlockchainMonitor { connections: 0, difficulty: 0, hashrate: '0', - lastUpdate: new Date() + lastUpdate: new Date(), }) }) logger.debug('[BlockchainMonitor] Initialized with config:', this.config) } - /** - * Start monitoring blockchain for all configured networks - */ async start(): Promise { if (this.isRunning) { logger.debug('[BlockchainMonitor] Already running') @@ -72,31 +125,26 @@ export class BlockchainMonitor { logger.debug('[BlockchainMonitor] Starting monitor...') this.isRunning = true - // Initialize states for all networks for (const network of this.config.networks) { await this.initializeNetworkState(network) } - // Start polling interval for blocks and mempool this.pollInterval = setInterval(() => { - this.pollAllNetworks() + void this.pollAllNetworks() }, this.config.pollInterval) - // Start stats interval (less frequent - every 30s) this.statsInterval = setInterval(() => { - this.pollNetworkStats() + void this.pollNetworkStats() }, 30000) - // Do initial poll await this.pollAllNetworks() await this.pollNetworkStats() - logger.info('[BlockchainMonitor] Monitor started successfully') + logger.info( + `[BlockchainMonitor] Monitor started (pollInterval=${this.config.pollInterval}ms)`, + ) } - /** - * Stop monitoring - */ stop(): void { if (!this.isRunning) { return @@ -118,23 +166,20 @@ export class BlockchainMonitor { logger.info('[BlockchainMonitor] Monitor stopped') } - /** - * Initialize network state - */ private async initializeNetworkState(network: NetworkType): Promise { try { logger.debug(`[BlockchainMonitor] Initializing ${network} state...`) - const blockCount = await blockCache.getBlockCount(network) + const blockCount = await rpcWithNetwork('getblockcount', [], network) const block = await blockCache.getBlock(blockCount, network, true) - const mempoolInfo = await blockCache.getMempoolInfo(network) + const mempoolInfo = await rpcWithNetwork('getmempoolinfo', [], network) const state = this.networkStates.get(network) if (state) { state.blockHeight = blockCount state.blockHash = block.hash - state.mempoolSize = mempoolInfo?.size || 0 - state.mempoolBytes = mempoolInfo?.bytes || 0 + state.mempoolSize = mempoolInfo.size ?? 0 + state.mempoolBytes = mempoolInfo.bytes ?? 0 state.lastUpdate = new Date() } @@ -144,18 +189,12 @@ export class BlockchainMonitor { } } - /** - * Poll all configured networks - */ private async pollAllNetworks(): Promise { for (const network of this.config.networks) { await this.pollNetwork(network) } } - /** - * Poll single network for block and mempool changes - */ private async pollNetwork(network: NetworkType): Promise { try { const state = this.networkStates.get(network) @@ -163,35 +202,33 @@ export class BlockchainMonitor { return } - // Check block count - const newBlockCount = await blockCache.getBlockCount(network) + // Live RPC for change detection — do not use the HTTP response cache here. + const newBlockCount = await rpcWithNetwork('getblockcount', [], network) if (newBlockCount > state.blockHeight) { - logger.debug(`[BlockchainMonitor] ${network}: New blocks detected (${state.blockHeight} -> ${newBlockCount})`) + logger.debug( + `[BlockchainMonitor] ${network}: New blocks detected (${state.blockHeight} -> ${newBlockCount})`, + ) - // Fetch new blocks for (let height = state.blockHeight + 1; height <= newBlockCount; height++) { await this.handleNewBlock(network, height) } - // Update state const previousHeight = state.blockHeight state.blockHeight = newBlockCount - // Broadcast block count update const blockCountEvent: BlockCountEvent = { type: 'block-count', network, timestamp: Date.now(), data: { height: newBlockCount, - previousHeight - } + previousHeight, + }, } this.wsManager.broadcast(blockCountEvent, network) } - // Check mempool await this.pollMempool(network) state.lastUpdate = new Date() @@ -200,9 +237,6 @@ export class BlockchainMonitor { } } - /** - * Handle new block - */ private async handleNewBlock(network: NetworkType, height: number): Promise { try { const block = await blockCache.getBlock(height, network, true) @@ -212,9 +246,14 @@ export class BlockchainMonitor { state.blockHash = block.hash } - logger.debug(`[BlockchainMonitor] ${network}: Broadcasting new block ${height} (${block.hash})`) + const txids = Array.isArray(block.tx) + ? block.tx.filter((entry): entry is string => typeof entry === 'string') + : [] + + logger.debug( + `[BlockchainMonitor] ${network}: Broadcasting new block ${height} (${block.hash})`, + ) - // Broadcast new block event const newBlockEvent: NewBlockEvent = { type: 'new-block', network, @@ -223,23 +262,69 @@ export class BlockchainMonitor { hash: block.hash, height: block.height, time: block.time, - nTx: block.nTx || block.tx?.length || 0, + nTx: block.nTx || txids.length, size: block.size, difficulty: block.difficulty ?? 0, - tx: Array.isArray(block.tx) ? block.tx.filter((entry): entry is string => typeof entry === 'string') : [], + tx: txids, previousblockhash: block.previousblockhash, - nextblockhash: block.nextblockhash - } + nextblockhash: block.nextblockhash, + }, } this.wsManager.broadcast(newBlockEvent, network) + + for (const txid of txids.slice(0, TRANSACTION_CONFIRMED_CAP)) { + const confirmedEvent: TransactionConfirmedEvent = { + type: 'transaction-confirmed', + network, + timestamp: Date.now(), + data: { + txid, + blockHeight: block.height, + blockHash: block.hash, + confirmations: 1, + }, + } + this.wsManager.broadcast(confirmedEvent, network) + } } catch (error) { - logger.error(`[BlockchainMonitor] Error handling new block ${height} on ${network}:`, error) + logger.error( + `[BlockchainMonitor] Error handling new block ${height} on ${network}:`, + error, + ) } } /** - * Poll mempool for changes + * Top-N mempool summary — same fields and limit as GET /api/mempool. */ + private async fetchMempoolTransactionSummary( + network: NetworkType, + ): Promise { + let rawMempool: string[] + try { + rawMempool = await rpcWithNetwork('getrawmempool', [], network) + } catch (error) { + logger.error(`[BlockchainMonitor] getrawmempool failed for ${network}:`, error) + return [] + } + + const detailedTxs: MempoolTransaction[] = [] + for (const txid of rawMempool.slice(0, MEMPOOL_TX_SUMMARY_LIMIT)) { + try { + const entry = await rpcWithNetwork('getmempoolentry', [txid], network) + detailedTxs.push(mempoolTransactionFromEntry(txid, entry)) + } catch (error) { + logger.debug( + `[BlockchainMonitor] getmempoolentry failed for ${txid} on ${network}:`, + error, + ) + detailedTxs.push(mempoolTransactionFromEntry(txid, null)) + } + } + + return detailedTxs + } + private async pollMempool(network: NetworkType): Promise { try { const state = this.networkStates.get(network) @@ -247,21 +332,23 @@ export class BlockchainMonitor { return } - const mempoolInfo = await blockCache.getMempoolInfo(network) - const size = mempoolInfo?.size ?? 0 - const bytes = mempoolInfo?.bytes ?? 0 - const usage = mempoolInfo?.usage ?? 0 - const maxmempool = mempoolInfo?.maxmempool ?? 0 - const mempoolminfee = mempoolInfo?.mempoolminfee ?? 0 + const mempoolInfo = await rpcWithNetwork('getmempoolinfo', [], network) + const size = mempoolInfo.size ?? 0 + const bytes = mempoolInfo.bytes ?? 0 + const usage = mempoolInfo.usage ?? 0 + const maxmempool = mempoolInfo.maxmempool ?? 0 + const mempoolminfee = mempoolInfo.mempoolminfee ?? 0 - // Check if mempool changed if (size !== state.mempoolSize || bytes !== state.mempoolBytes) { - logger.debug(`[BlockchainMonitor] ${network}: Mempool changed (${state.mempoolSize} -> ${size} tx)`) + logger.debug( + `[BlockchainMonitor] ${network}: Mempool changed (${state.mempoolSize} -> ${size} tx)`, + ) state.mempoolSize = size state.mempoolBytes = bytes - // Broadcast mempool update event + const transactions = await this.fetchMempoolTransactionSummary(network) + const mempoolEvent: MempoolUpdateEvent = { type: 'mempool-update', network, @@ -272,8 +359,8 @@ export class BlockchainMonitor { usage, maxmempool, mempoolminfee, - transactions: [] - } + transactions, + }, } this.wsManager.broadcast(mempoolEvent, network) } @@ -282,19 +369,12 @@ export class BlockchainMonitor { } } - /** - * Poll network stats for all networks - */ private async pollNetworkStats(): Promise { for (const network of this.config.networks) { await this.pollNetworkStatsForNetwork(network) } } - /** - * Poll network stats for single network. Broadcasts only when the payload - * actually changed — avoids redundant `network-stats` WS traffic every 30s. - */ private async pollNetworkStatsForNetwork(network: NetworkType): Promise { try { const state = this.networkStates.get(network) @@ -370,34 +450,24 @@ export class BlockchainMonitor { } } - /** - * Get current state for a network - */ getNetworkState(network: NetworkType): NetworkState | undefined { return this.networkStates.get(network) } - /** - * Get all network states - */ getAllNetworkStates(): Map { return new Map(this.networkStates) } - /** - * Check if monitor is running - */ isMonitorRunning(): boolean { return this.isRunning } } -// Singleton instance let monitorInstance: BlockchainMonitor | null = null export function getBlockchainMonitor( wsManager: WebSocketManager, - config?: Partial + config?: Partial, ): BlockchainMonitor { if (!monitorInstance) { monitorInstance = new BlockchainMonitor(wsManager, config) diff --git a/server/lib/websocket-handler.ts b/server/lib/websocket-handler.ts index 7bd87b1..c242c93 100644 --- a/server/lib/websocket-handler.ts +++ b/server/lib/websocket-handler.ts @@ -29,9 +29,9 @@ const MONITOR_NETWORKS = (process.env.WEBSOCKET_NETWORKS || 'mainnet,testnet') .filter((n): n is NetworkType => n === 'mainnet' || n === 'testnet') const blockchainMonitor = getBlockchainMonitor(wsManager, { - pollInterval: parseInt(process.env.BLOCKCHAIN_POLL_INTERVAL || '10000'), + pollInterval: parseInt(process.env.BLOCKCHAIN_POLL_INTERVAL || '4000', 10), networks: MONITOR_NETWORKS.length > 0 ? MONITOR_NETWORKS : ['mainnet'], - enabled: process.env.WEBSOCKET_ENABLED !== 'false' + enabled: process.env.WEBSOCKET_ENABLED !== 'false', }) // Start blockchain monitor once at module load (this module is evaluated a diff --git a/src/components/home/home-header.tsx b/src/components/home/home-header.tsx index 1b3275e..1d930b0 100644 --- a/src/components/home/home-header.tsx +++ b/src/components/home/home-header.tsx @@ -1,18 +1,28 @@ import { useTranslations } from '@/lib/i18n' import { useNetworkStats } from '@/hooks/use-network-stats' +import { useLiveMode, type LiveMode } from '@/contexts/blockchain-context' import { NetworkStatus } from '@/components/network-status' import { formatNumber } from '@/lib/format' +import { cn } from '@/lib/utils' export function HomeHeader() { const t = useTranslations('home') const { data } = useNetworkStats() + const mode = useLiveMode() return (

{t('title')}

- +

{t('subtitle')}

@@ -25,23 +35,35 @@ export function HomeHeader() { } interface LivePillProps { + mode: LiveMode phase: string | undefined height: number | undefined label: string } -function LivePill({ phase, height, label }: LivePillProps) { +function LivePill({ mode, phase, height, label }: LivePillProps) { return ( - - - - - + + {label} - {phase && · {phase}} - {typeof height === 'number' && ( + {phase ? · {phase} : null} + {typeof height === 'number' ? ( · #{formatNumber(height)} - )} + ) : null} ) } diff --git a/src/components/masternodes-content.tsx b/src/components/masternodes-content.tsx index 4d4cc8b..6535a85 100644 --- a/src/components/masternodes-content.tsx +++ b/src/components/masternodes-content.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { Activity, AlertCircle, @@ -12,8 +12,10 @@ import { FileText, Info, Key, + List, Monitor, Network, + Search, Server, Settings, Shield, @@ -29,15 +31,22 @@ import { useTranslations } from '@/lib/i18n' import { MASTERNODE_COLLATERAL, REWARD_SPLIT, + useMasternodeList, useMasternodes, + type MasternodeEntry, } from '@/hooks/use-masternodes' import { formatNumber } from '@/lib/format' import { DetailHeader } from '@/components/detail/detail-header' import { SectionCard } from '@/components/detail/section-card' import { StatTile, StatTileGrid } from '@/components/detail/stat-tile' +import { HashCell } from '@/components/detail/hash-cell' +import { RelativeTime } from '@/components/detail/relative-time' import { CopyButton } from '@/components/copy-button' import { cn } from '@/lib/utils' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' type Translate = (key: string, params?: Record) => string @@ -152,14 +161,20 @@ export function MasternodesContent() { - + {t('tabs.overview')} + {t('tabs.list')} {t('tabs.guide')} {t('tabs.budget')} {t('tabs.requirements')} {t('tabs.troubleshooting')} + {/* Live list */} + + + + {/* Overview */}
@@ -372,6 +387,197 @@ export function MasternodesContent() { ) } +const LIST_PAGE_SIZE = 25 + +function formatActiveDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return '—' + const days = Math.floor(seconds / 86_400) + const hours = Math.floor((seconds % 86_400) / 3_600) + if (days > 0) return `${days}d ${hours}h` + const minutes = Math.floor((seconds % 3_600) / 60) + if (hours > 0) return `${hours}h ${minutes}m` + return `${minutes}m` +} + +function MasternodeListPanel({ t }: { t: Translate }) { + const common = useTranslations('common') + const [page, setPage] = useState(1) + const [searchQuery, setSearchQuery] = useState('') + const offset = (page - 1) * LIST_PAGE_SIZE + const { data, isLoading, isError, error, isFetching, refetch } = useMasternodeList( + LIST_PAGE_SIZE, + offset, + ) + + const filtered = useMemo(() => { + const rows = data?.masternodes ?? [] + const query = searchQuery.trim().toLowerCase() + if (!query) return rows + return rows.filter((mn) => { + return ( + mn.address.toLowerCase().includes(query) || + mn.txid.toLowerCase().includes(query) || + mn.status.toLowerCase().includes(query) || + String(mn.rank).includes(query) + ) + }) + }, [data?.masternodes, searchQuery]) + + const total = data?.total ?? 0 + const totalPages = Math.max(1, Math.ceil(total / LIST_PAGE_SIZE)) + + if (isLoading) { + return ( + +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) + } + + if (isError || !data) { + return ( + +
+ + + +

+ {error instanceof Error ? error.message : t('list.error')} +

+ +
+
+ ) + } + + return ( +
+
+ + { + setSearchQuery(event.target.value) + setPage(1) + }} + className="w-full pl-10" + /> +
+ {searchQuery.trim() ? ( +

{t('list.filterPageOnly')}

+ ) : null} + + + {formatNumber(total)} + + } + > + {filtered.length > 0 ? ( + <> +
+ {t('list.rank')} + {t('list.status')} + {t('list.address')} + {t('list.active')} + {t('list.lastSeen')} + {t('list.collateral')} +
+
    + {filtered.map((mn) => ( + + ))} +
+ + ) : ( +
+ + + +

{t('list.empty')}

+
+ )} + + {totalPages > 1 ? ( +
+ + {common('page', { current: page, total: totalPages })} + +
+ + +
+
+ ) : null} +
+
+ ) +} + +function MasternodeRow({ mn, t }: { mn: MasternodeEntry; t: Translate }) { + const enabled = mn.status.toUpperCase() === 'ENABLED' + return ( +
  • + + #{formatNumber(mn.rank)} + + + {mn.status || t('list.unknownStatus')} + +
    + {mn.address ? ( + + ) : ( + + )} +
    + + {formatActiveDuration(mn.activeTime)} + + + {mn.lastSeen > 0 ? : '—'} + +
    + {mn.txid ? ( + + ) : ( + + )} +
    +
  • + ) +} + /** * Premium reward-distribution panel modelled on the home supply bar: a hero * collateral figure, a single dual-fill gradient bar that reads the 50/50 split diff --git a/src/components/network-status.tsx b/src/components/network-status.tsx index 682d390..15a24b5 100644 --- a/src/components/network-status.tsx +++ b/src/components/network-status.tsx @@ -1,31 +1,47 @@ import { Badge } from '@/components/ui/badge' import { useNetwork } from '@/contexts/network-context' +import { useLiveMode } from '@/contexts/blockchain-context' import { useStats } from '@/hooks/use-stats' -import { Wifi, WifiOff } from 'lucide-react' +import { useTranslations } from '@/lib/i18n' +import { Wifi, WifiOff, RefreshCw } from 'lucide-react' +import { cn } from '@/lib/utils' /** * Compact network pill (name + tip height) used in page headers. - * Reads from the shared `useStats` React Query cache — no separate poll. + * Border/icon reflect WebSocket health, not just HTTP success. */ export function NetworkStatus() { const { networkConfig } = useNetwork() - const { data: stats, isError, isSuccess } = useStats() + const { data: stats } = useStats() + const mode = useLiveMode() + const t = useTranslations('home') - const isConnected = isSuccess && !isError const blockCount = stats?.blockHeight + const modeLabel = + mode === 'live' ? t('live') : mode === 'polling' ? t('polling') : t('offline') return ( - {isConnected ? : } + {mode === 'live' ? ( + + ) : mode === 'polling' ? ( + + ) : ( + + )} {networkConfig.displayName} {typeof blockCount === 'number' && blockCount > 0 ? ( #{blockCount.toLocaleString()} ) : null} + · {modeLabel} ) } diff --git a/src/components/stats-content.tsx b/src/components/stats-content.tsx index 41652cf..a5e7981 100644 --- a/src/components/stats-content.tsx +++ b/src/components/stats-content.tsx @@ -107,6 +107,13 @@ export function StatsContent() { } /> + {currentNetwork !== 'mainnet' ? ( +
    + + {t('mainnetHistoryOnly')} +
    + ) : null} + {/* Supply hero — premium gradient bar + halving sub-stats, ambient line. */} @@ -154,7 +161,10 @@ export function StatsContent() { {/* Transaction statistics */} - + 0) return 'confirmed' + if (mempoolTxs?.some((entry) => entry.txid === transaction.txid)) return 'mempool' + return 'unconfirmed' +} + +function TxStatusBadge({ + status, + t, + confirmedLabel, +}: { + status: TxStatus + t: Translate + confirmedLabel: string +}) { + if (status === 'confirmed') { + return ( + + + {confirmedLabel} + + ) + } + if (status === 'mempool') { + return ( + + + {t('inMempool')} + + ) + } + return ( + + + {t('unconfirmed')} + + ) +} + export function TransactionContent({ txid }: { txid: string }) { const t = useTranslations('tx') const common = useTranslations('common') const nav = useTranslations('nav') const navigate = useNavigate() const { data: transaction, isLoading, isError, error, refetch, isFetching } = useTransaction(txid) + const { data: mempool } = useMempool() if (isLoading) { return @@ -202,7 +249,7 @@ export function TransactionContent({ txid }: { txid: string }) { } const confirmations = transaction.confirmations ?? 0 - const confirmed = confirmations > 0 + const status = resolveTxStatus(transaction, mempool?.transactions) const analysis = analyzeTransaction(transaction) const hero = describeHero(analysis, t) const changeTotal = analysis.outputs @@ -237,15 +284,7 @@ export function TransactionContent({ txid }: { txid: string }) {

    {hero.title}

    - - {confirmed ? : } - {confirmed ? common('confirmed') : t('unconfirmed')} - +
    diff --git a/src/contexts/blockchain-context.tsx b/src/contexts/blockchain-context.tsx index ad6202a..6af0cb3 100644 --- a/src/contexts/blockchain-context.tsx +++ b/src/contexts/blockchain-context.tsx @@ -1,7 +1,17 @@ -import { type ReactNode } from 'react' +import { createContext, useContext, type ReactNode } from 'react' +import type { ConnectionState, WebSocketEvent } from '@shared/websocket-types' import { useNetwork } from './network-context' import { useBlockchainWebSocket } from '@/hooks/use-blockchain-websocket' import { useRealtimeSync } from '@/hooks/use-realtime-sync' +import { useStats } from '@/hooks/use-stats' + +interface BlockchainContextValue { + isConnected: boolean + connectionState: ConnectionState + lastMessage: WebSocketEvent | null +} + +const BlockchainContext = createContext(undefined) interface BlockchainProviderProps { children: ReactNode @@ -9,13 +19,13 @@ interface BlockchainProviderProps { /** * Mounts the single blockchain WebSocket and wires real-time React Query - * invalidation. No consumer-facing context API — live data flows through - * React Query hooks (`useStats`, `useRecentBlocks`, etc.). + * invalidation. Exposes connection state for live indicators and for + * suppressing HTTP polling while the socket is healthy. */ export function BlockchainProvider({ children }: BlockchainProviderProps) { const { currentNetwork } = useNetwork() - const { lastMessage } = useBlockchainWebSocket({ + const { lastMessage, isConnected, connectionState } = useBlockchainWebSocket({ network: currentNetwork, autoConnect: true, reconnectOnNetworkChange: true, @@ -23,5 +33,43 @@ export function BlockchainProvider({ children }: BlockchainProviderProps) { useRealtimeSync(lastMessage) - return children + return ( + + {children} + + ) +} + +export function useBlockchain(): BlockchainContextValue { + const context = useContext(BlockchainContext) + if (context === undefined) { + throw new Error('useBlockchain must be used within a BlockchainProvider') + } + return context +} + +/** + * React Query `refetchInterval`: poll only when the WebSocket is down. + * While connected, push invalidation keeps caches fresh. + */ +export function useLiveRefetchInterval(fallbackMs = 30_000): number | false { + const { isConnected } = useBlockchain() + return isConnected ? false : fallbackMs +} + +export type LiveMode = 'live' | 'polling' | 'offline' + +/** + * Honest UI mode from real socket + HTTP health: + * - live: WebSocket connected + * - polling: WebSocket down, stats HTTP ok + * - offline: stats HTTP failed + */ +export function useLiveMode(): LiveMode { + const { isConnected } = useBlockchain() + const { isError, isSuccess } = useStats() + + if (isConnected) return 'live' + if (isSuccess && !isError) return 'polling' + return 'offline' } diff --git a/src/hooks/use-address.ts b/src/hooks/use-address.ts index bd307b9..94e81a8 100644 --- a/src/hooks/use-address.ts +++ b/src/hooks/use-address.ts @@ -1,5 +1,6 @@ import { useQuery, keepPreviousData, type UseQueryResult } from '@tanstack/react-query' import { useNetwork } from '@/contexts/network-context' +import { useLiveRefetchInterval } from '@/contexts/blockchain-context' import { readErrorMessage } from '@/lib/read-error-message' export interface AddressTransaction { @@ -41,6 +42,7 @@ interface AddressResponse { export function useAddress(address: string): UseQueryResult { const { currentNetwork } = useNetwork() + const refetchInterval = useLiveRefetchInterval() return useQuery({ queryKey: ['address', address, currentNetwork], @@ -54,7 +56,7 @@ export function useAddress(address: string): UseQueryResult { const data = (await response.json()) as AddressResponse return data.addressInfo }, - refetchInterval: 30_000, + refetchInterval, retry: 1, }) } @@ -79,6 +81,7 @@ export function useAddressTransactions( limit = 20, ): UseQueryResult { const { currentNetwork } = useNetwork() + const refetchInterval = useLiveRefetchInterval() return useQuery({ queryKey: ['address-txs', address, page, limit, currentNetwork], @@ -93,7 +96,7 @@ export function useAddressTransactions( return (await response.json()) as AddressTxsPage }, placeholderData: keepPreviousData, - refetchInterval: 30_000, + refetchInterval, retry: 1, }) } diff --git a/src/hooks/use-block.ts b/src/hooks/use-block.ts index 8e8a1bb..db85fe8 100644 --- a/src/hooks/use-block.ts +++ b/src/hooks/use-block.ts @@ -1,5 +1,6 @@ import { useQuery, type UseQueryResult } from '@tanstack/react-query' import { useNetwork } from '@/contexts/network-context' +import { useLiveRefetchInterval } from '@/contexts/blockchain-context' import { readErrorMessage } from '@/lib/read-error-message' export interface Block { @@ -28,6 +29,7 @@ interface BlockResponse { export function useBlock(hashOrHeight: string): UseQueryResult { const { currentNetwork } = useNetwork() + const refetchInterval = useLiveRefetchInterval() return useQuery({ queryKey: ['block', hashOrHeight, currentNetwork], @@ -41,7 +43,7 @@ export function useBlock(hashOrHeight: string): UseQueryResult { const data = (await response.json()) as BlockResponse return data.block }, - refetchInterval: 30_000, + refetchInterval, retry: 1, }) } diff --git a/src/hooks/use-blockchain-websocket.ts b/src/hooks/use-blockchain-websocket.ts index 2f7b345..faf002d 100644 --- a/src/hooks/use-blockchain-websocket.ts +++ b/src/hooks/use-blockchain-websocket.ts @@ -41,7 +41,7 @@ export function useBlockchainWebSocket( const [error, setError] = useState(null) const wsRef = useRef(null) - const reconnectTimeoutRef = useRef(null) + const reconnectTimeoutRef = useRef | null>(null) const reconnectAttemptsRef = useRef(0) const maxReconnectDelay = 30000 // 30 seconds const initialReconnectDelay = 1000 // 1 second @@ -80,13 +80,10 @@ export function useBlockchainWebSocket( const host = window.location.host const wsUrl = `${protocol}//${host}/api/ws` - console.log('[useBlockchainWebSocket] Connecting to:', wsUrl) - const ws = new WebSocket(wsUrl) wsRef.current = ws ws.onopen = () => { - console.log('[useBlockchainWebSocket] Connected') setConnectionState('connected') setError(null) reconnectAttemptsRef.current = 0 @@ -104,30 +101,26 @@ export function useBlockchainWebSocket( const message: WebSocketEvent = JSON.parse(event.data) setLastMessage(message) - // Handle special message types if (message.type === 'error') { console.error('[useBlockchainWebSocket] Server error:', message.data) } - } catch (error) { - console.error('[useBlockchainWebSocket] Error parsing message:', error) + } catch (parseError) { + console.error('[useBlockchainWebSocket] Error parsing message:', parseError) } } - ws.onerror = (event) => { - console.error('[useBlockchainWebSocket] WebSocket error:', event) + ws.onerror = () => { setError(new Error('WebSocket error')) setConnectionState('error') } ws.onclose = (event) => { - console.log('[useBlockchainWebSocket] Disconnected:', event.code, event.reason) setConnectionState('disconnected') wsRef.current = null // Auto-reconnect if not a clean close if (event.code !== 1000 && autoConnect) { const delay = calculateReconnectDelay() - console.log(`[useBlockchainWebSocket] Reconnecting in ${delay}ms...`) reconnectTimeoutRef.current = setTimeout(() => { reconnectAttemptsRef.current++ @@ -135,9 +128,13 @@ export function useBlockchainWebSocket( }, delay) } } - } catch (error) { - console.error('[useBlockchainWebSocket] Connection error:', error) - setError(error instanceof Error ? error : new Error('Unknown connection error')) + } catch (connectionError) { + console.error('[useBlockchainWebSocket] Connection error:', connectionError) + setError( + connectionError instanceof Error + ? connectionError + : new Error('Unknown connection error'), + ) setConnectionState('error') } }, [network, autoConnect, calculateReconnectDelay]) @@ -146,8 +143,6 @@ export function useBlockchainWebSocket( * Disconnect from WebSocket */ const disconnect = useCallback(() => { - console.log('[useBlockchainWebSocket] Disconnecting...') - // Clear reconnect timeout if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) @@ -167,7 +162,6 @@ export function useBlockchainWebSocket( * Reconnect to WebSocket */ const reconnect = useCallback(() => { - console.log('[useBlockchainWebSocket] Manual reconnect') disconnect() reconnectAttemptsRef.current = 0 connect() @@ -179,8 +173,6 @@ export function useBlockchainWebSocket( const send = useCallback((message: ClientMessage) => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(message)) - } else { - console.warn('[useBlockchainWebSocket] Cannot send message: not connected') } }, []) @@ -225,7 +217,6 @@ export function useBlockchainWebSocket( */ useEffect(() => { if (reconnectOnNetworkChange && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - console.log('[useBlockchainWebSocket] Network changed, sending change-network message') const message: ClientMessage = { type: 'change-network', network diff --git a/src/hooks/use-masternodes.ts b/src/hooks/use-masternodes.ts index f2ca9cf..f1bd0a1 100644 --- a/src/hooks/use-masternodes.ts +++ b/src/hooks/use-masternodes.ts @@ -1,5 +1,6 @@ -import { useQuery, type UseQueryResult } from '@tanstack/react-query' +import { useQuery, keepPreviousData, type UseQueryResult } from '@tanstack/react-query' import { useNetwork } from '@/contexts/network-context' +import { useLiveRefetchInterval } from '@/contexts/blockchain-context' /** * FairCoin v3.0.0 protocol constants. The masternode collateral and reward split @@ -43,6 +44,7 @@ const EMPTY_STATS: MasternodeStats = { export function useMasternodes(): UseQueryResult { const { currentNetwork } = useNetwork() + const refetchInterval = useLiveRefetchInterval() return useQuery({ queryKey: ['masternodes', currentNetwork], @@ -56,7 +58,77 @@ export function useMasternodes(): UseQueryResult { const data = (await response.json()) as MasternodesResponse return { ...EMPTY_STATS, ...data.stats } }, - refetchInterval: 30_000, + refetchInterval, + retry: 1, + }) +} + +export interface MasternodeEntry { + txid: string + outidx: number + address: string + protocol: number + status: string + activeTime: number + lastSeen: number + lastPaid: number + rank: number +} + +export interface MasternodeListPage { + masternodes: MasternodeEntry[] + stats: MasternodeStats + total: number + limit: number + offset: number +} + +interface MasternodeListResponse { + masternodes: MasternodeEntry[] + stats?: Partial + network: string + pagination: { total: number; limit: number; offset: number } +} + +const DEFAULT_LIST_LIMIT = 25 + +/** + * Paginated masternode rows from `GET /api/masternodes?include=list`. + * Stats-only callers should keep using {@link useMasternodes}. + */ +export function useMasternodeList( + limit: number = DEFAULT_LIST_LIMIT, + offset: number = 0, +): UseQueryResult { + const { currentNetwork } = useNetwork() + const refetchInterval = useLiveRefetchInterval() + + return useQuery({ + queryKey: ['masternode-list', currentNetwork, limit, offset], + queryFn: async (): Promise => { + const params = new URLSearchParams({ + network: currentNetwork, + include: 'list', + limit: String(limit), + offset: String(offset), + }) + const response = await fetch(`/api/masternodes?${params}`, { + headers: { Accept: 'application/json' }, + }) + if (!response.ok) { + throw new Error(`Failed to load masternode list (${response.status})`) + } + const data = (await response.json()) as MasternodeListResponse + return { + masternodes: data.masternodes ?? [], + stats: { ...EMPTY_STATS, ...data.stats }, + total: data.pagination?.total ?? 0, + limit: data.pagination?.limit ?? limit, + offset: data.pagination?.offset ?? offset, + } + }, + placeholderData: keepPreviousData, + refetchInterval, retry: 1, }) } diff --git a/src/hooks/use-mempool.ts b/src/hooks/use-mempool.ts index 2cb6b1a..f5d6797 100644 --- a/src/hooks/use-mempool.ts +++ b/src/hooks/use-mempool.ts @@ -1,5 +1,6 @@ import { useQuery, type UseQueryResult } from '@tanstack/react-query' import { useNetwork } from '@/contexts/network-context' +import { useLiveRefetchInterval } from '@/contexts/blockchain-context' export interface MempoolTransaction { txid: string @@ -26,6 +27,7 @@ interface MempoolResponse { export function useMempool(): UseQueryResult { const { currentNetwork } = useNetwork() + const refetchInterval = useLiveRefetchInterval() return useQuery({ queryKey: ['mempool', currentNetwork], @@ -44,7 +46,7 @@ export function useMempool(): UseQueryResult { transactions: info?.transactions ?? [], } }, - refetchInterval: 30_000, + refetchInterval, retry: 1, }) } diff --git a/src/hooks/use-node-status.ts b/src/hooks/use-node-status.ts index e3f821a..5eeb2ec 100644 --- a/src/hooks/use-node-status.ts +++ b/src/hooks/use-node-status.ts @@ -1,5 +1,6 @@ import { useQuery, type UseQueryResult } from '@tanstack/react-query' import { useNetwork } from '@/contexts/network-context' +import { useLiveRefetchInterval } from '@/contexts/blockchain-context' interface BlockCountResponse { blockcount: number @@ -45,6 +46,7 @@ async function fetchJson(url: string): Promise { export function useNodeStatus(): UseQueryResult { const { currentNetwork } = useNetwork() + const refetchInterval = useLiveRefetchInterval() return useQuery({ queryKey: ['node-status', currentNetwork], @@ -71,7 +73,7 @@ export function useNodeStatus(): UseQueryResult { relayFee: networkInfo?.relayfee ?? 0, } }, - refetchInterval: 30_000, + refetchInterval, retry: 1, }) } diff --git a/src/hooks/use-peers.ts b/src/hooks/use-peers.ts index 66fcd09..36cadfb 100644 --- a/src/hooks/use-peers.ts +++ b/src/hooks/use-peers.ts @@ -1,5 +1,6 @@ import { useQuery, type UseQueryResult } from '@tanstack/react-query' import { useNetwork } from '@/contexts/network-context' +import { useLiveRefetchInterval } from '@/contexts/blockchain-context' export interface Peer { addr: string @@ -32,6 +33,7 @@ export interface PeersData { export function usePeers(): UseQueryResult { const { currentNetwork } = useNetwork() + const refetchInterval = useLiveRefetchInterval() return useQuery({ queryKey: ['peers', currentNetwork], @@ -52,7 +54,7 @@ export function usePeers(): UseQueryResult { outbound: peers.length - inbound, } }, - refetchInterval: 30_000, + refetchInterval, retry: 1, }) } diff --git a/src/hooks/use-realtime-sync.ts b/src/hooks/use-realtime-sync.ts index 8de93eb..2675b70 100644 --- a/src/hooks/use-realtime-sync.ts +++ b/src/hooks/use-realtime-sync.ts @@ -6,31 +6,20 @@ import { NewBlockEvent, BlockCountEvent, NetworkStatsEvent, + MempoolUpdateEvent, + TransactionConfirmedEvent, } from '@shared/websocket-types' /** - * Real-time cache sync for the home dashboard. + * Real-time cache sync for live explorer views. * - * The home reads live blockchain data through React Query hooks that poll every - * 30s as a baseline. This hook upgrades that to true real-time by listening to - * the already-open blockchain WebSocket (see `useBlockchainWebSocket`, mounted - * once by `BlockchainProvider`) and, on the relevant push events, invalidating - * the matching React Query caches so they refetch immediately instead of waiting - * for the next poll. + * Listens to the single blockchain WebSocket (via `BlockchainProvider`) and + * invalidates the matching React Query caches so they refetch the canonical + * HTTP API shape. Invalidate-on-event is intentional: it is robust, typed, and + * React Query coalesces overlapping refetches. * - * Design notes: - * - It consumes the SINGLE existing socket via the `lastMessage` value the - * provider already exposes — it never opens a second connection. - * - Invalidate-on-event is intentional: it is robust (always fetches the - * canonical server state) and React Query coalesces overlapping refetches, so - * bursts of events do not cause fetch storms. - * - The 30s `refetchInterval` on the underlying hooks stays in place as a - * fallback, so the home keeps updating even if the socket drops (e.g. in local - * dev where the Vite proxy does not forward `/api/ws`). - * - * The effect here is a legitimate external-subscription effect: it reacts to the - * newest WebSocket message. It performs no work and throws nothing when the - * socket is absent (`event` is simply `null`). + * Live hooks use `useLiveRefetchInterval` so HTTP polling is only a fallback + * when the socket is down. */ export function useRealtimeSync(event: WebSocketEvent | null): void { const queryClient = useQueryClient() @@ -38,58 +27,58 @@ export function useRealtimeSync(event: WebSocketEvent | null): void { useEffect(() => { if (!event) return - - // Ignore events for a network the user is not currently viewing so we don't - // refetch the wrong network's data. if (event.network !== currentNetwork) return switch (event.type) { - // A new block (or a bare height bump) changes the tip: the recent-blocks - // feed, the derived latest-tx feed (it reads the same query), the - // height-driven header pill and the stat-strip height all need to update. - // Invalidating these two keys refetches everything the home derives from - // them. The query keys carry the network, so invalidating the prefix - // refreshes the active network's cache. case 'new-block': case 'block-count': { void queryClient.invalidateQueries({ queryKey: ['recent-blocks'] }) void queryClient.invalidateQueries({ queryKey: ['recent-transactions'] }) - // Must match useStats / useNetworkStats (`['stats', network]`). void queryClient.invalidateQueries({ queryKey: ['stats'] }) - // Append the freshly-sampled tip to the stat-strip sparkline series. - void queryClient.invalidateQueries({ queryKey: ['stats-history'] }) + void queryClient.invalidateQueries({ queryKey: ['block'] }) + void queryClient.invalidateQueries({ queryKey: ['transaction'] }) + void queryClient.invalidateQueries({ queryKey: ['address'] }) + void queryClient.invalidateQueries({ queryKey: ['address-txs'] }) + void queryClient.invalidateQueries({ queryKey: ['node-status'] }) + void queryClient.invalidateQueries({ queryKey: ['masternodes'] }) + void queryClient.invalidateQueries({ queryKey: ['masternode-list'] }) + void queryClient.invalidateQueries({ queryKey: ['peers'] }) break } - // Difficulty / connections / hashrate moved — refresh the stats the - // header pill, stat-strip, supply bar and network card read. case 'network-stats': { void queryClient.invalidateQueries({ queryKey: ['stats'] }) + void queryClient.invalidateQueries({ queryKey: ['peers'] }) + void queryClient.invalidateQueries({ queryKey: ['node-status'] }) break } case 'mempool-update': { void queryClient.invalidateQueries({ queryKey: ['mempool'] }) void queryClient.invalidateQueries({ queryKey: ['recent-transactions'] }) - // memPoolSize on the stats strip also changes with the pool. void queryClient.invalidateQueries({ queryKey: ['stats'] }) break } + case 'transaction-confirmed': { + if (!isTransactionConfirmedEvent(event)) break + void queryClient.invalidateQueries({ + queryKey: ['transaction', event.data.txid, currentNetwork], + }) + void queryClient.invalidateQueries({ queryKey: ['address'] }) + void queryClient.invalidateQueries({ queryKey: ['address-txs'] }) + void queryClient.invalidateQueries({ queryKey: ['recent-transactions'] }) + void queryClient.invalidateQueries({ queryKey: ['mempool'] }) + break + } + default: - // Other event types (transaction-confirmed, ping/pong, - // subscribe/unsubscribe, error) do not feed the dashboard caches, - // so there is nothing to invalidate here. break } }, [event, currentNetwork, queryClient]) } -/** - * Narrowing helpers for callers that need the typed payload of an event (e.g. to - * prepend a block for zero-flicker). Exposed alongside the hook so consumers can - * discriminate `WebSocketEvent` without unsafe casts. - */ +/** Narrowing helpers for typed WebSocket payloads (base `WebSocketEvent` is not a union). */ export function isNewBlockEvent(event: WebSocketEvent): event is NewBlockEvent { return event.type === 'new-block' } @@ -101,3 +90,13 @@ export function isBlockCountEvent(event: WebSocketEvent): event is BlockCountEve export function isNetworkStatsEvent(event: WebSocketEvent): event is NetworkStatsEvent { return event.type === 'network-stats' } + +export function isMempoolUpdateEvent(event: WebSocketEvent): event is MempoolUpdateEvent { + return event.type === 'mempool-update' +} + +export function isTransactionConfirmedEvent( + event: WebSocketEvent, +): event is TransactionConfirmedEvent { + return event.type === 'transaction-confirmed' +} diff --git a/src/hooks/use-recent-blocks.ts b/src/hooks/use-recent-blocks.ts index 4455828..2eb6892 100644 --- a/src/hooks/use-recent-blocks.ts +++ b/src/hooks/use-recent-blocks.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { useQuery, keepPreviousData, type UseQueryResult } from '@tanstack/react-query' import { useNetwork } from '@/contexts/network-context' +import { useLiveRefetchInterval } from '@/contexts/blockchain-context' export interface RecentBlock { height: number @@ -47,6 +48,7 @@ export function useRecentBlocks( offset: number = 0, ): UseQueryResult { const { currentNetwork } = useNetwork() + const liveInterval = useLiveRefetchInterval() return useQuery({ queryKey: ['recent-blocks', currentNetwork, limit, offset], @@ -69,7 +71,7 @@ export function useRecentBlocks( }, placeholderData: keepPreviousData, // Only auto-refresh the tip window; deeper pages are stable history. - refetchInterval: offset === 0 ? 30_000 : false, + refetchInterval: offset === 0 ? liveInterval : false, retry: 1, }) } diff --git a/src/hooks/use-recent-transactions.ts b/src/hooks/use-recent-transactions.ts index b4f095e..f0d35e2 100644 --- a/src/hooks/use-recent-transactions.ts +++ b/src/hooks/use-recent-transactions.ts @@ -1,5 +1,6 @@ import { useQuery, keepPreviousData, type UseQueryResult } from '@tanstack/react-query' import { useNetwork } from '@/contexts/network-context' +import { useLiveRefetchInterval } from '@/contexts/blockchain-context' export interface RecentTransaction { txid: string @@ -41,6 +42,7 @@ export function useRecentTransactions( includeMempool = true, ): UseQueryResult { const { currentNetwork } = useNetwork() + const liveInterval = useLiveRefetchInterval() return useQuery({ queryKey: ['recent-transactions', currentNetwork, limit, offset, includeMempool], @@ -68,7 +70,7 @@ export function useRecentTransactions( } }, placeholderData: keepPreviousData, - refetchInterval: offset === 0 ? 30_000 : false, + refetchInterval: offset === 0 ? liveInterval : false, retry: 1, }) } diff --git a/src/hooks/use-stats.ts b/src/hooks/use-stats.ts index 87ea7de..8f5d652 100644 --- a/src/hooks/use-stats.ts +++ b/src/hooks/use-stats.ts @@ -1,5 +1,6 @@ import { useQuery, type UseQueryResult } from '@tanstack/react-query' import { useNetwork } from '@/contexts/network-context' +import { useLiveRefetchInterval } from '@/contexts/blockchain-context' export interface StatsLastBlock { height: number @@ -34,6 +35,7 @@ interface StatsResponse { export function useStats(): UseQueryResult { const { currentNetwork } = useNetwork() + const refetchInterval = useLiveRefetchInterval() return useQuery({ queryKey: ['stats', currentNetwork], @@ -47,7 +49,7 @@ export function useStats(): UseQueryResult { const data = (await response.json()) as StatsResponse return data.stats }, - refetchInterval: 30_000, + refetchInterval, retry: 1, }) } diff --git a/src/hooks/use-transaction.ts b/src/hooks/use-transaction.ts index 1df5781..71cd568 100644 --- a/src/hooks/use-transaction.ts +++ b/src/hooks/use-transaction.ts @@ -1,5 +1,6 @@ import { useQuery, type UseQueryResult } from '@tanstack/react-query' import { useNetwork } from '@/contexts/network-context' +import { useLiveRefetchInterval } from '@/contexts/blockchain-context' import { readErrorMessage } from '@/lib/read-error-message' export interface TransactionScriptSig { @@ -262,6 +263,7 @@ export function analyzeTransaction(tx: Transaction): TransactionAnalysis { export function useTransaction(txid: string): UseQueryResult { const { currentNetwork } = useNetwork() + const refetchInterval = useLiveRefetchInterval() return useQuery({ queryKey: ['transaction', txid, currentNetwork], @@ -276,7 +278,7 @@ export function useTransaction(txid: string): UseQueryResult { const data = (await response.json()) as TransactionResponse return data.transaction }, - refetchInterval: 30_000, + refetchInterval, retry: 1, }) } diff --git a/src/messages/de.json b/src/messages/de.json index 041f445..82bd077 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -1048,5 +1048,25 @@ "errorBoundary.fallback": "Ein unerwarteter Fehler ist aufgetreten.", "errorBoundary.reload": "Seite neu laden", "blocks.filterPageOnly": "Filter gelten nur für diese Ergebnisseite", - "blocks.timeFilterHint": "Nur diese Seite" + "blocks.timeFilterHint": "Nur diese Seite", + "home.polling": "Polling", + "home.offline": "Offline", + "peers.privacyTitle": "Peer details are private", + "peers.privacyNote": "IP addresses, client versions, latency, and traffic are redacted by the API. Only aggregate connection counts are shown.", + "masternodes.tabs.list": "List", + "masternodes.list.title": "Masternode list", + "masternodes.list.searchPlaceholder": "Search by address, txid, status, or rank…", + "masternodes.list.filterPageOnly": "Search filters the current page only.", + "masternodes.list.rank": "Rank", + "masternodes.list.status": "Status", + "masternodes.list.address": "Address", + "masternodes.list.active": "Active", + "masternodes.list.lastSeen": "Last seen", + "masternodes.list.collateral": "Collateral tx", + "masternodes.list.empty": "No masternodes match this page.", + "masternodes.list.error": "Could not load the masternode list.", + "masternodes.list.unknownStatus": "Unknown", + "stats.totalTransactionsEstimated": "Total Transactions (estimated)", + "stats.mainnetHistoryOnly": "Sparkline history is sampled for mainnet only. Live stats above still reflect the selected network.", + "tx.inMempool": "In mempool" } diff --git a/src/messages/en.json b/src/messages/en.json index 856c07a..8598cfe 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -505,7 +505,7 @@ "mempool.tip4": "Use InstantSend for near-instant transaction confirmations", "mempool.backToHome": "Back to Home", "peers.title": "Connected Peers", - "peers.subtitle": "Live view of nodes connected to the FairCoin network", + "peers.subtitle": "Aggregate view of nodes connected to the explorer’s FairCoin node", "peers.refresh": "Refresh", "peers.totalPeers": "Total Peers", "peers.connectedNodes": "Connected nodes", @@ -1048,5 +1048,25 @@ "errorBoundary.fallback": "An unexpected error occurred.", "errorBoundary.reload": "Reload page", "blocks.filterPageOnly": "Filters apply to this page of results only", - "blocks.timeFilterHint": "This page only" + "blocks.timeFilterHint": "This page only", + "home.polling": "Polling", + "home.offline": "Offline", + "peers.privacyTitle": "Peer details are private", + "peers.privacyNote": "IP addresses, client versions, latency, and traffic are redacted by the API. Only aggregate connection counts are shown.", + "masternodes.tabs.list": "List", + "masternodes.list.title": "Masternode list", + "masternodes.list.searchPlaceholder": "Search by address, txid, status, or rank…", + "masternodes.list.filterPageOnly": "Search filters the current page only.", + "masternodes.list.rank": "Rank", + "masternodes.list.status": "Status", + "masternodes.list.address": "Address", + "masternodes.list.active": "Active", + "masternodes.list.lastSeen": "Last seen", + "masternodes.list.collateral": "Collateral tx", + "masternodes.list.empty": "No masternodes match this page.", + "masternodes.list.error": "Could not load the masternode list.", + "masternodes.list.unknownStatus": "Unknown", + "stats.totalTransactionsEstimated": "Total Transactions (estimated)", + "stats.mainnetHistoryOnly": "Sparkline history is sampled for mainnet only. Live stats above still reflect the selected network.", + "tx.inMempool": "In mempool" } diff --git a/src/messages/es.json b/src/messages/es.json index 58b1e77..e8b1155 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -1048,5 +1048,25 @@ "errorBoundary.fallback": "Ocurrió un error inesperado.", "errorBoundary.reload": "Recargar página", "blocks.filterPageOnly": "Los filtros solo se aplican a esta página de resultados", - "blocks.timeFilterHint": "Solo esta página" + "blocks.timeFilterHint": "Solo esta página", + "home.polling": "Polling", + "home.offline": "Offline", + "peers.privacyTitle": "Peer details are private", + "peers.privacyNote": "IP addresses, client versions, latency, and traffic are redacted by the API. Only aggregate connection counts are shown.", + "masternodes.tabs.list": "List", + "masternodes.list.title": "Masternode list", + "masternodes.list.searchPlaceholder": "Search by address, txid, status, or rank…", + "masternodes.list.filterPageOnly": "Search filters the current page only.", + "masternodes.list.rank": "Rank", + "masternodes.list.status": "Status", + "masternodes.list.address": "Address", + "masternodes.list.active": "Active", + "masternodes.list.lastSeen": "Last seen", + "masternodes.list.collateral": "Collateral tx", + "masternodes.list.empty": "No masternodes match this page.", + "masternodes.list.error": "Could not load the masternode list.", + "masternodes.list.unknownStatus": "Unknown", + "stats.totalTransactionsEstimated": "Total Transactions (estimated)", + "stats.mainnetHistoryOnly": "Sparkline history is sampled for mainnet only. Live stats above still reflect the selected network.", + "tx.inMempool": "In mempool" } diff --git a/src/messages/fr.json b/src/messages/fr.json index c2565a6..5cc1f2c 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -1048,5 +1048,25 @@ "errorBoundary.fallback": "Une erreur inattendue s'est produite.", "errorBoundary.reload": "Recharger la page", "blocks.filterPageOnly": "Les filtres s’appliquent uniquement à cette page de résultats", - "blocks.timeFilterHint": "Cette page uniquement" + "blocks.timeFilterHint": "Cette page uniquement", + "home.polling": "Polling", + "home.offline": "Offline", + "peers.privacyTitle": "Peer details are private", + "peers.privacyNote": "IP addresses, client versions, latency, and traffic are redacted by the API. Only aggregate connection counts are shown.", + "masternodes.tabs.list": "List", + "masternodes.list.title": "Masternode list", + "masternodes.list.searchPlaceholder": "Search by address, txid, status, or rank…", + "masternodes.list.filterPageOnly": "Search filters the current page only.", + "masternodes.list.rank": "Rank", + "masternodes.list.status": "Status", + "masternodes.list.address": "Address", + "masternodes.list.active": "Active", + "masternodes.list.lastSeen": "Last seen", + "masternodes.list.collateral": "Collateral tx", + "masternodes.list.empty": "No masternodes match this page.", + "masternodes.list.error": "Could not load the masternode list.", + "masternodes.list.unknownStatus": "Unknown", + "stats.totalTransactionsEstimated": "Total Transactions (estimated)", + "stats.mainnetHistoryOnly": "Sparkline history is sampled for mainnet only. Live stats above still reflect the selected network.", + "tx.inMempool": "In mempool" } diff --git a/src/messages/ja.json b/src/messages/ja.json index 8237ab1..e8c2ee9 100644 --- a/src/messages/ja.json +++ b/src/messages/ja.json @@ -1048,5 +1048,25 @@ "errorBoundary.fallback": "予期しないエラーが発生しました。", "errorBoundary.reload": "ページを再読み込み", "blocks.filterPageOnly": "フィルターはこのページの結果にのみ適用されます", - "blocks.timeFilterHint": "このページのみ" + "blocks.timeFilterHint": "このページのみ", + "home.polling": "Polling", + "home.offline": "Offline", + "peers.privacyTitle": "Peer details are private", + "peers.privacyNote": "IP addresses, client versions, latency, and traffic are redacted by the API. Only aggregate connection counts are shown.", + "masternodes.tabs.list": "List", + "masternodes.list.title": "Masternode list", + "masternodes.list.searchPlaceholder": "Search by address, txid, status, or rank…", + "masternodes.list.filterPageOnly": "Search filters the current page only.", + "masternodes.list.rank": "Rank", + "masternodes.list.status": "Status", + "masternodes.list.address": "Address", + "masternodes.list.active": "Active", + "masternodes.list.lastSeen": "Last seen", + "masternodes.list.collateral": "Collateral tx", + "masternodes.list.empty": "No masternodes match this page.", + "masternodes.list.error": "Could not load the masternode list.", + "masternodes.list.unknownStatus": "Unknown", + "stats.totalTransactionsEstimated": "Total Transactions (estimated)", + "stats.mainnetHistoryOnly": "Sparkline history is sampled for mainnet only. Live stats above still reflect the selected network.", + "tx.inMempool": "In mempool" } diff --git a/src/messages/ko.json b/src/messages/ko.json index 691693f..b29350f 100644 --- a/src/messages/ko.json +++ b/src/messages/ko.json @@ -1048,5 +1048,25 @@ "errorBoundary.fallback": "예기치 않은 오류가 발생했습니다.", "errorBoundary.reload": "페이지 새로고침", "blocks.filterPageOnly": "필터는 현재 결과 페이지에만 적용됩니다", - "blocks.timeFilterHint": "이 페이지만" + "blocks.timeFilterHint": "이 페이지만", + "home.polling": "Polling", + "home.offline": "Offline", + "peers.privacyTitle": "Peer details are private", + "peers.privacyNote": "IP addresses, client versions, latency, and traffic are redacted by the API. Only aggregate connection counts are shown.", + "masternodes.tabs.list": "List", + "masternodes.list.title": "Masternode list", + "masternodes.list.searchPlaceholder": "Search by address, txid, status, or rank…", + "masternodes.list.filterPageOnly": "Search filters the current page only.", + "masternodes.list.rank": "Rank", + "masternodes.list.status": "Status", + "masternodes.list.address": "Address", + "masternodes.list.active": "Active", + "masternodes.list.lastSeen": "Last seen", + "masternodes.list.collateral": "Collateral tx", + "masternodes.list.empty": "No masternodes match this page.", + "masternodes.list.error": "Could not load the masternode list.", + "masternodes.list.unknownStatus": "Unknown", + "stats.totalTransactionsEstimated": "Total Transactions (estimated)", + "stats.mainnetHistoryOnly": "Sparkline history is sampled for mainnet only. Live stats above still reflect the selected network.", + "tx.inMempool": "In mempool" } diff --git a/src/messages/ru.json b/src/messages/ru.json index cf305ea..cf7ad9e 100644 --- a/src/messages/ru.json +++ b/src/messages/ru.json @@ -1048,5 +1048,25 @@ "errorBoundary.fallback": "Произошла непредвиденная ошибка.", "errorBoundary.reload": "Перезагрузить страницу", "blocks.filterPageOnly": "Фильтры применяются только к этой странице результатов", - "blocks.timeFilterHint": "Только эта страница" + "blocks.timeFilterHint": "Только эта страница", + "home.polling": "Polling", + "home.offline": "Offline", + "peers.privacyTitle": "Peer details are private", + "peers.privacyNote": "IP addresses, client versions, latency, and traffic are redacted by the API. Only aggregate connection counts are shown.", + "masternodes.tabs.list": "List", + "masternodes.list.title": "Masternode list", + "masternodes.list.searchPlaceholder": "Search by address, txid, status, or rank…", + "masternodes.list.filterPageOnly": "Search filters the current page only.", + "masternodes.list.rank": "Rank", + "masternodes.list.status": "Status", + "masternodes.list.address": "Address", + "masternodes.list.active": "Active", + "masternodes.list.lastSeen": "Last seen", + "masternodes.list.collateral": "Collateral tx", + "masternodes.list.empty": "No masternodes match this page.", + "masternodes.list.error": "Could not load the masternode list.", + "masternodes.list.unknownStatus": "Unknown", + "stats.totalTransactionsEstimated": "Total Transactions (estimated)", + "stats.mainnetHistoryOnly": "Sparkline history is sampled for mainnet only. Live stats above still reflect the selected network.", + "tx.inMempool": "In mempool" } diff --git a/src/messages/zh.json b/src/messages/zh.json index a3f33e4..c55fc5b 100644 --- a/src/messages/zh.json +++ b/src/messages/zh.json @@ -1048,5 +1048,25 @@ "errorBoundary.fallback": "发生了意外错误。", "errorBoundary.reload": "重新加载页面", "blocks.filterPageOnly": "筛选仅适用于当前页结果", - "blocks.timeFilterHint": "仅本页" + "blocks.timeFilterHint": "仅本页", + "home.polling": "Polling", + "home.offline": "Offline", + "peers.privacyTitle": "Peer details are private", + "peers.privacyNote": "IP addresses, client versions, latency, and traffic are redacted by the API. Only aggregate connection counts are shown.", + "masternodes.tabs.list": "List", + "masternodes.list.title": "Masternode list", + "masternodes.list.searchPlaceholder": "Search by address, txid, status, or rank…", + "masternodes.list.filterPageOnly": "Search filters the current page only.", + "masternodes.list.rank": "Rank", + "masternodes.list.status": "Status", + "masternodes.list.address": "Address", + "masternodes.list.active": "Active", + "masternodes.list.lastSeen": "Last seen", + "masternodes.list.collateral": "Collateral tx", + "masternodes.list.empty": "No masternodes match this page.", + "masternodes.list.error": "Could not load the masternode list.", + "masternodes.list.unknownStatus": "Unknown", + "stats.totalTransactionsEstimated": "Total Transactions (estimated)", + "stats.mainnetHistoryOnly": "Sparkline history is sampled for mainnet only. Live stats above still reflect the selected network.", + "tx.inMempool": "In mempool" } diff --git a/src/pages/peers.tsx b/src/pages/peers.tsx index dd635d9..33a3173 100644 --- a/src/pages/peers.tsx +++ b/src/pages/peers.tsx @@ -2,35 +2,25 @@ import { AlertTriangle, ArrowDownLeft, ArrowUpRight, + Info, Network, Users, } from 'lucide-react' import { useTranslations } from '@/lib/i18n' -import { usePeers, type Peer } from '@/hooks/use-peers' -import { formatBytes, formatNumber } from '@/lib/format' +import { usePeers } from '@/hooks/use-peers' +import { formatNumber } from '@/lib/format' import { ListHeader } from '@/components/detail/list-header' import { SectionCard } from '@/components/detail/section-card' import { StatTile, StatTileGrid } from '@/components/detail/stat-tile' -import { RelativeTime } from '@/components/detail/relative-time' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' -import { cn } from '@/lib/utils' - -type Translate = (key: string, params?: Record) => string - -function cleanSubver(subver: string): string { - return subver.replace(/^\/(.*)\/$/, '$1') -} - -function formatLatency(pingtime: number): string { - return pingtime > 0 ? `${(pingtime * 1000).toFixed(0)} ms` : '—' -} - -/** Total bytes exchanged with a peer (sent + received). */ -function formatTraffic(peer: Peer): string { - return formatBytes(peer.bytessent + peer.bytesrecv) -} +/** + * Peers page — aggregate counts only. + * + * `GET /api/peers` redacts IP, client, latency, and traffic. The UI shows the + * public totals (inbound / outbound) instead of empty per-peer columns. + */ export default function PeersPage() { const t = useTranslations('peers') const common = useTranslations('common') @@ -81,7 +71,7 @@ export default function PeersPage() { } /> - + - - {formatNumber(data.total)} - - } - > - {data.peers.length > 0 ? ( - <> - {/* Mobile: stacked cards */} -
      - {data.peers.map((peer) => ( - - ))} -
    - - {/* Desktop: table */} -
    - - - - - - - - - - - - - - {data.peers.map((peer) => ( - - - - - - - - - - ))} - -
    {t('tableAddress')}{t('tableClient')}{t('tableDirection')}{t('tableLatency')}{t('tableData')}{t('tableConnected')}{t('tableHeight')}
    - - - {peer.inbound ? ( - - ) : ( - - )} - - {peer.addr} - - - {cleanSubver(peer.subver) || t('unknown')} - - - - {formatLatency(peer.pingtime)} - - {formatTraffic(peer)} - - - - {formatNumber(peer.synced_headers)} -
    -
    - - ) : ( -
    + {data.total === 0 ? ( + +

    {t('noPeers')}

    - )} -
    -
    - ) -} - -function DirectionBadge({ inbound, t }: { inbound: boolean; t: Translate }) { - return ( - - {inbound ? : } - {inbound ? t('inboundBadge') : t('outboundBadge')} - - ) -} +
    + ) : null} -function PeerCard({ peer, t }: { peer: Peer; t: Translate }) { - return ( -
  • -
    - - - {peer.inbound ? ( - - ) : ( - - )} - - {peer.addr} - - -
    -
    - {cleanSubver(peer.subver) || t('unknown')} - {formatLatency(peer.pingtime)} - - - {t('tableHeight')}: {formatNumber(peer.synced_headers)} - - {t('tableData')}: {formatTraffic(peer)} +
    + +
    +

    {t('privacyTitle')}

    +

    {t('privacyNote')}

    +
    -
  • +
    ) } @@ -258,16 +130,7 @@ function PeersSkeleton() { ))}
    -
    -
      - {Array.from({ length: 6 }).map((_, i) => ( -
    • - - -
    • - ))} -
    -
    + ) } diff --git a/vite.config.ts b/vite.config.ts index a47988f..0afa1a4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,8 @@ import path from 'path' // win is the per-route code-splitting from React.lazy() in src/App.tsx (the 14 // non-home pages, and recharts via the lazy /stats page, stay out of the home). +const apiTarget = process.env.VITE_API_TARGET || 'http://localhost:8080' + export default defineConfig({ plugins: [tailwindcss(), react()], resolve: { @@ -28,7 +30,13 @@ export default defineConfig({ // Override target with VITE_API_TARGET to point at a remote API (e.g. prod) // when no local FairCoin node is running. // Not used by the production build (Express serves the static dist). - '/api': process.env.VITE_API_TARGET || 'http://localhost:8080', + // `ws: true` upgrades `/api/ws` so the browser socket reaches the API in + // local dev instead of silently falling back to 30s HTTP polling. + '/api': { + target: apiTarget, + changeOrigin: true, + ws: true, + }, }, }, })