diff --git a/.prettierrc b/.prettierrc index a44ca06..81e3c70 100644 --- a/.prettierrc +++ b/.prettierrc @@ -11,4 +11,4 @@ "endOfLine": "lf", "proseWrap": "preserve" -} \ No newline at end of file +} diff --git a/README.md b/README.md index b6bd2a8..0e8f264 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,25 @@ # Nomos Block Explorer -# TODO +## Assumptions +There are a few assumptions made to facilitate the development of the PoC: +- One block per slot. +- If a range has been backfilled, it has been fully successfully backfilled. +- Backfilling strategy assumes there's, at most, one gap to fill. + +## TODO - Better backfilling -- Ensure transactions +- Upsert on backfill - Change Sqlite -> Postgres - Performance improvements on API and DB calls +- Fix assumptions, so we don't rely on them - DbRepository interfaces - Setup DB Migrations - Tests - Fix ordering for Blocks and Transactions - Fix assumption of 1 block per slot - Split the single file static into components +- Log colouring + +- Store hashes - Get transaction by hash - Get block by hash - -# Demo -- Get transaction by id -- Get block by id -- Block viewer -- Transaction viewer -- htm -- Show transactions in table diff --git a/src/api/router.py b/src/api/router.py index b956898..3024c3a 100644 --- a/src/api/router.py +++ b/src/api/router.py @@ -1,9 +1,9 @@ from fastapi import APIRouter -from .v1.router import router as v1_router +from .v1.router import create_v1_router def create_api_router() -> APIRouter: router = APIRouter() - router.include_router(v1_router, prefix="/v1") + router.include_router(create_v1_router(), prefix="/v1") return router diff --git a/src/api/streams.py b/src/api/streams.py index d37a473..26b456c 100644 --- a/src/api/streams.py +++ b/src/api/streams.py @@ -1,9 +1,10 @@ import logging from typing import AsyncIterable, AsyncIterator, List, Union -from core.models import IdNbeModel +from core.models import NbeModel, NbeSchema -Data = Union[IdNbeModel, List[IdNbeModel]] +T = Union[NbeModel, NbeSchema] +Data = Union[T, List[T]] Stream = AsyncIterator[Data] diff --git a/src/api/v1/blocks.py b/src/api/v1/blocks.py index eeef1b8..7debaf3 100644 --- a/src/api/v1/blocks.py +++ b/src/api/v1/blocks.py @@ -1,24 +1,43 @@ -from typing import List +from http.client import NOT_FOUND +from typing import TYPE_CHECKING, AsyncIterator, List, Optional -from fastapi import Query -from starlette.responses import Response +from fastapi import Path, Query +from starlette.responses import JSONResponse, Response from api.streams import into_ndjson_stream +from api.v1.serializers.blocks import BlockRead from core.api import NBERequest, NDJsonStreamingResponse from node.models.blocks import Block +if TYPE_CHECKING: + from core.app import NBE -async def _prefetch_blocks(request: NBERequest, prefetch_limit: int) -> List[Block]: - return ( - [] - if prefetch_limit == 0 else - await request.app.state.block_repository.get_latest(limit=prefetch_limit, ascending=True) - ) + +async def _get_latest(request: NBERequest, limit: int) -> List[BlockRead]: + blocks = await request.app.state.block_repository.get_latest(limit=limit, ascending=True) + return [BlockRead.from_block(block) for block in blocks] + + +async def _prefetch_blocks(request: NBERequest, prefetch_limit: int) -> List[BlockRead]: + return [] if prefetch_limit == 0 else await _get_latest(request, prefetch_limit) + + +async def _updates_stream(app: "NBE", latest_block: Optional[Block]) -> AsyncIterator[List[BlockRead]]: + _stream = app.state.block_repository.updates_stream(block_from=latest_block) + async for blocks in _stream: + yield [BlockRead.from_block(block) for block in blocks] async def stream(request: NBERequest, prefetch_limit: int = Query(0, alias="prefetch-limit", ge=0)) -> Response: - bootstrap_blocks: List[Block] = await _prefetch_blocks(request, prefetch_limit) - highest_slot: int = max((block.slot for block in bootstrap_blocks), default=0) - updates_stream = request.app.state.block_repository.updates_stream(slot_from=highest_slot + 1) - block_stream = into_ndjson_stream(stream=updates_stream, bootstrap_data=bootstrap_blocks) - return NDJsonStreamingResponse(block_stream) + bootstrap_blocks: List[BlockRead] = await _prefetch_blocks(request, prefetch_limit) + latest_block = bootstrap_blocks[-1] if bootstrap_blocks else None + updates_stream: AsyncIterator[List[BlockRead]] = _updates_stream(request.app, latest_block) + ndjson_blocks_stream = into_ndjson_stream(stream=updates_stream, bootstrap_data=bootstrap_blocks) + return NDJsonStreamingResponse(ndjson_blocks_stream) + + +async def get(request: NBERequest, block_id: int = Path(ge=1)) -> Response: + block = await request.app.state.block_repository.get_by_id(block_id) + return block.map(lambda _block: JSONResponse(BlockRead.from_block(_block).model_dump(mode="json"))).unwrap_or_else( + lambda: Response(status_code=NOT_FOUND) + ) diff --git a/src/api/v1/router.py b/src/api/v1/router.py index 060f7a6..f9a2b37 100644 --- a/src/api/v1/router.py +++ b/src/api/v1/router.py @@ -2,10 +2,19 @@ from fastapi import APIRouter from . import blocks, health, index, transactions -router = APIRouter() -router.add_api_route("/", index.index, methods=["GET", "HEAD"]) -router.add_api_route("/health", health.get, methods=["GET", "HEAD"]) -router.add_api_route("/health/stream", health.stream, methods=["GET", "HEAD"]) -router.add_api_route("/transactions/stream", transactions.stream, methods=["GET"]) -router.add_api_route("/blocks/stream", blocks.stream, methods=["GET"]) +def create_v1_router() -> APIRouter: + router = APIRouter() + + router.add_api_route("/", index.index, methods=["GET", "HEAD"]) + + router.add_api_route("/health", health.get, methods=["GET", "HEAD"]) + router.add_api_route("/health/stream", health.stream, methods=["GET", "HEAD"]) + + router.add_api_route("/transactions/{transaction_id:int}", transactions.get, methods=["GET"]) + router.add_api_route("/transactions/stream", transactions.stream, methods=["GET"]) + + router.add_api_route("/blocks/{block_id:int}", blocks.get, methods=["GET"]) + router.add_api_route("/blocks/stream", blocks.stream, methods=["GET"]) + + return router diff --git a/src/api/v1/serializers/__init__.py b/src/api/v1/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/v1/serializers/blocks.py b/src/api/v1/serializers/blocks.py new file mode 100644 index 0000000..d41e0cd --- /dev/null +++ b/src/api/v1/serializers/blocks.py @@ -0,0 +1,21 @@ +from typing import List, Self + +from core.models import NbeSchema +from node.models.blocks import Block, Header +from node.models.transactions import Transaction + + +class BlockRead(NbeSchema): + id: int + slot: int + header: Header + transactions: List[Transaction] + + @classmethod + def from_block(cls, block: Block) -> Self: + return cls( + id=block.id, + slot=block.header.slot, + header=block.header, + transactions=block.transactions, + ) diff --git a/src/api/v1/serializers/transactions.py b/src/api/v1/serializers/transactions.py new file mode 100644 index 0000000..673c23d --- /dev/null +++ b/src/api/v1/serializers/transactions.py @@ -0,0 +1,24 @@ +from typing import List, Self + +from core.models import NbeSchema +from node.models.transactions import Gas, LedgerTransaction, Transaction + + +class TransactionRead(NbeSchema): + id: int + block_id: int + operations: List[str] + ledger_transaction: LedgerTransaction + execution_gas_price: Gas + storage_gas_price: Gas + + @classmethod + def from_transaction(cls, transaction: Transaction) -> Self: + return cls( + id=transaction.id, + block_id=transaction.block_id, + operations=transaction.operations, + ledger_transaction=transaction.ledger_transaction, + execution_gas_price=transaction.execution_gas_price, + storage_gas_price=transaction.storage_gas_price, + ) diff --git a/src/api/v1/transactions.py b/src/api/v1/transactions.py index 772693f..d96c062 100644 --- a/src/api/v1/transactions.py +++ b/src/api/v1/transactions.py @@ -1,30 +1,40 @@ -from datetime import datetime -from typing import List +from http.client import NOT_FOUND +from typing import TYPE_CHECKING, AsyncIterator, List, Optional -from fastapi import Query -from starlette.responses import Response +from fastapi import Path, Query +from starlette.responses import JSONResponse, Response from api.streams import into_ndjson_stream +from api.v1.serializers.transactions import TransactionRead from core.api import NBERequest, NDJsonStreamingResponse from node.models.transactions import Transaction -from utils.datetime import increment_datetime + +if TYPE_CHECKING: + from core.app import NBE -async def _prefetch_transactions(request: NBERequest, prefetch_limit: int) -> List[Transaction]: - return ( - [] - if prefetch_limit == 0 else - await request.app.state.transaction_repository.get_latest(limit=prefetch_limit, descending=False) - ) +async def _updates_stream( + app: "NBE", latest_transaction: Optional[Transaction] +) -> AsyncIterator[List[TransactionRead]]: + _stream = app.state.transaction_repository.updates_stream(transaction_from=latest_transaction) + async for transactions in _stream: + yield [TransactionRead.from_transaction(transaction) for transaction in transactions] async def stream(request: NBERequest, prefetch_limit: int = Query(0, alias="prefetch-limit", ge=0)) -> Response: - bootstrap_transactions: List[Transaction] = await _prefetch_transactions(request, prefetch_limit) - highest_timestamp: datetime = max( - (transaction.timestamp for transaction in bootstrap_transactions), default=datetime.min + latest_transactions: List[Transaction] = await request.app.state.transaction_repository.get_latest( + limit=prefetch_limit, ascending=True, preload_relationships=True ) - updates_stream = request.app.state.transaction_repository.updates_stream( - timestamp_from=increment_datetime(highest_timestamp) - ) - transaction_stream = into_ndjson_stream(stream=updates_stream, bootstrap_data=bootstrap_transactions) - return NDJsonStreamingResponse(transaction_stream) + latest_transaction = latest_transactions[-1] if latest_transactions else None + latest_transaction_read = [TransactionRead.from_transaction(transaction) for transaction in latest_transactions] + + updates_stream: AsyncIterator[List[TransactionRead]] = _updates_stream(request.app, latest_transaction) + ndjson_transactions_stream = into_ndjson_stream(stream=updates_stream, bootstrap_data=latest_transaction_read) + return NDJsonStreamingResponse(ndjson_transactions_stream) + + +async def get(request: NBERequest, transaction_id: int = Path(ge=1)) -> Response: + transaction = await request.app.state.transaction_repository.get_by_id(transaction_id) + return transaction.map( + lambda _transaction: JSONResponse(TransactionRead.from_transaction(_transaction).model_dump(mode="json")) + ).unwrap_or_else(lambda: Response(status_code=NOT_FOUND)) diff --git a/src/core/db.py b/src/core/db.py new file mode 100644 index 0000000..b51b35c --- /dev/null +++ b/src/core/db.py @@ -0,0 +1,22 @@ +from typing import Literal + +from sqlalchemy import Float, Integer, String, cast, func + + +def order_by_json( + sql_expr, path: str, *, into_type: Literal["int", "float", "text"] = "text", descending: bool = False +): + expression = jget(sql_expr, path, into_type=into_type) + return expression.desc() if descending else expression.asc() + + +def jget(sql_expr, path: str, *, into_type: Literal["int", "float", "text"] = "text"): + expression = func.json_extract(sql_expr, path) + match into_type: + case "int": + expression = cast(expression, Integer) + case "float": + expression = cast(expression, Float) + case "text": + expression = cast(expression, String) + return expression diff --git a/src/db/blocks.py b/src/db/blocks.py index c8b95e2..4082822 100644 --- a/src/db/blocks.py +++ b/src/db/blocks.py @@ -1,35 +1,16 @@ from asyncio import sleep -from typing import AsyncIterator, List, Literal +from typing import AsyncIterator, List, Optional from rusty_results import Empty, Option, Some -from sqlalchemy import Float, Integer, Result, String, cast, func +from sqlalchemy import Result, Select from sqlalchemy.orm import aliased from sqlmodel import select -from sqlmodel.sql._expression_select_cls import Select +from core.db import jget, order_by_json from db.clients import DbClient from node.models.blocks import Block -def order_by_json( - sql_expr, path: str, *, into_type: Literal["int", "float", "text"] = "text", descending: bool = False -): - expression = jget(sql_expr, path, into_type=into_type) - return expression.desc() if descending else expression.asc() - - -def jget(sql_expr, path: str, *, into_type: Literal["int", "float", "text"] = "text"): - expression = func.json_extract(sql_expr, path) - match into_type: - case "int": - expression = cast(expression, Integer) - case "float": - expression = cast(expression, Float) - case "text": - expression = cast(expression, String) - return expression - - def get_latest_statement(limit: int, latest_ascending: bool = True) -> Select: # Fetch latest descending = order_by_json(Block.header, "$.slot", into_type="int", descending=True) @@ -63,8 +44,21 @@ class BlockRepository: results: Result[Block] = session.exec(statement) return results.all() - async def updates_stream(self, slot_from: int, *, timeout_seconds: int = 1) -> AsyncIterator[List[Block]]: - slot_cursor = slot_from + async def get_by_id(self, block_id: int) -> Option[Block]: + statement = select(Block).where(Block.id == block_id) + + with self.client.session() as session: + result: Result[Block] = session.exec(statement) + if (block := result.first()) is not None: + return Some(block) + else: + return Empty() + + async def updates_stream( + self, block_from: Optional[Block], *, timeout_seconds: int = 1 + ) -> AsyncIterator[List[Block]]: + # FIXME + slot_cursor = block_from.slot + 1 if block_from is not None else 0 block_slot_expression = jget(Block.header, "$.slot", into_type="int") order = order_by_json(Block.header, "$.slot", into_type="int", descending=False) @@ -82,9 +76,10 @@ class BlockRepository: await sleep(timeout_seconds) async def get_earliest(self) -> Option[Block]: + order = order_by_json(Block.header, "$.slot", into_type="int", descending=False) + statement = select(Block).order_by(order).limit(1) + with self.client.session() as session: - order = order_by_json(Block.header, "$.slot", into_type="int", descending=False) - statement = select(Block).order_by(order).limit(1) results: Result[Block] = session.exec(statement) if (block := results.first()) is not None: return Some(block) diff --git a/src/db/transaction.py b/src/db/transaction.py index 016f59a..9c2512a 100644 --- a/src/db/transaction.py +++ b/src/db/transaction.py @@ -1,14 +1,42 @@ from asyncio import sleep -from datetime import datetime -from typing import AsyncIterator, Iterable, List +from typing import AsyncIterator, Iterable, List, Optional from rusty_results import Empty, Option, Some -from sqlalchemy import Result +from sqlalchemy import Result, Select +from sqlalchemy.orm import aliased, selectinload from sqlmodel import select +from core.db import jget, order_by_json from db.clients import DbClient from node.models.transactions import Transaction -from utils.datetime import increment_datetime + + +def get_latest_statement( + limit: int, output_ascending: bool = True, preload_relationships: bool = False, **kwargs +) -> Select: + from node.models.blocks import Block + + # Join with Block to order by Block's slot + slot_expr = jget(Block.header, "$.slot", into_type="int").label("slot") + slot_desc = order_by_json(Block.header, "$.slot", into_type="int", descending=True) + inner = ( + select(Transaction, slot_expr) + .join(Block, Transaction.block_id == Block.id, isouter=False) + .order_by(slot_desc, Block.id.desc()) + .limit(limit) + .subquery() + ) + + # Reorder + latest = aliased(Transaction, inner) + output_slot_order = inner.c.slot.asc() if output_ascending else inner.c.slot.desc() + output_id_order = ( + latest.id.asc() if output_ascending else latest.id.desc() + ) # TODO: Double check it's Transaction.id + statement = select(latest).order_by(output_slot_order, output_id_order) + if preload_relationships: + statement = statement.options(selectinload(latest.block)) + return statement class TransactionRepository: @@ -20,49 +48,49 @@ class TransactionRepository: session.add_all(transaction) session.commit() - async def get_latest(self, limit: int, descending: bool = True) -> List[Transaction]: - return [] - - statement = select(Transaction).limit(limit) - if descending: - statement = statement.order_by(Transaction.timestamp.desc()) - else: - statement = statement.order_by(Transaction.timestamp.asc()) + async def get_latest(self, limit: int, *, ascending: bool = True, **kwargs) -> List[Transaction]: + statement = get_latest_statement(limit, ascending, **kwargs) with self.client.session() as session: results: Result[Transaction] = session.exec(statement) return results.all() - async def updates_stream(self, timestamp_from: datetime) -> AsyncIterator[List[Transaction]]: - while True: - if False: - yield [] - await sleep(10) + async def get_by_id(self, transaction_id: int) -> Option[Transaction]: + statement = select(Transaction).where(Transaction.id == transaction_id) + + with self.client.session() as session: + result: Result[Transaction] = session.exec(statement) + if (transaction := result.first()) is not None: + return Some(transaction) + else: + return Empty() + + async def updates_stream( + self, transaction_from: Optional[Transaction], *, timeout_seconds: int = 1 + ) -> AsyncIterator[List[Transaction]]: + from node.models.blocks import Block + + slot_cursor: int = transaction_from.block.slot + 1 if transaction_from is not None else 0 + slot_expression = jget(Block.header, "$.slot", into_type="int") + slot_order = order_by_json(Block.header, "$.slot", into_type="int", descending=False) - _timestamp_from = timestamp_from while True: + where_clause_slot = slot_expression >= slot_cursor + where_clause_id = Transaction.id > transaction_from.id if transaction_from is not None else True + statement = ( select(Transaction) - .where(Transaction.timestamp >= _timestamp_from) - .order_by(Transaction.timestamp.asc()) + .options(selectinload(Transaction.block)) + .join(Block, Transaction.block_id == Block.id) + .where(where_clause_slot, where_clause_id) + .order_by(slot_order, Block.id.asc(), Transaction.id.asc()) ) with self.client.session() as session: transactions: List[Transaction] = session.exec(statement).all() if len(transactions) > 0: - # POC: Assumes transactions are inserted in order and with a minimum 1 of second difference - _timestamp_from = increment_datetime(transactions[-1].timestamp) - - yield transactions - - async def get_earliest(self) -> Option[Transaction]: - return Empty() - - with self.client.session() as session: - statement = select(Transaction).order_by(Transaction.slot.asc()).limit(1) - results: Result[Transaction] = session.exec(statement) - if (transaction := results.first()) is not None: - return Some(transaction) + slot_cursor = transactions[-1].block.slot + 1 + yield transactions else: - return Empty() + await sleep(timeout_seconds) diff --git a/src/frontend/router.py b/src/frontend/router.py index da1efc1..cf4ef5b 100644 --- a/src/frontend/router.py +++ b/src/frontend/router.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter +from http.client import SERVICE_UNAVAILABLE + +from fastapi import APIRouter, HTTPException from starlette.responses import FileResponse from . import STATIC_DIR @@ -6,11 +8,13 @@ from . import STATIC_DIR INDEX_FILE = STATIC_DIR.joinpath("index.html") -def spa() -> FileResponse: +def spa(path: str) -> FileResponse: + if path.startswith(("api", "static")): + raise HTTPException(SERVICE_UNAVAILABLE, detail="Routing is incorrectly configured.") return FileResponse(INDEX_FILE) def create_frontend_router() -> APIRouter: router = APIRouter() - router.get("/", include_in_schema=False)(spa) + router.get("/{path:path}", include_in_schema=False)(spa) return router diff --git a/src/node/api/base.py b/src/node/api/base.py index 6a58baa..dc50ce7 100644 --- a/src/node/api/base.py +++ b/src/node/api/base.py @@ -11,10 +11,6 @@ class NodeApi(ABC): async def get_health_check(self) -> Health: pass - @abstractmethod - async def get_transactions(self) -> List[Transaction]: - pass - @abstractmethod async def get_blocks(self, **kwargs) -> List[Block]: pass diff --git a/src/node/api/fake.py b/src/node/api/fake.py index 394a683..c85bd6f 100644 --- a/src/node/api/fake.py +++ b/src/node/api/fake.py @@ -20,9 +20,6 @@ class FakeNodeApi(NodeApi): else: return Health.from_healthy() - async def get_transactions(self) -> List[Transaction]: - return [Transaction.from_random() for _ in range(get_weighted_amount())] - async def get_blocks(self) -> List[Block]: return [Block.from_random() for _ in range(1)] diff --git a/src/node/api/http.py b/src/node/api/http.py index 1860288..836da1f 100644 --- a/src/node/api/http.py +++ b/src/node/api/http.py @@ -37,12 +37,6 @@ class HttpNodeApi(NodeApi): else: return Health.from_unhealthy() - async def get_transactions(self) -> List[Transaction]: - url = urljoin(self.base_url, self.ENDPOINT_TRANSACTIONS) - response = requests.get(url, timeout=60) - json = response.json() - return [Transaction.model_validate(item) for item in json] - async def get_blocks(self, slot_from: int, slot_to: int) -> List[Block]: query_string = f"slot_from={slot_from}&slot_to={slot_to}" endpoint = urljoin(self.base_url, self.ENDPOINT_BLOCKS) diff --git a/src/node/lifespan.py b/src/node/lifespan.py index 1935ebb..5ada081 100644 --- a/src/node/lifespan.py +++ b/src/node/lifespan.py @@ -149,7 +149,7 @@ async def backfill_blocks(app: "NBE", *, db_hit_interval_seconds: int, batch_siz blocks = await app.state.node_api.get_blocks(slot_from=slot_from, slot_to=slot_to) logger.debug(f"Backfilling {len(blocks)} blocks from slot {slot_from} to {slot_to}...") await app.state.block_repository.create(*blocks) - slot_to = slot_from + slot_to = slot_from - 1 logger.info("Backfilling blocks completed.") diff --git a/src/node/models/blocks.py b/src/node/models/blocks.py index 25fa1dd..38c9fd4 100644 --- a/src/node/models/blocks.py +++ b/src/node/models/blocks.py @@ -1,17 +1,20 @@ import logging import os import random -from typing import Any, List, Self +from typing import TYPE_CHECKING, Any, List, Self from pydantic.config import ExtraValues +from pydantic_core.core_schema import computed_field from sqlalchemy import Column -from sqlmodel import Field +from sqlmodel import Field, Relationship from core.models import NbeSchema, TimestampedModel from core.sqlmodel import PydanticJsonColumn -from node.models.transactions import Transaction from utils.random import random_hash +if TYPE_CHECKING: + from node.models.transactions import Transaction + def _is_debug__randomize_transactions(): is_debug = os.getenv("DEBUG", "False").lower() == "true" @@ -81,11 +84,15 @@ class Header(NbeSchema): class Block(TimestampedModel, table=True): - __tablename__ = "blocks" + __tablename__ = "block" header: Header = Field(sa_column=Column(PydanticJsonColumn(Header), nullable=False)) - transactions: List[Transaction] = Field( - default_factory=list, sa_column=Column(PydanticJsonColumn(Transaction, many=True), nullable=False) + transactions: List["Transaction"] = Relationship( + back_populates="block", + sa_relationship_kwargs={ + "lazy": "selectin", + "cascade": "all, delete-orphan", + }, ) @property @@ -113,6 +120,8 @@ class Block(TimestampedModel, table=True): json_data, strict=strict, extra=extra, context=context, by_alias=by_alias, by_name=by_name ) if _is_debug__randomize_transactions(): + from node.models.transactions import Transaction + logger.debug("DEBUG and DEBUG__RANDOMIZE_TRANSACTIONS is enabled, randomizing Block's transactions.") n = 0 if random.randint(0, 1) <= 0.5 else random.randint(1, 10) self.transactions = [Transaction.from_random() for _ in range(n)] @@ -120,9 +129,9 @@ class Block(TimestampedModel, table=True): @classmethod def from_random(cls, slot_from: int = 1, slot_to: int = 100) -> "Block": - n = random.randint(1, 10) - _transactions = [Transaction.from_random() for _ in range(n)] + n = 0 if random.randint(0, 1) < 0.3 else random.randint(1, 5) + transactions = [Transaction.from_random() for _ in range(n)] return Block( header=Header.from_random(slot_from, slot_to), - transactions=[], + transactions=transactions, ) diff --git a/src/node/models/transactions.py b/src/node/models/transactions.py index 09c4ebb..5302975 100644 --- a/src/node/models/transactions.py +++ b/src/node/models/transactions.py @@ -1,13 +1,17 @@ import random from enum import StrEnum -from typing import List +from typing import TYPE_CHECKING, List, Optional from sqlalchemy import JSON, Column -from sqlmodel import Field +from sqlmodel import Field, Relationship from core.models import NbeSchema, TimestampedModel +from core.sqlmodel import PydanticJsonColumn from utils.random import random_address +if TYPE_CHECKING: + from node.models.blocks import Block + Value = int Fr = int Gas = float @@ -33,7 +37,7 @@ class Note(NbeSchema): def from_random(cls) -> "Note": return Note( value=random.randint(1, 100), - public_key=random_address(), + public_key=random_address().encode("utf-8"), ) @@ -53,19 +57,23 @@ class LedgerTransaction(NbeSchema): ) -class Transaction(NbeSchema): # table=true # It currently lives inside Block +class Transaction(TimestampedModel, table=True): """ MantleTx """ - # __tablename__ = "transactions" + __tablename__ = "transaction" - # TODO: hash + block_id: int = Field(foreign_key="block.id", nullable=False, index=True) operations: List[str] = Field(alias="ops", default_factory=list, sa_column=Column(JSON, nullable=False)) - ledger_transaction: LedgerTransaction = Field(default_factory=dict, sa_column=Column(JSON, nullable=False)) + ledger_transaction: LedgerTransaction = Field( + default_factory=dict, sa_column=Column(PydanticJsonColumn(LedgerTransaction), nullable=False) + ) execution_gas_price: Gas storage_gas_price: Gas + block: Optional["Block"] = Relationship(back_populates="transactions") + def __str__(self) -> str: return f"Transaction({self.operations})" @@ -74,7 +82,7 @@ class Transaction(NbeSchema): # table=true # It currently lives inside Block @classmethod def from_random(cls) -> "Transaction": - n = random.randint(1, 10) + n = random.randint(1, 3) operations = [random.choice(list(Operation)).value for _ in range(n)] return Transaction( operations=operations, diff --git a/src/router.py b/src/router.py index 982d7d9..367b4b4 100644 --- a/src/router.py +++ b/src/router.py @@ -30,6 +30,7 @@ def create_router() -> APIRouter: router.include_router(create_api_router(), prefix="/api") if bool(environ.get("DEBUG")): router.add_route("/debug", debug_router) - router.include_router(create_frontend_router()) + + router.include_router(create_frontend_router()) # Needs to go last since it contains a catch-all route return router diff --git a/src/utils/datetime.py b/src/utils/datetime.py deleted file mode 100644 index feab333..0000000 --- a/src/utils/datetime.py +++ /dev/null @@ -1,8 +0,0 @@ -from datetime import datetime, timedelta - - -# Increments a timestamp by the smallest possible unit (1 microsecond), in terms of DB precision. -# This is used to avoid returning the same record again when querying for updates. -# FIXME: Hardcoded -def increment_datetime(timestamp: datetime) -> datetime: - return timestamp + timedelta(microseconds=1) diff --git a/static/app.js b/static/app.js index a30fda4..c5c7484 100644 --- a/static/app.js +++ b/static/app.js @@ -30,20 +30,22 @@ const ROUTES = [ }, { name: 'blockDetail', - re: /^\/block\/([^/]+)$/, - view: ({ params }) => h(AppShell, null, h(BlockDetailPage, { params })), + re: /^\/blocks\/([^/]+)$/, + view: ({ parameters }) => { + return h(AppShell, null, h(BlockDetailPage, { parameters })); + }, }, { name: 'transactionDetail', - re: /^\/transaction\/([^/]+)$/, - view: ({ params }) => h(AppShell, null, h(TransactionDetailPage, { params })), + re: /^\/transactions\/([^/]+)$/, + view: ({ parameters }) => h(AppShell, null, h(TransactionDetailPage, { parameters })), }, ]; function AppRouter() { - const wired = ROUTES.map((r) => ({ - re: r.re, - view: (match) => r.view({ params: match }), + const wired = ROUTES.map((route) => ({ + re: route.re, + view: route.view, })); return h(Router, { routes: wired }); } diff --git a/static/components/BlocksTable.js b/static/components/BlocksTable.js index bb8e67f..094735a 100644 --- a/static/components/BlocksTable.js +++ b/static/components/BlocksTable.js @@ -1,123 +1,133 @@ +// static/pages/BlocksTable.js import { h } from 'preact'; import { useEffect, useRef } from 'preact/hooks'; -import { BLOCKS_ENDPOINT, TABLE_SIZE } from '../lib/api.js?dev=1'; -import {streamNdjson, ensureFixedRowCount, shortenHex, formatTimestamp, withBenignFilter} from '../lib/utils.js?dev=1'; +import { PAGE, API, TABLE_SIZE } from '../lib/api.js?dev=1'; +import { streamNdjson, ensureFixedRowCount, shortenHex } from '../lib/utils.js?dev=1'; export default function BlocksTable() { - const tbodyRef = useRef(null); + const bodyRef = useRef(null); const countRef = useRef(null); const abortRef = useRef(null); const seenKeysRef = useRef(new Set()); useEffect(() => { - const tbody = tbodyRef.current; + const body = bodyRef.current; const counter = countRef.current; - ensureFixedRowCount(tbody, 5, TABLE_SIZE); + + // 5 columns now (ID, Slot, Root, Parent, Transactions) + ensureFixedRowCount(body, 5, TABLE_SIZE); abortRef.current?.abort(); abortRef.current = new AbortController(); - function pruneAndPad() { - // remove placeholders - for (let i = tbody.rows.length - 1; i >= 0; i--) { - if (tbody.rows[i].classList.contains('ph')) tbody.deleteRow(i); + const pruneAndPad = () => { + for (let i = body.rows.length - 1; i >= 0; i--) { + if (body.rows[i].classList.contains('ph')) body.deleteRow(i); } - // trim overflow - while ([...tbody.rows].filter((r) => !r.classList.contains('ph')).length > TABLE_SIZE) { - const last = tbody.rows[tbody.rows.length - 1]; + while ([...body.rows].filter((r) => !r.classList.contains('ph')).length > TABLE_SIZE) { + const last = body.rows[body.rows.length - 1]; const key = last?.dataset?.key; if (key) seenKeysRef.current.delete(key); - tbody.deleteRow(-1); + body.deleteRow(-1); } - // pad placeholders - const real = [...tbody.rows].filter((r) => !r.classList.contains('ph')).length; - ensureFixedRowCount(tbody, 5, TABLE_SIZE); + // keep placeholders in sync with 5 columns + ensureFixedRowCount(body, 5, TABLE_SIZE); + const real = [...body.rows].filter((r) => !r.classList.contains('ph')).length; counter.textContent = String(real); - } - - const makeLink = (href, text, title) => { - const a = document.createElement('a'); - a.className = 'linkish mono'; - a.href = href; - if (title) a.title = title; - a.textContent = text; - return a; }; - const appendRow = (block, key) => { - const row = document.createElement('tr'); - row.dataset.key = key; + const navigateToBlockDetail = (blockId) => { + history.pushState({}, '', PAGE.BLOCK_DETAIL(blockId)); + window.dispatchEvent(new PopStateEvent('popstate')); + }; - const cellSlot = document.createElement('td'); - const spanSlot = document.createElement('span'); - spanSlot.className = 'mono'; - spanSlot.textContent = String(block.slot); - cellSlot.appendChild(spanSlot); + const appendRow = (b, key) => { + const tr = document.createElement('tr'); + tr.dataset.key = key; - const cellRoot = document.createElement('td'); - cellRoot.appendChild(makeLink(`/block/${block.root}`, shortenHex(block.root), block.root)); + // ID (clickable) + const tdId = document.createElement('td'); + const linkId = document.createElement('a'); + linkId.className = 'linkish mono'; + linkId.href = PAGE.BLOCK_DETAIL(b.id); + linkId.textContent = String(b.id); + linkId.addEventListener('click', (e) => { + e.preventDefault(); + navigateToBlockDetail(b.id); + }); + tdId.appendChild(linkId); - const cellParent = document.createElement('td'); - cellParent.appendChild(makeLink(`/block/${block.parent}`, shortenHex(block.parent), block.parent)); + // Slot + const tdSlot = document.createElement('td'); + const spSlot = document.createElement('span'); + spSlot.className = 'mono'; + spSlot.textContent = String(b.slot); + tdSlot.appendChild(spSlot); - const cellTxCount = document.createElement('td'); - const spanTx = document.createElement('span'); - spanTx.className = 'mono'; - spanTx.textContent = String(block.transactionCount); - cellTxCount.appendChild(spanTx); + // Root + const tdRoot = document.createElement('td'); + const spRoot = document.createElement('span'); + spRoot.className = 'mono'; + spRoot.title = b.root; + spRoot.textContent = shortenHex(b.root); + tdRoot.appendChild(spRoot); - const cellTime = document.createElement('td'); - const spanTime = document.createElement('span'); - spanTime.className = 'mono'; - spanTime.title = block.time ?? ''; - spanTime.textContent = formatTimestamp(block.time); - cellTime.appendChild(spanTime); + // Parent + const tdParent = document.createElement('td'); + const spParent = document.createElement('span'); + spParent.className = 'mono'; + spParent.title = b.parent; + spParent.textContent = shortenHex(b.parent); + tdParent.appendChild(spParent); - row.append(cellSlot, cellRoot, cellParent, cellTxCount, cellTime); - tbody.insertBefore(row, tbody.firstChild); + // Transactions (array length) + const tdCount = document.createElement('td'); + const spCount = document.createElement('span'); + spCount.className = 'mono'; + spCount.textContent = String(b.transactionCount); + tdCount.appendChild(spCount); + + tr.append(tdId, tdSlot, tdRoot, tdParent, tdCount); + body.insertBefore(tr, body.firstChild); pruneAndPad(); }; const normalize = (raw) => { const header = raw.header ?? raw; - const created = raw.created_at ?? raw.header?.created_at ?? null; + const txLen = Array.isArray(raw.transactions) + ? raw.transactions.length + : Array.isArray(raw.txs) + ? raw.txs.length + : 0; + return { id: Number(raw.id ?? 0), slot: Number(header?.slot ?? raw.slot ?? 0), root: header?.block_root ?? raw.block_root ?? '', parent: header?.parent_block ?? raw.parent_block ?? '', - transactionCount: Array.isArray(raw.transactions) - ? raw.transactions.length - : typeof raw.transaction_count === 'number' - ? raw.transaction_count - : 0, - time: created, + transactionCount: txLen, }; }; - const url = `${BLOCKS_ENDPOINT}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`; streamNdjson( - url, + `${API.BLOCKS_STREAM}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`, (raw) => { - const block = normalize(raw); - const key = `${block.slot}:${block.id}`; + const b = normalize(raw); + const key = `${b.id}:${b.slot}`; if (seenKeysRef.current.has(key)) { pruneAndPad(); return; } seenKeysRef.current.add(key); - appendRow(block, key); + appendRow(b, key); }, { signal: abortRef.current.signal, - onError: withBenignFilter( - (e) => console.error('Blocks stream error:', e), - abortRef.current.signal - ) + onError: (e) => { + console.error('Blocks stream error:', e); + }, }, - ).catch((err) => { - if (!abortRef.current.signal.aborted) console.error('Blocks stream error:', err); - }); + ); return () => abortRef.current?.abort(); }, []); @@ -129,7 +139,7 @@ export default function BlocksTable() { 'div', { class: 'card-header' }, h('div', null, h('strong', null, 'Blocks '), h('span', { class: 'pill', ref: countRef }, '0')), - h('div', { style: 'color:var(--muted); font-size:12px;' }, '/api/v1/blocks/stream'), + h('div', { style: 'color:var(--muted); fontSize:12px;' }), ), h( 'div', @@ -140,18 +150,26 @@ export default function BlocksTable() { h( 'colgroup', null, - h('col', { style: 'width:90px' }), - h('col', { style: 'width:260px' }), - h('col', { style: 'width:260px' }), - h('col', { style: 'width:120px' }), - h('col', { style: 'width:180px' }), + h('col', { style: 'width:80px' }), // ID + h('col', { style: 'width:90px' }), // Slot + h('col', { style: 'width:240px' }), // Root + h('col', { style: 'width:240px' }), // Parent + h('col', { style: 'width:120px' }), // Transactions ), h( 'thead', null, - h('tr', null, h('th', null, 'Slot'), h('th', null, 'Block Root'), h('th', null, 'Parent'), h('th', null, 'Transactions'), h('th', null, 'Time')), + h( + 'tr', + null, + h('th', null, 'ID'), + h('th', null, 'Slot'), + h('th', null, 'Block Root'), + h('th', null, 'Parent'), + h('th', null, 'Transactions'), + ), ), - h('tbody', { ref: tbodyRef }), + h('tbody', { ref: bodyRef }), ), ), ); diff --git a/static/components/HealthPill.js b/static/components/HealthPill.js index ef207f9..c7671cd 100644 --- a/static/components/HealthPill.js +++ b/static/components/HealthPill.js @@ -1,7 +1,7 @@ import { h } from 'preact'; import { useEffect, useRef, useState } from 'preact/hooks'; -import { HEALTH_ENDPOINT } from '../lib/api.js'; -import {streamNdjson, withBenignFilter} from '../lib/utils.js'; +import { API } from '../lib/api.js'; +import { streamNdjson, withBenignFilter } from '../lib/utils.js'; const STATUS = { CONNECTING: 'connecting', @@ -28,7 +28,7 @@ export default function HealthPill() { abortRef.current = new AbortController(); streamNdjson( - HEALTH_ENDPOINT, + API.HEALTH_ENDPOINT, (item) => { if (typeof item?.healthy === 'boolean') { setStatus(item.healthy ? STATUS.ONLINE : STATUS.OFFLINE); @@ -36,15 +36,12 @@ export default function HealthPill() { }, { signal: abortRef.current.signal, - onError: withBenignFilter( - (err) => { - if (!abortRef.current.signal.aborted) { - console.error('Health stream error:', err); - setStatus(STATUS.OFFLINE); - } - }, - abortRef.current.signal - ), + onError: withBenignFilter((err) => { + if (!abortRef.current.signal.aborted) { + console.error('Health stream error:', err); + setStatus(STATUS.OFFLINE); + } + }, abortRef.current.signal), }, ); diff --git a/static/components/Router.js b/static/components/Router.js index cc4faa8..1d6c8ce 100644 --- a/static/components/Router.js +++ b/static/components/Router.js @@ -8,14 +8,13 @@ export default function AppRouter({ routes }) { const handlePopState = () => setMatch(resolveRoute(location.pathname, routes)); const handleLinkClick = (event) => { - // Only intercept unmodified left-clicks - if (event.defaultPrevented || event.button !== 0) return; + if (event.defaultPrevented || event.button !== 0) return; // only left-click if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; const anchor = event.target.closest?.('a[href]'); if (!anchor) return; - // Respect explicit navigation hints + // Respect hints/targets if (anchor.target && anchor.target !== '_self') return; if (anchor.hasAttribute('download')) return; if (anchor.getAttribute('rel')?.includes('external')) return; @@ -24,13 +23,13 @@ export default function AppRouter({ routes }) { const href = anchor.getAttribute('href'); if (!href) return; - // Allow in-page, mailto, and other schemes to pass through + // Allow in-page, mailto, tel, etc. if (href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return; - // Different origin → let the browser handle it + // Cross-origin goes to browser if (anchor.origin !== location.origin) return; - // Likely a static asset (e.g., ".css", ".png") → let it pass + // Likely a static asset if (/\.[a-z0-9]+($|\?)/i.test(href)) return; event.preventDefault(); @@ -40,7 +39,6 @@ export default function AppRouter({ routes }) { window.addEventListener('popstate', handlePopState); document.addEventListener('click', handleLinkClick); - return () => { window.removeEventListener('popstate', handlePopState); document.removeEventListener('click', handleLinkClick); @@ -48,13 +46,15 @@ export default function AppRouter({ routes }) { }, [routes]); const View = match?.view ?? NotFound; - return h(View, { params: match?.params ?? [] }); + return h(View, { parameters: match?.parameters ?? [] }); } function resolveRoute(pathname, routes) { for (const route of routes) { - const result = pathname.match(route.pattern); - if (result) return { view: route.view, params: result.slice(1) }; + const rx = route.pattern || route.re; + if (!rx) continue; + const m = pathname.match(rx); + if (m) return { view: route.view, parameters: m.slice(1) }; } return null; } diff --git a/static/components/TransactionsTable.js b/static/components/TransactionsTable.js index f814d3f..9644274 100644 --- a/static/components/TransactionsTable.js +++ b/static/components/TransactionsTable.js @@ -1,77 +1,139 @@ import { h } from 'preact'; import { useEffect, useRef } from 'preact/hooks'; -import { TRANSACTIONS_ENDPOINT, TABLE_SIZE } from '../lib/api.js?dev=1'; -import {streamNdjson, ensureFixedRowCount, shortenHex, formatTimestamp, withBenignFilter} from '../lib/utils.js?dev=1'; +import { API, TABLE_SIZE } from '../lib/api.js?dev=1'; +import { + streamNdjson, + ensureFixedRowCount, + shortenHex, + formatTimestamp, + withBenignFilter, +} from '../lib/utils.js?dev=1'; + +const OPERATIONS_PREVIEW_LIMIT = 2; + +function createSpan(className, text, title) { + const element = document.createElement('span'); + if (className) element.className = className; + if (title) element.title = title; + element.textContent = text; + return element; +} + +function createLink(href, text, title) { + const element = document.createElement('a'); + element.className = 'linkish mono'; + element.href = href; + if (title) element.title = title; + element.textContent = text; + return element; +} + +function normalizeTransaction(raw) { + // Defensive parsing and intent-revealing structure + const operations = Array.isArray(raw?.ops) ? raw.ops : Array.isArray(raw?.operations) ? raw.operations : []; + + const ledgerOutputs = Array.isArray(raw?.ledger_transaction?.outputs) ? raw.ledger_transaction.outputs : []; + + const totalOutputValue = ledgerOutputs.reduce((sum, note) => sum + Number(note?.value ?? 0), 0); + + return { + id: raw?.id ?? '', + operations, + createdAt: raw?.created_at ?? raw?.timestamp ?? '', + executionGasPrice: Number(raw?.execution_gas_price ?? 0), + storageGasPrice: Number(raw?.storage_gas_price ?? 0), + numberOfOutputs: ledgerOutputs.length, + totalOutputValue, + }; +} + +function formatOperationsPreview(operations) { + if (operations.length === 0) return '—'; + if (operations.length <= OPERATIONS_PREVIEW_LIMIT) return operations.join(', '); + const head = operations.slice(0, OPERATIONS_PREVIEW_LIMIT).join(', '); + const remainder = operations.length - OPERATIONS_PREVIEW_LIMIT; + return `${head} +${remainder}`; +} + +function buildTransactionRow(transactionData) { + const row = document.createElement('tr'); + + // ID + const cellId = document.createElement('td'); + cellId.className = 'mono'; + cellId.appendChild( + createLink(`/transactions/${transactionData.id}`, String(transactionData.id), String(transactionData.id)), + ); + + // Operations + const cellOperations = document.createElement('td'); + const operationsPreview = formatOperationsPreview(transactionData.operations); + cellOperations.appendChild(createSpan('', operationsPreview, transactionData.operations.join(', '))); + + // Outputs (count / total value) + const cellOutputs = document.createElement('td'); + cellOutputs.className = 'amount'; + cellOutputs.textContent = `${transactionData.numberOfOutputs} / ${transactionData.totalOutputValue.toLocaleString(undefined, { maximumFractionDigits: 8 })}`; + + // Gas (execution / storage) + const cellGas = document.createElement('td'); + cellGas.className = 'mono'; + cellGas.textContent = `${transactionData.executionGasPrice.toLocaleString()} / ${transactionData.storageGasPrice.toLocaleString()}`; + + // Time + const cellTime = document.createElement('td'); + const timeSpan = createSpan('mono', formatTimestamp(transactionData.createdAt), String(transactionData.createdAt)); + cellTime.appendChild(timeSpan); + + row.append(cellId, cellOperations, cellOutputs, cellGas, cellTime); + return row; +} export default function TransactionsTable() { - const tbodyRef = useRef(null); - const countRef = useRef(null); - const abortRef = useRef(null); + const tableBodyRef = useRef(null); + const counterRef = useRef(null); + const abortControllerRef = useRef(null); const totalCountRef = useRef(0); useEffect(() => { - const tbody = tbodyRef.current; - const counter = countRef.current; - ensureFixedRowCount(tbody, 4, TABLE_SIZE); + const tableBodyElement = tableBodyRef.current; + const counterElement = counterRef.current; + ensureFixedRowCount(tableBodyElement, 4, TABLE_SIZE); - abortRef.current?.abort(); - abortRef.current = new AbortController(); + abortControllerRef.current?.abort(); + abortControllerRef.current = new AbortController(); - const makeSpan = (className, text, title) => { - const s = document.createElement('span'); - if (className) s.className = className; - if (title) s.title = title; - s.textContent = text; - return s; - }; - const makeLink = (href, text, title) => { - const a = document.createElement('a'); - a.className = 'linkish mono'; - a.href = href; - if (title) a.title = title; - a.textContent = text; - return a; - }; + const url = `${API.TRANSACTIONS_STREAM}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`; - const url = `${TRANSACTIONS_ENDPOINT}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`; streamNdjson( url, - (t) => { - const row = document.createElement('tr'); + (rawTransaction) => { + try { + const transactionData = normalizeTransaction(rawTransaction); + const row = buildTransactionRow(transactionData); - const cellHash = document.createElement('td'); - cellHash.appendChild(makeLink(`/transaction/${t.hash ?? ''}`, shortenHex(t.hash ?? ''), t.hash ?? '')); - - const cellFromTo = document.createElement('td'); - cellFromTo.appendChild(makeSpan('mono', shortenHex(t.sender ?? ''), t.sender ?? '')); - cellFromTo.appendChild(document.createTextNode(' \u2192 ')); - cellFromTo.appendChild(makeSpan('mono', shortenHex(t.recipient ?? ''), t.recipient ?? '')); - - const cellAmount = document.createElement('td'); - cellAmount.className = 'amount'; - cellAmount.textContent = Number(t.amount ?? 0).toLocaleString(undefined, { maximumFractionDigits: 8 }); - - const cellTime = document.createElement('td'); - const spanTime = makeSpan('mono', formatTimestamp(t.timestamp), t.timestamp ?? ''); - cellTime.appendChild(spanTime); - - row.append(cellHash, cellFromTo, cellAmount, cellTime); - tbody.insertBefore(row, tbody.firstChild); - while (tbody.rows.length > TABLE_SIZE) tbody.deleteRow(-1); - counter.textContent = String(++totalCountRef.current); + tableBodyElement.insertBefore(row, tableBodyElement.firstChild); + while (tableBodyElement.rows.length > TABLE_SIZE) tableBodyElement.deleteRow(-1); + counterElement.textContent = String(++totalCountRef.current); + } catch (error) { + // Fail fast per row, but do not break the stream + console.error('Failed to render transaction row:', error); + } }, { - signal: abortRef.current.signal, + signal: abortControllerRef.current.signal, onError: withBenignFilter( - (e) => console.error('Transaction stream error:', e), - abortRef.current.signal - ) + (error) => console.error('Transaction stream error:', error), + abortControllerRef.current.signal, + ), }, - ).catch((err) => { - if (!abortRef.current.signal.aborted) console.error('Transactions stream error:', err); + ).catch((error) => { + if (!abortControllerRef.current.signal.aborted) { + console.error('Transactions stream connection error:', error); + } }); - return () => abortRef.current?.abort(); + return () => abortControllerRef.current?.abort(); }, []); return h( @@ -80,8 +142,8 @@ export default function TransactionsTable() { 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;' }, '/api/v1/transactions/stream'), + h('div', null, h('strong', null, 'Transactions '), h('span', { class: 'pill', ref: counterRef }, '0')), + h('div', { style: 'color:var(--muted); font-size:12px;' }), ), h( 'div', @@ -92,17 +154,26 @@ export default function TransactionsTable() { h( 'colgroup', null, - h('col', { style: 'width:260px' }), - h('col', null), - h('col', { style: 'width:120px' }), - h('col', { style: 'width:180px' }), + h('col', { style: 'width:120px' }), // ID + h('col', null), // Operations + h('col', { style: 'width:180px' }), // Outputs (count / total) + h('col', { style: 'width:180px' }), // Gas (execution / storage) + h('col', { style: 'width:180px' }), // Time ), h( 'thead', null, - h('tr', null, h('th', null, 'Hash'), h('th', null, 'From → To'), h('th', null, 'Amount'), h('th', null, 'Time')), + h( + 'tr', + null, + h('th', null, 'ID'), + h('th', null, 'Operations'), + h('th', null, 'Outputs (count / total)'), + h('th', null, 'Gas (execution / storage)'), + h('th', null, 'Time'), + ), ), - h('tbody', { ref: tbodyRef }), + h('tbody', { ref: tableBodyRef }), ), ), ); diff --git a/static/lib/api.js b/static/lib/api.js index a6464b8..c3e78b9 100644 --- a/static/lib/api.js +++ b/static/lib/api.js @@ -1,13 +1,29 @@ export const API_PREFIX = '/api/v1'; +export const TABLE_SIZE = 10; const joinUrl = (...parts) => parts.join('/').replace(/\/{2,}/g, '/'); const encodeId = (id) => encodeURIComponent(String(id)); -export const HEALTH_ENDPOINT = joinUrl(API_PREFIX, 'health/stream'); -export const BLOCKS_ENDPOINT = joinUrl(API_PREFIX, 'blocks/stream'); -export const TRANSACTIONS_ENDPOINT = joinUrl(API_PREFIX, 'transactions/stream'); +const HEALTH_ENDPOINT = joinUrl(API_PREFIX, 'health/stream'); -export const TABLE_SIZE = 10; +const TRANSACTION_DETAIL_BY_ID = (id) => joinUrl(API_PREFIX, 'transactions', encodeId(id)); +const TRANSACTIONS_STREAM = joinUrl(API_PREFIX, 'transactions/stream'); -export const BLOCK_DETAIL = (id) => joinUrl(API_PREFIX, 'blocks', encodeId(id)); -export const TRANSACTION_DETAIL = (id) => joinUrl(API_PREFIX, 'transactions', encodeId(id)); +const BLOCK_DETAIL_BY_ID = (id) => joinUrl(API_PREFIX, 'blocks', encodeId(id)); +const BLOCKS_STREAM = joinUrl(API_PREFIX, 'blocks/stream'); + +export const API = { + HEALTH_ENDPOINT, + TRANSACTION_DETAIL_BY_ID, + TRANSACTIONS_STREAM, + BLOCK_DETAIL_BY_ID, + BLOCKS_STREAM, +}; + +const BLOCK_DETAIL = (id) => joinUrl('/blocks', encodeId(id)); +const TRANSACTION_DETAIL = (id) => joinUrl('/transactions', encodeId(id)); + +export const PAGE = { + BLOCK_DETAIL, + TRANSACTION_DETAIL, +}; diff --git a/static/lib/utils.js b/static/lib/utils.js index 025fbf2..df629ed 100644 --- a/static/lib/utils.js +++ b/static/lib/utils.js @@ -2,11 +2,9 @@ export const isBenignStreamError = (error, signal) => { return false; }; -export const withBenignFilter = - (onError, signal) => - (error) => { - if (!isBenignStreamError(error, signal)) onError?.(error); - }; +export const withBenignFilter = (onError, signal) => (error) => { + if (!isBenignStreamError(error, signal)) onError?.(error); +}; export async function streamNdjson(url, handleItem, { signal, onError = () => {} } = {}) { const response = await fetch(url, { diff --git a/static/pages/BlockDetail.js b/static/pages/BlockDetail.js index 3766fff..ccc8878 100644 --- a/static/pages/BlockDetail.js +++ b/static/pages/BlockDetail.js @@ -1,50 +1,152 @@ +// static/pages/BlockDetailPage.js import { h, Fragment } from 'preact'; -import { useEffect, useState } from 'preact/hooks'; -import { shortenHex, formatTimestamp } from '../lib/utils.js?dev=1'; -import { BLOCK_DETAIL } from '../lib/api.js?dev=1'; +import { useEffect, useMemo, useState } from 'preact/hooks'; +import { API, PAGE } from '../lib/api.js?dev=1'; -export default function BlockDetail({ params: routeParams }) { - const blockId = routeParams[0]; +const OPERATIONS_PREVIEW_LIMIT = 2; + +// Helpers +function opsToPills(ops, limit = OPERATIONS_PREVIEW_LIMIT) { + const arr = Array.isArray(ops) ? ops : []; + if (!arr.length) return h('span', { style: 'color:var(--muted); white-space:nowrap;' }, '—'); + const shown = arr.slice(0, limit); + const extra = arr.length - shown.length; + return h( + 'div', + { style: 'display:flex; gap:6px; flex-wrap:nowrap; align-items:center; white-space:nowrap;' }, + ...shown.map((op, i) => + h('span', { key: `${op}-${i}`, class: 'pill', title: op, style: 'flex:0 0 auto;' }, op), + ), + extra > 0 && h('span', { class: 'pill', title: `${extra} more`, style: 'flex:0 0 auto;' }, `+${extra}`), + ); +} + +function computeOutputsSummary(ledgerTransaction) { + const outputs = Array.isArray(ledgerTransaction?.outputs) ? ledgerTransaction.outputs : []; + const count = outputs.length; + const total = outputs.reduce((sum, o) => sum + Number(o?.value ?? 0), 0); + return { count, total }; +} + +function CopyPill({ text }) { + const onCopy = async (e) => { + e.preventDefault(); + try { + await navigator.clipboard.writeText(String(text ?? '')); + } catch {} + }; + return h( + 'a', + { + class: 'pill linkish mono', + style: 'cursor:pointer; user-select:none;', + href: '#', + onClick: onCopy, + onKeyDown: (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onCopy(e); + } + }, + tabIndex: 0, + role: 'button', + }, + 'Copy', + ); +} + +export default function BlockDetailPage({ parameters }) { + const blockIdParameter = parameters[0]; + const blockId = Number.parseInt(String(blockIdParameter), 10); + const isValidId = Number.isInteger(blockId) && blockId >= 0; const [block, setBlock] = useState(null); - const [error, setError] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const [errorKind, setErrorKind] = useState(null); // 'invalid-id' | 'not-found' | 'network' | null + + const pageTitle = useMemo(() => `Block ${String(blockIdParameter)}`, [blockIdParameter]); + useEffect(() => { + document.title = pageTitle; + }, [pageTitle]); useEffect(() => { + setBlock(null); + setErrorMessage(''); + setErrorKind(null); + + if (!isValidId) { + setErrorKind('invalid-id'); + setErrorMessage('Invalid block id.'); + return; + } + + let alive = true; const controller = new AbortController(); (async () => { try { - const res = await fetch(BLOCK_DETAIL(blockId), { - signal: controller.signal, + const res = await fetch(API.BLOCK_DETAIL_BY_ID(blockId), { cache: 'no-cache', + signal: controller.signal, }); + if (res.status === 404 || res.status === 410) { + if (alive) { + setErrorKind('not-found'); + setErrorMessage('Block not found.'); + } + return; + } if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - setBlock(data); - } catch (err) { - if (!controller.signal.aborted) setError(err.message || 'Request failed'); + const payload = await res.json(); + if (alive) setBlock(payload); + } catch (e) { + if (!alive || e?.name === 'AbortError') return; + setErrorKind('network'); + setErrorMessage(e?.message ?? 'Failed to load block'); } })(); - return () => controller.abort(); - }, [blockId]); + return () => { + alive = false; + controller.abort(); + }; + }, [blockId, isValidId]); const header = block?.header ?? {}; - const transactions = block?.transactions ?? []; + const transactions = Array.isArray(block?.transactions) ? block.transactions : []; + const slot = block?.slot ?? header.slot; return h( 'main', { class: 'wrap' }, + + // Top bar h( 'header', { style: 'display:flex; gap:12px; align-items:center; margin:12px 0;' }, h('a', { class: 'linkish', href: '/' }, '← Back'), - h('h1', { style: 'margin:0' }, `Block ${shortenHex(blockId, 12, 12)}`), + h('h1', { style: 'margin:0' }, pageTitle), ), - error && h('p', { style: 'color:#ff8a8a' }, `Error: ${error}`), - !block && !error && h('p', null, 'Loading…'), + // Error states + errorKind === 'invalid-id' && h('p', { style: 'color:#ff8a8a' }, errorMessage), + errorKind === 'not-found' && + h( + 'div', + { class: 'card', style: 'margin-top:12px;' }, + h('div', { class: 'card-header' }, h('strong', null, 'Block not found')), + h( + 'div', + { style: 'padding:12px 14px' }, + h('p', null, 'We could not find a block with that identifier.'), + ), + ), + errorKind === 'network' && h('p', { style: 'color:#ff8a8a' }, `Error: ${errorMessage}`), + // Loading + !block && !errorKind && h('p', null, 'Loading…'), + + // Success block && h( Fragment, @@ -54,45 +156,68 @@ export default function BlockDetail({ params: routeParams }) { h( 'div', { class: 'card', style: 'margin-top:12px;' }, - h('div', { class: 'card-header' }, h('strong', null, 'Header')), h( 'div', - { style: 'padding:12px 14px' }, - h('div', null, h('b', null, 'Slot: '), h('span', { class: 'mono' }, header.slot ?? '')), + { class: 'card-header', style: 'display:flex; align-items:center; gap:8px;' }, + h('strong', null, 'Header'), h( 'div', - null, - h('b', null, 'Root: '), + { style: 'margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;' }, + slot != null && h('span', { class: 'pill', title: 'Slot' }, `Slot ${String(slot)}`), + ), + ), + h( + 'div', + { style: 'padding:12px 14px; display:grid; grid-template-columns: 120px 1fr; gap:8px 12px;' }, + + // Root (pill + copy) + h('div', null, h('b', null, 'Root:')), + h( + 'div', + { style: 'display:flex; gap:8px; flex-wrap:wrap; align-items:flex-start;' }, h( 'span', - { class: 'mono', title: header.block_root ?? '' }, - shortenHex(header.block_root ?? ''), - ), - ), - h( - 'div', - null, - h('b', null, 'Parent: '), - h( - 'a', { - class: 'linkish mono', - href: `/block/${header.parent_block ?? ''}`, - title: header.parent_block ?? '', + class: 'pill mono', + title: header.block_root ?? '', + style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;', }, - shortenHex(header.parent_block ?? ''), + String(header.block_root ?? ''), ), + h(CopyPill, { text: header.block_root }), ), + + // Parent (pill + copy) + h('div', null, h('b', null, 'Parent:')), h( 'div', - null, - h('b', null, 'Created: '), - h('span', { class: 'mono' }, formatTimestamp(block.created_at)), + { style: 'display:flex; gap:8px; flex-wrap:wrap; align-items:flex-start;' }, + block?.parent_id + ? h( + 'a', + { + class: 'pill mono linkish', + href: PAGE.BLOCK_DETAIL(block.parent_id), + title: String(block.parent_id), + style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;', + }, + String(block.parent_id), + ) + : h( + 'span', + { + class: 'pill mono', + title: header.parent_block ?? '', + style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;', + }, + String(header.parent_block ?? ''), + ), + h(CopyPill, { text: block?.parent_id ?? header.parent_block }), ), ), ), - // Transactions card + // Transactions card — rows fill width; Outputs & Gas centered h( 'div', { class: 'card', style: 'margin-top:16px;' }, @@ -104,75 +229,90 @@ export default function BlockDetail({ params: routeParams }) { ), h( 'div', - { class: 'table-wrapper' }, + { class: 'table-wrapper', style: 'max-width:100%; overflow:auto;' }, h( 'table', - { class: 'table--transactions' }, - h( - 'colgroup', - null, - h('col', { style: 'width:260px' }), - h('col', null), - h('col', { style: 'width:120px' }), - h('col', { style: 'width:180px' }), - ), + { + class: 'table--transactions', + // Fill card by default; expand + scroll if content is wider + style: 'min-width:100%; width:max-content; table-layout:auto; border-collapse:collapse;', + }, h( 'thead', null, h( 'tr', null, - h('th', null, 'Hash'), - h('th', null, 'From → To'), - h('th', null, 'Amount'), - h('th', null, 'Time'), + h('th', { style: 'text-align:left; padding:8px 10px; white-space:nowrap;' }, 'ID'), + h( + 'th', + { style: 'text-align:center; padding:8px 10px; white-space:nowrap;' }, + 'Outputs (count / total)', + ), + h( + 'th', + { style: 'text-align:center; padding:8px 10px; white-space:nowrap;' }, + 'Gas (execution / storage)', + ), + h( + 'th', + { style: 'text-align:left; padding:8px 10px; white-space:nowrap;' }, + 'Operations', + ), ), ), h( 'tbody', null, - ...transactions.map((tx) => - h( + ...transactions.map((t) => { + const operations = Array.isArray(t?.operations) ? t.operations : []; + const { count, total } = computeOutputsSummary(t?.ledger_transaction); + const executionGas = Number(t?.execution_gas_price ?? 0); + const storageGas = Number(t?.storage_gas_price ?? 0); + + return h( 'tr', - null, + { key: t?.id ?? `${count}/${total}` }, + // ID (left) h( 'td', - null, + { style: 'text-align:left; padding:8px 10px; white-space:nowrap;' }, h( 'a', { class: 'linkish mono', - href: `/transaction/${tx.hash}`, - title: tx.hash, + href: PAGE.TRANSACTION_DETAIL(t?.id ?? ''), + title: String(t?.id ?? ''), }, - shortenHex(tx.hash), + String(t?.id ?? ''), ), ), + // Outputs (center) h( 'td', - null, - h( - 'span', - { class: 'mono', title: tx.sender ?? '' }, - shortenHex(tx.sender ?? ''), - ), - ' \u2192 ', - h( - 'span', - { class: 'mono', title: tx.recipient ?? '' }, - shortenHex(tx.recipient ?? ''), - ), + { + class: 'amount', + style: 'text-align:center; padding:8px 10px; white-space:nowrap;', + }, + `${count} / ${Number(total).toLocaleString(undefined, { maximumFractionDigits: 8 })}`, ), + // Gas (center) h( 'td', - { class: 'amount' }, - Number(tx.amount ?? 0).toLocaleString(undefined, { - maximumFractionDigits: 8, - }), + { + class: 'mono', + style: 'text-align:center; padding:8px 10px; white-space:nowrap;', + }, + `${executionGas.toLocaleString()} / ${storageGas.toLocaleString()}`, ), - h('td', { class: 'mono' }, formatTimestamp(tx.timestamp)), - ), - ), + // Operations (left; no wrap) + h( + 'td', + { style: 'text-align:left; padding:8px 10px; white-space:nowrap;' }, + opsToPills(operations), + ), + ); + }), ), ), ), diff --git a/static/pages/TransactionDetail.js b/static/pages/TransactionDetail.js index 9bd2f22..86199c3 100644 --- a/static/pages/TransactionDetail.js +++ b/static/pages/TransactionDetail.js @@ -1,35 +1,315 @@ -import { h } from 'preact'; -import { useEffect, useState } from 'preact/hooks'; -import { shortenHex, formatTimestamp } from '../lib/utils.js?dev=1'; -import { TRANSACTION_DETAIL } from '../lib/api.js?dev=1'; +// static/pages/TransactionDetail.js +import { h, Fragment } from 'preact'; +import { useEffect, useMemo, useState } from 'preact/hooks'; +import { API } from '../lib/api.js?dev=1'; -export default function TransactionDetail({ params: routeParams }) { - const transactionId = routeParams[0]; +// ————— helpers ————— +const isNumber = (v) => typeof v === 'number' && !Number.isNaN(v); +const toLocaleNum = (n, opts = {}) => Number(n ?? 0).toLocaleString(undefined, { maximumFractionDigits: 8, ...opts }); - const [transaction, setTransaction] = useState(null); - const [error, setError] = useState(null); +// Try to render bytes in a readable way without guessing too hard +function renderBytes(value) { + if (typeof value === 'string') return value; // hex/base64/etc. + if (Array.isArray(value) && value.every((x) => Number.isInteger(x) && x >= 0 && x <= 255)) { + return '0x' + value.map((b) => b.toString(16).padStart(2, '0')).join(''); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +// ————— normalizer (robust to partial data) ————— +function normalizeTransaction(raw) { + const ops = Array.isArray(raw?.operations) ? raw.operations : []; + const lt = raw?.ledger_transaction ?? {}; + const inputs = Array.isArray(lt?.inputs) ? lt.inputs : []; + const outputs = Array.isArray(lt?.outputs) ? lt.outputs : []; + + const totalOutputValue = outputs.reduce((sum, note) => sum + Number(note?.value ?? 0), 0); + + return { + id: raw?.id ?? '', + blockId: raw?.block_id ?? null, + operations: ops.map(String), + executionGasPrice: isNumber(raw?.execution_gas_price) + ? raw.execution_gas_price + : Number(raw?.execution_gas_price ?? 0), + storageGasPrice: isNumber(raw?.storage_gas_price) ? raw.storage_gas_price : Number(raw?.storage_gas_price ?? 0), + ledger: { inputs, outputs, totalOutputValue }, + }; +} + +// ————— UI bits ————— +function SectionCard({ title, children, style }) { + return h( + 'div', + { class: 'card', style: `margin-top:12px; ${style ?? ''}` }, + h('div', { class: 'card-header' }, h('strong', null, title)), + h('div', { style: 'padding:12px 14px' }, children), + ); +} + +function Summary({ tx }) { + return h( + SectionCard, + { title: 'Summary' }, + h( + 'div', + { style: 'display:grid; gap:8px;' }, + + // (ID removed) + + tx.blockId != null && + h( + 'div', + null, + h('b', null, 'Block: '), + h( + 'a', + { class: 'linkish mono', href: API.BLOCK_DETAIL_BY_ID(tx.blockId), title: String(tx.blockId) }, + String(tx.blockId), + ), + ), + + h( + 'div', + null, + h('b', null, 'Execution Gas: '), + h('span', { class: 'mono' }, toLocaleNum(tx.executionGasPrice)), + ), + h( + 'div', + null, + h('b', null, 'Storage Gas: '), + h('span', { class: 'mono' }, toLocaleNum(tx.storageGasPrice)), + ), + + h( + 'div', + null, + h('b', null, 'Operations: '), + tx.operations?.length + ? h( + 'span', + { style: 'display:inline-flex; gap:6px; flex-wrap:wrap; vertical-align:middle;' }, + ...tx.operations.map((op, i) => h('span', { key: i, class: 'pill', title: op }, op)), + ) + : h('span', { style: 'color:var(--muted)' }, '—'), + ), + ), + ); +} + +function InputsTable({ inputs }) { + if (!inputs?.length) { + return h('div', { style: 'color:var(--muted)' }, '—'); + } + + return h( + 'div', + { class: 'table-wrapper', style: 'margin-top:6px;' }, + h( + 'table', + { class: 'table--transactions' }, + h( + 'colgroup', + null, + h('col', { style: 'width:80px' }), // # + h('col', null), // Value (fills) + ), + h('thead', null, h('tr', null, h('th', { style: 'text-align:center;' }, '#'), h('th', null, 'Value'))), + h( + 'tbody', + null, + ...inputs.map((fr, i) => + h( + 'tr', + { key: i }, + h('td', { style: 'text-align:center;' }, String(i)), + h( + 'td', + null, + h( + 'span', + { class: 'mono', style: 'overflow-wrap:anywhere; word-break:break-word;' }, + String(fr), + ), + ), + ), + ), + ), + ), + ); +} + +function OutputsTable({ outputs }) { + if (!outputs?.length) { + return h('div', { style: 'color:var(--muted)' }, '—'); + } + + return h( + 'div', + { class: 'table-wrapper', style: 'margin-top:6px;' }, + h( + 'table', + { class: 'table--transactions' }, + h( + 'colgroup', + null, + h('col', { style: 'width:80px' }), // # (compact, centered) + h('col', null), // Public Key (fills) + h('col', { style: 'width:180px' }), // Value (compact, right) + ), + h( + 'thead', + null, + h( + 'tr', + null, + h('th', { style: 'text-align:center;' }, '#'), + h('th', null, 'Public Key'), // ← back to Public Key second + h('th', { style: 'text-align:right;' }, 'Value'), // ← Value last + ), + ), + h( + 'tbody', + null, + ...outputs.map((note, idx) => + h( + 'tr', + { key: idx }, + // # (index) + h('td', { style: 'text-align:center;' }, String(idx)), + + // Public Key (fills, wraps) + h( + 'td', + null, + h( + 'span', + { + class: 'mono', + style: 'display:inline-block; overflow-wrap:anywhere; word-break:break-word;', + title: renderBytes(note?.public_key), + }, + renderBytes(note?.public_key), + ), + ), + + // Value (right-aligned) + h('td', { class: 'amount', style: 'text-align:right;' }, toLocaleNum(note?.value)), + ), + ), + ), + ), + ); +} + +function Ledger({ ledger }) { + const { inputs, outputs, totalOutputValue } = ledger; + + // Sum inputs as integers (Fr is declared as int in your schema) + const totalInputValue = inputs.reduce((sum, v) => sum + Number(v ?? 0), 0); + + return h( + SectionCard, + { title: 'Ledger Transaction' }, + h( + 'div', + { style: 'display:grid; gap:16px;' }, + + // Inputs (with Total on the right) + h( + 'div', + null, + h( + 'div', + { style: 'display:flex; align-items:center; gap:8px;' }, + h('b', null, 'Inputs'), + h('span', { class: 'pill' }, String(inputs.length)), + h( + 'span', + { class: 'amount', style: 'margin-left:auto;' }, + `Total: ${toLocaleNum(totalInputValue)}`, + ), + ), + h(InputsTable, { inputs }), + ), + + // Outputs (unchanged header total) + h( + 'div', + null, + h( + 'div', + { style: 'display:flex; align-items:center; gap:8px;' }, + h('b', null, 'Outputs'), + h('span', { class: 'pill' }, String(outputs.length)), + h( + 'span', + { class: 'amount', style: 'margin-left:auto;' }, + `Total: ${toLocaleNum(totalOutputValue)}`, + ), + ), + h(OutputsTable, { outputs }), + ), + ), + ); +} + +// ————— page ————— +export default function TransactionDetail({ parameters }) { + const idParam = parameters?.[0]; + const id = Number.parseInt(String(idParam), 10); + const isValidId = Number.isInteger(id) && id >= 0; + + const [tx, setTx] = useState(null); + const [err, setErr] = useState(null); // { kind: 'invalid'|'not-found'|'network', msg: string } + + const pageTitle = useMemo(() => `Transaction ${String(idParam)}`, [idParam]); + useEffect(() => { + document.title = pageTitle; + }, [pageTitle]); useEffect(() => { + setTx(null); + setErr(null); + + if (!isValidId) { + setErr({ kind: 'invalid', msg: 'Invalid transaction id.' }); + return; + } + + let alive = true; const controller = new AbortController(); (async () => { try { - const response = await fetch(TRANSACTION_DETAIL(transactionId), { - signal: controller.signal, + const res = await fetch(API.TRANSACTION_DETAIL_BY_ID(id), { cache: 'no-cache', + signal: controller.signal, }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const data = await response.json(); - setTransaction(data); - } catch (err) { - if (!controller.signal.aborted) { - setError(err.message || 'Request failed'); + if (res.status === 404 || res.status === 410) { + if (alive) setErr({ kind: 'not-found', msg: 'Transaction not found.' }); + return; } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const payload = await res.json(); + if (!alive) return; + setTx(normalizeTransaction(payload)); + } catch (e) { + if (!alive || e?.name === 'AbortError') return; + setErr({ kind: 'network', msg: e?.message ?? 'Failed to load transaction' }); } })(); - return () => controller.abort(); - }, [transactionId]); + return () => { + alive = false; + controller.abort(); + }; + }, [id, isValidId]); return h( 'main', @@ -39,80 +319,23 @@ export default function TransactionDetail({ params: routeParams }) { 'header', { style: 'display:flex; gap:12px; align-items:center; margin:12px 0;' }, h('a', { class: 'linkish', href: '/' }, '← Back'), - h('h1', { style: 'margin:0' }, `Transaction ${shortenHex(transactionId, 12, 12)}`), + h('h1', { style: 'margin:0' }, pageTitle), ), - error && h('p', { style: 'color:#ff8a8a' }, `Error: ${error}`), - !transaction && !error && h('p', null, 'Loading…'), - - transaction && + // Errors + err?.kind === 'invalid' && h('p', { style: 'color:#ff8a8a' }, err.msg), + err?.kind === 'not-found' && h( - 'div', - { class: 'card', style: 'margin-top:12px;' }, - h('div', { class: 'card-header' }, h('strong', null, 'Overview')), - h( - 'div', - { style: 'padding:12px 14px' }, - h( - 'div', - null, - h('b', null, 'Hash: '), - h('span', { class: 'mono', title: transaction.hash }, shortenHex(transaction.hash)), - ), - h( - 'div', - null, - h('b', null, 'From: '), - h( - 'span', - { class: 'mono', title: transaction.sender ?? '' }, - shortenHex(transaction.sender ?? ''), - ), - ), - h( - 'div', - null, - h('b', null, 'To: '), - h( - 'span', - { class: 'mono', title: transaction.recipient ?? '' }, - shortenHex(transaction.recipient ?? ''), - ), - ), - h( - 'div', - null, - h('b', null, 'Amount: '), - h( - 'span', - { class: 'amount' }, - Number(transaction.amount ?? 0).toLocaleString(undefined, { - maximumFractionDigits: 8, - }), - ), - ), - h( - 'div', - null, - h('b', null, 'Time: '), - h('span', { class: 'mono' }, formatTimestamp(transaction.timestamp)), - ), - transaction.block_root && - h( - 'div', - null, - h('b', null, 'Block: '), - h( - 'a', - { - class: 'linkish mono', - href: `/block/${transaction.block_root}`, - title: transaction.block_root, - }, - shortenHex(transaction.block_root), - ), - ), - ), + SectionCard, + { title: 'Transaction not found' }, + h('p', null, 'We could not find a transaction with that identifier.'), ), + err?.kind === 'network' && h('p', { style: 'color:#ff8a8a' }, `Error: ${err.msg}`), + + // Loading + !tx && !err && h('p', null, 'Loading…'), + + // Success + tx && h(Fragment, null, h(Summary, { tx }), h(Ledger, { ledger: tx.ledger })), ); }