From a8d350b601e6f5b7816068f02880cd764a70fbab Mon Sep 17 00:00:00 2001 From: David Rusu Date: Mon, 16 Feb 2026 23:58:06 +0400 Subject: [PATCH] pagination in tx page --- src/api/v1/router.py | 1 + src/api/v1/transactions.py | 20 ++ src/db/transaction.py | 33 ++- static/components/BlocksTable.js | 56 +----- static/components/TransactionsTable.js | 266 ++++++++++++++++--------- static/lib/api.js | 3 + static/pages/Home.js | 39 +++- static/styles.css | 5 + 8 files changed, 276 insertions(+), 147 deletions(-) diff --git a/src/api/v1/router.py b/src/api/v1/router.py index 6e3d36f..1eb2567 100644 --- a/src/api/v1/router.py +++ b/src/api/v1/router.py @@ -15,6 +15,7 @@ def create_v1_router() -> APIRouter: router.add_api_route("/health", health.get, methods=["GET", "HEAD"]) router.add_api_route("/transactions/stream", transactions.stream, methods=["GET"]) + router.add_api_route("/transactions/list", transactions.list_transactions, methods=["GET"]) router.add_api_route("/transactions/{transaction_hash:str}", transactions.get, methods=["GET"]) router.add_api_route("/fork-choice", fork_choice.get, methods=["GET"]) diff --git a/src/api/v1/transactions.py b/src/api/v1/transactions.py index 1f1e7ff..f3319a3 100644 --- a/src/api/v1/transactions.py +++ b/src/api/v1/transactions.py @@ -41,6 +41,26 @@ async def stream( return NDJsonStreamingResponse(ndjson_transactions_stream) +async def list_transactions( + request: NBERequest, + page: int = Query(0, ge=0), + page_size: int = Query(10, ge=1, le=100, alias="page-size"), + fork: int = Query(...), +) -> Response: + transactions, total_count = await request.app.state.transaction_repository.get_paginated( + page, page_size, fork=fork + ) + total_pages = (total_count + page_size - 1) // page_size + + return JSONResponse({ + "transactions": [TransactionRead.from_transaction(tx).model_dump(mode="json") for tx in transactions], + "page": page, + "page_size": page_size, + "total_count": total_count, + "total_pages": total_pages, + }) + + async def get(request: NBERequest, transaction_hash: str, fork: int = Query(...)) -> Response: if not transaction_hash: return Response(status_code=NOT_FOUND) diff --git a/src/db/transaction.py b/src/db/transaction.py index 892cd3e..b01b001 100644 --- a/src/db/transaction.py +++ b/src/db/transaction.py @@ -2,7 +2,7 @@ from asyncio import sleep from typing import AsyncIterator, List from rusty_results import Empty, Option, Some -from sqlalchemy import Result, Select +from sqlalchemy import Result, Select, func as sa_func from sqlalchemy.orm import aliased, selectinload from sqlmodel import select @@ -77,6 +77,37 @@ class TransactionRepository: results: Result[Transaction] = session.exec(statement) return results.all() + async def get_paginated(self, page: int, page_size: int, *, fork: int) -> tuple[List[Transaction], int]: + """ + Get transactions with pagination, ordered by block height descending (newest first). + Returns a tuple of (transactions, total_count). + """ + offset = page * page_size + + with self.client.session() as session: + # Get total count for this fork + count_statement = ( + select(sa_func.count()) + .select_from(Transaction) + .join(Block, Transaction.block_id == Block.id) + .where(Block.fork == fork) + ) + total_count = session.exec(count_statement).one() + + # Get paginated transactions + statement = ( + select(Transaction) + .options(selectinload(Transaction.block)) + .join(Block, Transaction.block_id == Block.id) + .where(Block.fork == fork) + .order_by(Block.height.desc(), Transaction.id.desc()) + .offset(offset) + .limit(page_size) + ) + transactions = session.exec(statement).all() + + return transactions, total_count + async def updates_stream( self, transaction_from: Option[Transaction], *, fork: int, timeout_seconds: int = 1 ) -> AsyncIterator[List[Transaction]]: diff --git a/static/components/BlocksTable.js b/static/components/BlocksTable.js index 72524f1..8ca5078 100644 --- a/static/components/BlocksTable.js +++ b/static/components/BlocksTable.js @@ -25,14 +25,13 @@ const normalize = (raw) => { }; }; -export default function BlocksTable() { +export default function BlocksTable({ live }) { const [blocks, setBlocks] = useState([]); const [page, setPage] = useState(0); const [totalPages, setTotalPages] = useState(0); const [totalCount, setTotalCount] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [live, setLive] = useState(true); // Start in live mode const [fork, setFork] = useState(null); const abortRef = useRef(null); @@ -110,27 +109,19 @@ export default function BlocksTable() { if (live) { startLiveStream(fork); } else { - fetchBlocks(page, fork); + setPage(0); + fetchBlocks(0, fork); } return () => abortRef.current?.abort(); }, [live, fork, startLiveStream]); - // Go to a page (turns off live mode) + // Go to a page const goToPage = (newPage) => { if (newPage >= 0 && fork != null) { - setLive(false); fetchBlocks(newPage, fork); } }; - // Toggle live mode - const toggleLive = () => { - if (!live) { - setLive(true); - setPage(0); - } - }; - const navigateToBlockDetail = (blockHash) => { history.pushState({}, '', PAGE.BLOCK_DETAIL(blockHash)); window.dispatchEvent(new PopStateEvent('popstate')); @@ -209,46 +200,13 @@ export default function BlocksTable() { } } - // Live button styles - const liveButtonStyle = live - ? ` - cursor: pointer; - background: #ff4444; - color: white; - border: none; - animation: live-pulse 1.5s ease-in-out infinite; - ` - : ` - cursor: pointer; - background: var(--bg-secondary, #333); - color: var(--muted, #888); - border: 1px solid var(--border, #444); - `; - return h( 'div', { class: 'card' }, - // Inject keyframes for the pulse animation - h('style', null, ` - @keyframes live-pulse { - 0%, 100% { box-shadow: 0 0 4px #ff4444, 0 0 8px #ff4444; } - 50% { box-shadow: 0 0 8px #ff4444, 0 0 16px #ff4444, 0 0 24px #ff6666; } - } - `), h( 'div', { class: 'card-header', style: 'display:flex; justify-content:space-between; align-items:center;' }, h('div', null, h('strong', null, 'Blocks '), h('span', { class: 'pill' }, String(totalCount))), - h( - 'button', - { - class: 'pill', - style: liveButtonStyle, - onClick: toggleLive, - title: live ? 'Live updates enabled' : 'Click to enable live updates', - }, - live ? 'LIVE \u2022' : 'LIVE', - ), ), h( 'div', @@ -294,7 +252,7 @@ export default function BlocksTable() { 'button', { class: 'pill', - disabled: page === 0 || loading, + disabled: live || page === 0 || loading, onClick: () => goToPage(page - 1), style: 'cursor:pointer;', }, @@ -309,8 +267,8 @@ export default function BlocksTable() { 'button', { class: 'pill', - disabled: (!live && page >= totalPages - 1) || loading, - onClick: () => live ? goToPage(0) : goToPage(page + 1), + disabled: live || page >= totalPages - 1 || loading, + onClick: () => goToPage(page + 1), style: 'cursor:pointer;', }, 'Next', diff --git a/static/components/TransactionsTable.js b/static/components/TransactionsTable.js index ee257cb..de02016 100644 --- a/static/components/TransactionsTable.js +++ b/static/components/TransactionsTable.js @@ -1,36 +1,13 @@ // static/components/TransactionsTable.js import { h } from 'preact'; -import { useEffect, useRef, useState } from 'preact/hooks'; +import { useEffect, useState, useCallback, useRef } from 'preact/hooks'; import { API, PAGE } from '../lib/api.js'; import { TABLE_SIZE } from '../lib/constants.js'; -import { - streamNdjson, - ensureFixedRowCount, - shortenHex, // (kept in case you want to use later) - withBenignFilter, -} from '../lib/utils.js'; +import { shortenHex, streamNdjson } from '../lib/utils.js'; import { subscribeFork } from '../lib/fork.js'; const OPERATIONS_PREVIEW_LIMIT = 2; -// ---------- small DOM helpers ---------- -function createSpan(className, text, title) { - const el = document.createElement('span'); - if (className) el.className = className; - if (title) el.title = title; - el.textContent = text; - return el; -} - -function createLink(href, text, title) { - const el = document.createElement('a'); - el.className = 'linkish mono'; - el.href = href; - if (title) el.title = title; - el.textContent = text; - return el; -} - // ---------- coercion / formatting helpers ---------- const toNumber = (v) => { if (v == null) return 0; @@ -97,7 +74,7 @@ function opPreview(op) { } function formatOperationsPreview(ops) { - if (!ops?.length) return '—'; + if (!ops?.length) return '\u2014'; const previews = ops.map(opPreview); if (previews.length <= OPERATIONS_PREVIEW_LIMIT) return previews.join(', '); const head = previews.slice(0, OPERATIONS_PREVIEW_LIMIT).join(', '); @@ -106,10 +83,8 @@ function formatOperationsPreview(ops) { } // ---------- normalize API → view model ---------- -function normalizeTransaction(raw) { - // { id, block_id, hash, operations:[Operation], inputs:[HexBytes], outputs:[Note], proof, execution_gas_price, storage_gas_price, created_at? } +function normalize(raw) { const ops = Array.isArray(raw?.operations) ? raw.operations : Array.isArray(raw?.ops) ? raw.ops : []; - const outputs = Array.isArray(raw?.outputs) ? raw.outputs : []; const totalOutputValue = outputs.reduce((sum, note) => sum + toNumber(note?.value), 0); @@ -124,102 +99,166 @@ function normalizeTransaction(raw) { }; } -// ---------- row builder ---------- -function buildTransactionRow(tx) { - const tr = document.createElement('tr'); - - // Hash (replaces ID) - const tdId = document.createElement('td'); - tdId.className = 'mono'; - tdId.appendChild(createLink(PAGE.TRANSACTION_DETAIL(tx.hash), shortenHex(tx.hash), tx.hash)); - - // Operations (preview) - const tdOps = document.createElement('td'); - tdOps.style.whiteSpace = 'normal'; - tdOps.style.lineHeight = '1.4'; - const preview = formatOperationsPreview(tx.operations); - const fullPreview = Array.isArray(tx.operations) ? tx.operations.map(opPreview).join(', ') : ''; - tdOps.appendChild(createSpan('', preview, fullPreview)); - - // Outputs (count / total) - const tdOut = document.createElement('td'); - tdOut.className = 'amount'; - tdOut.textContent = `${tx.numberOfOutputs} / ${tx.totalOutputValue.toLocaleString(undefined, { maximumFractionDigits: 8 })}`; - - tr.append(tdId, tdOps, tdOut); - return tr; -} - // ---------- component ---------- -export default function TransactionsTable() { - const bodyRef = useRef(null); - const countRef = useRef(null); - const abortRef = useRef(null); - const totalCountRef = useRef(0); +export default function TransactionsTable({ live }) { + const [transactions, setTransactions] = useState([]); + const [page, setPage] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [fork, setFork] = useState(null); + const abortRef = useRef(null); + const seenKeysRef = useRef(new Set()); + // Subscribe to fork-choice changes useEffect(() => { return subscribeFork((newFork) => setFork(newFork)); }, []); - useEffect(() => { - if (fork == null) return; + // Fetch paginated transactions + const fetchTransactions = useCallback(async (pageNum, currentFork) => { + abortRef.current?.abort(); + seenKeysRef.current.clear(); - const body = bodyRef.current; - const counter = countRef.current; - - // Clear existing rows on fork change - while (body.rows.length > 0) body.deleteRow(0); - totalCountRef.current = 0; - counter.textContent = '0'; - - // 3 columns: Hash | Operations | Outputs - ensureFixedRowCount(body, 3, TABLE_SIZE); + setLoading(true); + setError(null); + try { + const res = await fetch(API.TRANSACTIONS_LIST(pageNum, TABLE_SIZE, currentFork)); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setTransactions(data.transactions.map(normalize)); + setTotalPages(data.total_pages); + setTotalCount(data.total_count); + setPage(data.page); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + }, []); + // Start live streaming + const startLiveStream = useCallback((currentFork) => { abortRef.current?.abort(); abortRef.current = new AbortController(); + seenKeysRef.current.clear(); + setTransactions([]); + setLoading(true); + setError(null); - const url = `${API.TRANSACTIONS_STREAM_WITH_FORK(fork)}&prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`; + let liveTxs = []; + const url = `${API.TRANSACTIONS_STREAM_WITH_FORK(currentFork)}&prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`; streamNdjson( url, (raw) => { - try { - const tx = normalizeTransaction(raw); - const row = buildTransactionRow(tx); - body.insertBefore(row, body.firstChild); + const tx = normalize(raw); + const key = `${tx.id}:${tx.hash}`; + if (seenKeysRef.current.has(key)) return; + seenKeysRef.current.add(key); - while (body.rows.length > TABLE_SIZE) body.deleteRow(-1); - counter.textContent = String(++totalCountRef.current); - } catch (err) { - console.error('Failed to render transaction row:', err, raw); - } + liveTxs = [tx, ...liveTxs].slice(0, TABLE_SIZE); + setTransactions([...liveTxs]); + setTotalCount(liveTxs.length); + setLoading(false); }, { signal: abortRef.current.signal, - onError: withBenignFilter( - (err) => console.error('Transactions stream error:', err), - abortRef.current.signal, - ), + onError: (e) => { + if (e?.name !== 'AbortError') { + console.error('Transactions stream error:', e); + setError(e?.message || 'Stream error'); + } + }, }, - ).catch((err) => { - if (!abortRef.current.signal.aborted) { - console.error('Transactions stream connection error:', err); - } - }); + ); + }, []); + // Handle live mode and fork changes + useEffect(() => { + if (fork == null) return; + if (live) { + startLiveStream(fork); + } else { + setPage(0); + fetchTransactions(0, fork); + } return () => abortRef.current?.abort(); - }, [fork]); + }, [live, fork, startLiveStream]); + + // Go to a page + const goToPage = (newPage) => { + if (newPage >= 0 && fork != null) { + fetchTransactions(newPage, fork); + } + }; + + const navigateToTxDetail = (txHash) => { + history.pushState({}, '', PAGE.TRANSACTION_DETAIL(txHash)); + window.dispatchEvent(new PopStateEvent('popstate')); + }; + + const renderRow = (tx, idx) => { + const opsPreview = formatOperationsPreview(tx.operations); + const fullPreview = Array.isArray(tx.operations) ? tx.operations.map(opPreview).join(', ') : ''; + const outputsText = `${tx.numberOfOutputs} / ${tx.totalOutputValue.toLocaleString(undefined, { maximumFractionDigits: 8 })}`; + + return h( + 'tr', + { key: tx.id || idx }, + // Hash + h( + 'td', + null, + h( + 'a', + { + class: 'linkish mono', + href: PAGE.TRANSACTION_DETAIL(tx.hash), + title: tx.hash, + onClick: (e) => { + e.preventDefault(); + navigateToTxDetail(tx.hash); + }, + }, + shortenHex(tx.hash), + ), + ), + // Operations + h('td', { style: 'white-space:normal; line-height:1.4;' }, h('span', { title: fullPreview }, opsPreview)), + // Outputs + h('td', { class: 'amount' }, outputsText), + ); + }; + + const renderPlaceholderRow = (idx) => { + return h( + 'tr', + { key: `ph-${idx}`, class: 'ph' }, + h('td', null, '\u00A0'), + h('td', null, '\u00A0'), + h('td', null, '\u00A0'), + ); + }; + + const rows = []; + for (let i = 0; i < TABLE_SIZE; i++) { + if (i < transactions.length) { + rows.push(renderRow(transactions[i], i)); + } else { + rows.push(renderPlaceholderRow(i)); + } + } return h( 'div', { class: 'card' }, h( 'div', - { class: 'card-header' }, - h('div', null, h('strong', null, 'Transactions '), h('span', { class: 'pill', ref: countRef }, '0')), - h('div', { style: 'color:var(--muted); font-size:12px;' }), + { class: 'card-header', style: 'display:flex; justify-content:space-between; align-items:center;' }, + h('div', null, h('strong', null, 'Transactions '), h('span', { class: 'pill' }, String(totalCount))), ), h( 'div', @@ -245,8 +284,43 @@ export default function TransactionsTable() { h('th', null, 'Outputs (count / total)'), ), ), - h('tbody', { ref: bodyRef }), + h('tbody', null, ...rows), ), ), + // Pagination controls + h( + 'div', + { + class: 'card-footer', + style: 'display:flex; justify-content:space-between; align-items:center; padding:8px 14px; border-top:1px solid var(--border);', + }, + h( + 'button', + { + class: 'pill', + disabled: live || page === 0 || loading, + onClick: () => goToPage(page - 1), + style: 'cursor:pointer;', + }, + 'Previous', + ), + h( + 'span', + { style: 'color:var(--muted); font-size:13px;' }, + live ? 'Streaming live transactions...' : totalPages > 0 ? `Page ${page + 1} of ${totalPages}` : 'No transactions', + ), + h( + 'button', + { + class: 'pill', + disabled: live || page >= totalPages - 1 || loading, + onClick: () => goToPage(page + 1), + style: 'cursor:pointer;', + }, + 'Next', + ), + ), + // Error display + error && h('div', { style: 'padding:8px 14px; color:#ff8a8a;' }, `Error: ${error}`), ); } diff --git a/static/lib/api.js b/static/lib/api.js index 214ae80..c47be9d 100644 --- a/static/lib/api.js +++ b/static/lib/api.js @@ -22,6 +22,8 @@ const BLOCKS_LIST = (page, pageSize, fork) => const TRANSACTIONS_STREAM_WITH_FORK = (fork) => `${joinUrl(API_PREFIX, 'transactions/stream')}?fork=${encodeURIComponent(fork)}`; +const TRANSACTIONS_LIST = (page, pageSize, fork) => + `${joinUrl(API_PREFIX, 'transactions/list')}?page=${encodeURIComponent(page)}&page-size=${encodeURIComponent(pageSize)}&fork=${encodeURIComponent(fork)}`; export const API = { HEALTH_ENDPOINT, @@ -29,6 +31,7 @@ export const API = { TRANSACTION_DETAIL_BY_HASH, TRANSACTIONS_STREAM, TRANSACTIONS_STREAM_WITH_FORK, + TRANSACTIONS_LIST, BLOCK_DETAIL_BY_HASH, BLOCKS_STREAM, BLOCKS_LIST, diff --git a/static/pages/Home.js b/static/pages/Home.js index 777011e..ed76799 100644 --- a/static/pages/Home.js +++ b/static/pages/Home.js @@ -1,11 +1,48 @@ import { h } from 'preact'; +import { useState } from 'preact/hooks'; import BlocksTable from '../components/BlocksTable.js'; import TransactionsTable from '../components/TransactionsTable.js'; export default function HomeView() { + const [live, setLive] = useState(true); + + const toggleLive = () => setLive((prev) => !prev); + + const liveButtonStyle = live + ? ` + cursor: pointer; + background: #ff4444; + color: white; + border: none; + animation: live-pulse 1.5s ease-in-out infinite; + ` + : ` + cursor: pointer; + background: var(--bg-secondary, #333); + color: var(--muted, #888); + border: 1px solid var(--border, #444); + `; + return h( 'main', { class: 'wrap' }, - h('section', { class: 'two-columns twocol' }, h(BlocksTable, {}), h(TransactionsTable, {})), + h( + 'div', + { style: 'display:flex; justify-content:flex-end; margin-bottom:12px;' }, + h( + 'button', + { + class: 'pill', + style: liveButtonStyle, + onClick: toggleLive, + title: live ? 'Live updates enabled' : 'Click to enable live updates', + }, + live ? 'LIVE \u2022' : 'LIVE', + ), + ), + h('section', { class: 'two-columns twocol' }, + h(BlocksTable, { live }), + h(TransactionsTable, { live }), + ), ); } diff --git a/static/styles.css b/static/styles.css index 84c2c5e..336e4f8 100644 --- a/static/styles.css +++ b/static/styles.css @@ -151,3 +151,8 @@ tr:nth-child(odd) { tr.ph td { opacity: 0.35; } + +@keyframes live-pulse { + 0%, 100% { box-shadow: 0 0 4px #ff4444, 0 0 8px #ff4444; } + 50% { box-shadow: 0 0 8px #ff4444, 0 0 16px #ff4444, 0 0 24px #ff6666; } +}