feat: add transaction search functionality

- Add search endpoint to backend (GET /api/v1/transactions/search)
- Support search by transaction hash (partial match) or block height
- Add search bar UI to TransactionsTable component
- Increase default page size from 10 to 50 transactions
- Add Block Height and Block Slot columns to transaction table
- Debounce search input (300ms) for better UX

Fixes:
- Fix health endpoint JSON serialization
- Fix main.py import path
This commit is contained in:
waclaw-claw 2026-03-28 03:16:02 -04:00
parent db76ad97f8
commit 2d95bd3baa
8 changed files with 249 additions and 14 deletions

View File

@ -12,6 +12,9 @@ from node.api.serializers.health import HealthSerializer
async def get(request: NBERequest) -> Response:
response = await request.app.state.node_api.get_health()
# HealthSerializer needs to be converted to dict for JSON serialization
if hasattr(response, 'model_dump'):
return JSONResponse(response.model_dump(mode='json'))
return JSONResponse(response)

View File

@ -16,6 +16,7 @@ def create_v1_router() -> APIRouter:
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/search", transactions.search, 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

@ -69,3 +69,38 @@ async def get(request: NBERequest, transaction_hash: str, fork: int = Query(...)
return transaction.map(
lambda _transaction: JSONResponse(TransactionRead.from_transaction(_transaction).model_dump(mode="json"))
).unwrap_or_else(lambda: Response(status_code=NOT_FOUND))
async def search(
request: NBERequest,
q: str = Query(..., description="Search query (hash partial match or block height)"),
page: int = Query(0, ge=0, description="Page number"),
page_size: int = Query(50, ge=1, le=100, description="Items per page"),
fork: int = Query(..., description="Fork ID"),
) -> Response:
"""Search transactions by hash or block height."""
if not q:
return JSONResponse({"transactions": [], "page": page, "page_size": page_size, "total_count": 0, "total_pages": 0})
# Try to parse as block height (integer)
try:
block_height = int(q)
transactions, total_count = await request.app.state.transaction_repository.search_by_block_height(
block_height, fork=fork, page=page, page_size=page_size
)
except ValueError:
# Search by hash (partial match)
transactions, total_count = await request.app.state.transaction_repository.search(
q, fork=fork, page=page, page_size=page_size
)
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 0
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,
"query": q,
})

View File

@ -1,5 +1,5 @@
from asyncio import sleep
from typing import AsyncIterator, List
from typing import AsyncIterator, List, Optional
from rusty_results import Empty, Option, Some
from sqlalchemy import Result, Select, func as sa_func
@ -141,3 +141,91 @@ class TransactionRepository:
yield transactions
else:
await sleep(timeout_seconds)
async def search(
self,
query: str,
*,
fork: int,
page: int = 0,
page_size: int = 50,
) -> tuple[List[Transaction], int]:
"""
Search transactions by hash (partial match).
Returns (transactions, total_count).
"""
offset = page * page_size
chain = chain_block_ids_cte(fork=fork)
# Build search condition: match hash (case-insensitive, partial)
search_term = query.lower()
with self.client.session() as session:
# Get all transactions in the chain
all_statement = (
select(Transaction)
.options(selectinload(Transaction.block))
.join(Block, Transaction.block_id == Block.id)
.join(chain, Block.id == chain.c.id)
.order_by(Block.height.desc(), Transaction.id.desc())
)
all_transactions = session.exec(all_statement).all()
# Filter in Python for hash matching
filtered = []
for tx in all_transactions:
# Convert hash bytes to hex string and check if search term is in it
hex_hash = tx.hash.hex().lower() if hasattr(tx.hash, 'hex') else bytes(tx.hash).hex().lower()
if search_term in hex_hash:
filtered.append(tx)
# Apply pagination
total_count = len(filtered)
transactions = filtered[offset:offset + page_size]
return transactions, total_count
async def search_by_block_height(
self,
block_height: int,
*,
fork: int,
page: int = 0,
page_size: int = 50,
) -> tuple[List[Transaction], int]:
"""
Search transactions by block height.
Returns (transactions, total_count).
"""
offset = page * page_size
chain = chain_block_ids_cte(fork=fork)
with self.client.session() as session:
# Count total matching transactions
count_statement = (
select(sa_func.count())
.select_from(Transaction)
.join(Block, Transaction.block_id == Block.id)
.join(chain, Block.id == chain.c.id)
.where(Block.height == block_height)
)
total_count = session.exec(count_statement).one()
if total_count == 0:
return [], 0
# Get matching transactions
statement = (
select(Transaction)
.options(selectinload(Transaction.block))
.join(Block, Transaction.block_id == Block.id)
.join(chain, Block.id == chain.c.id)
.where(Block.height == block_height)
.order_by(Transaction.id.desc())
.offset(offset)
.limit(page_size)
)
transactions = session.exec(statement).all()
return transactions, total_count

