mirror of
https://github.com/logos-blockchain/logos-blockchain-block-explorer-template.git
synced 2026-04-03 01:13:12 +00:00
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:
parent
db76ad97f8
commit
2d95bd3baa
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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',
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1 +1 @@
|
||||
export const TABLE_SIZE = 10;
|
||||
export const TABLE_SIZE = 50;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user