diff --git a/src/api/v1/health.py b/src/api/v1/health.py index a00c5df..35e3416 100644 --- a/src/api/v1/health.py +++ b/src/api/v1/health.py @@ -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) diff --git a/src/api/v1/router.py b/src/api/v1/router.py index 1eb2567..3c582c7 100644 --- a/src/api/v1/router.py +++ b/src/api/v1/router.py @@ -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"]) diff --git a/src/api/v1/transactions.py b/src/api/v1/transactions.py index 57729d8..6592b73 100644 --- a/src/api/v1/transactions.py +++ b/src/api/v1/transactions.py @@ -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, + }) diff --git a/src/db/transaction.py b/src/db/transaction.py index bb4e55d..204a719 100644 --- a/src/db/transaction.py +++ b/src/db/transaction.py @@ -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 diff --git a/src/main.py b/src/main.py index 634487f..8de870c 100644 --- a/src/main.py +++ b/src/main.py @@ -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 diff --git a/static/components/TransactionsTable.js b/static/components/TransactionsTable.js index 8e751a2..0944dd9 100644 --- a/static/components/TransactionsTable.js +++ b/static/components/TransactionsTable.js @@ -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', ), diff --git a/static/lib/api.js b/static/lib/api.js index effd18b..51944a7 100644 --- a/static/lib/api.js +++ b/static/lib/api.js @@ -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, diff --git a/static/lib/constants.js b/static/lib/constants.js index d512175..b4b2d6d 100644 --- a/static/lib/constants.js +++ b/static/lib/constants.js @@ -1 +1 @@ -export const TABLE_SIZE = 10; +export const TABLE_SIZE = 50;