View File

@ -4,7 +4,7 @@ from os import getenv
import uvicorn
from dotenv import load_dotenv
from app import create_app
from src.app import create_app
from logs import setup_logging

View File

@ -82,11 +82,12 @@ function formatOperationsPreview(ops) {
return `${head} +${remainder}`;
}
// ---------- normalize API → view model ----------
// ---------- normalize API → view model ----------
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);
const block = raw?.block ?? {};
return {
id: raw?.id ?? '',
@ -96,6 +97,8 @@ function normalize(raw) {
storageGasPrice: toNumber(raw?.storage_gas_price),
numberOfOutputs: outputs.length,
totalOutputValue,
blockHeight: block?.height ?? 0,
blockSlot: block?.slot ?? 0,
};
}
@ -108,6 +111,8 @@ export default function TransactionsTable({ live, onDisableLive }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [fork, setFork] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const abortRef = useRef(null);
const seenKeysRef = useRef(new Set());
@ -117,7 +122,7 @@ export default function TransactionsTable({ live, onDisableLive }) {
return subscribeFork((newFork) => setFork(newFork));
}, []);
// Fetch paginated transactions
// Fetch paginated transactions (normal mode)
const fetchTransactions = useCallback(async (pageNum, currentFork) => {
abortRef.current?.abort();
seenKeysRef.current.clear();
@ -139,6 +144,43 @@ export default function TransactionsTable({ live, onDisableLive }) {
}
}, []);
// Search transactions
const searchTransactions = useCallback(async (query, pageNum, currentFork) => {
if (!query) return;
setIsSearching(true);
abortRef.current?.abort();
seenKeysRef.current.clear();
setLoading(true);
setError(null);
try {
const res = await fetch(API.TRANSACTIONS_SEARCH(query, 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);
setSearchQuery(query);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
setIsSearching(false);
}
}, []);
// Clear search
const clearSearch = useCallback(() => {
setSearchQuery('');
setIsSearching(false);
setPage(0);
if (fork !== null) {
fetchTransactions(0, fork);
}
}, [fork, fetchTransactions]);
// Start live streaming
const startLiveStream = useCallback((currentFork) => {
abortRef.current?.abort();
@ -180,12 +222,36 @@ export default function TransactionsTable({ live, onDisableLive }) {
if (fork == null) return;
if (live) {
startLiveStream(fork);
} else if (isSearching) {
// In search mode, don't auto-fetch - let user control
// Only fetch on page 0 when search is initiated
} else {
setPage(0);
fetchTransactions(0, fork);
}
return () => abortRef.current?.abort();
}, [live, fork, startLiveStream]);
}, [live, fork, startLiveStream, isSearching]);
// Handle search query changes (debounced)
const searchTimeoutRef = useRef(null);
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
if (searchQuery && fork !== null) {
searchTimeoutRef.current = setTimeout(() => {
searchTransactions(searchQuery, 0, fork);
}, 300); // Debounce search by 300ms
} else if (!searchQuery && fork !== null) {
// Clear search and fetch normal list
fetchTransactions(0, fork);
}
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, fork, searchTransactions]);
// Go to a page (or exit live mode into page 0)
const goToPage = (newPage) => {
@ -194,7 +260,10 @@ export default function TransactionsTable({ live, onDisableLive }) {
onDisableLive?.();
return; // useEffect will handle fetching page 0 when live changes
}
if (newPage >= 0) {
if (isSearching) {
// In search mode, search with new page
searchTransactions(searchQuery, newPage, fork);
} else if (newPage >= 0) {
fetchTransactions(newPage, fork);
}
};
@ -204,7 +273,7 @@ export default function TransactionsTable({ live, onDisableLive }) {
window.dispatchEvent(new PopStateEvent('popstate'));
};
const renderRow = (tx, idx) => {
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 })}`;
@ -230,8 +299,12 @@ export default function TransactionsTable({ live, onDisableLive }) {
shortenHex(tx.hash),
),
),
// Block Height
h('td', { class: 'mono' }, String(tx.blockHeight)),
// Operations
h('td', { style: 'white-space:normal; line-height:1.4;' }, h('span', { title: fullPreview }, opsPreview)),
// Block Slot
h('td', { class: 'mono', style: 'font-size:12px; color:var(--muted);' }, `Slot ${tx.blockSlot}`),
// Outputs
h('td', { class: 'amount' }, outputsText),
);
@ -261,12 +334,38 @@ export default function TransactionsTable({ live, onDisableLive }) {
{ class: 'card' },
h(
'div',
{ class: 'card-header', style: 'display:flex; justify-content:space-between; align-items:center;' },
{
class: 'card-header',
style:
'display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;',
},
h(
'div',
null,
{ style: 'display:flex; align-items:center; gap:8px;' },
h('strong', null, 'Transactions '),
!live && totalCount > 0 && h('span', { class: 'pill' }, String(totalCount)),
!live && !isSearching && totalCount > 0 &&
h('span', { class: 'pill' }, String(totalCount)),
isSearching &&
h('span', { class: 'pill', style: 'background:var(--primary); color:white;' }, `Search: ${searchQuery}`),
),
// Search bar
h('div', {
style: 'display:flex; gap:4px; flex:1; max-width:400px; min-width:200px;',
},
h('input', {
type: 'text',
placeholder: 'Search by hash or block height...',
value: searchQuery,
onInput: (e) => setSearchQuery(e.target.value),
style:
'flex:1; padding:8px 12px; border:1px solid var(--border); border-radius:4px; background:var(--bg-secondary); color:var(--text); font-size:14px;',
}),
searchQuery &&
h('button', {
class: 'pill',
style: 'background:var(--danger); color:white; padding:8px 12px;',
onClick: clearSearch,
}, '✕'),
),
),
h(
@ -278,9 +377,11 @@ export default function TransactionsTable({ live, onDisableLive }) {
h(
'colgroup',
null,
h('col', { style: 'width:240px' }), // Hash
h('col', { style: 'width:180px' }), // Hash
h('col', { style: 'width:100px' }), // Block Height
h('col', null), // Operations
h('col', { style: 'width:200px' }), // Outputs (count / total)
h('col', { style: 'width:120px' }), // Timestamp
h('col', { style: 'width:180px' }), // Outputs (count / total)
),
h(
'thead',
@ -289,7 +390,9 @@ export default function TransactionsTable({ live, onDisableLive }) {
'tr',
null,
h('th', null, 'Hash'),
h('th', null, 'Block'),
h('th', null, 'Operations'),
h('th', null, 'Slot'),
h('th', null, 'Outputs (count / total)'),
),
),
@ -317,7 +420,9 @@ export default function TransactionsTable({ live, onDisableLive }) {
{ style: 'color:var(--muted); font-size:13px;' },
live
? 'Streaming live transactions...'
: totalPages > 0
: isSearching
? `Search results: ${totalCount} found for "${searchQuery}"`
: totalPages > 0
? `Page ${page + 1} of ${totalPages}`
: 'No transactions',
),

View File

@ -23,6 +23,8 @@ 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)}`;
const TRANSACTIONS_SEARCH = (query, page, pageSize, fork) =>
`${joinUrl(API_PREFIX, 'transactions/search')}?q=${encodeURIComponent(query)}&page=${encodeURIComponent(page)}&page-size=${encodeURIComponent(pageSize)}&fork=${encodeURIComponent(fork)}`;
export const API = {
HEALTH_ENDPOINT,
@ -31,6 +33,7 @@ export const API = {
TRANSACTIONS_STREAM,
TRANSACTIONS_STREAM_WITH_FORK,
TRANSACTIONS_LIST,
TRANSACTIONS_SEARCH,
BLOCK_DETAIL_BY_HASH,
BLOCKS_STREAM,
BLOCKS_LIST,

View File

@ -1 +1 @@
export const TABLE_SIZE = 10;
export const TABLE_SIZE = 50;