pagination in tx page

This commit is contained in:
David Rusu 2026-02-16 23:58:06 +04:00
parent e2b61db480
commit a8d350b601
8 changed files with 276 additions and 147 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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