Rework statics, integrate with node.

This commit is contained in:
Alejandro Cabeza Romero 2025-10-20 15:42:12 +02:00
parent 1325799edb
commit 68c5e45804
No known key found for this signature in database
GPG Key ID: DA3D14AE478030FD
31 changed files with 1104 additions and 507 deletions

View File

@ -1,23 +1,25 @@
# Nomos Block Explorer # 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 - Better backfilling
- Ensure transactions - Upsert on backfill
- Change Sqlite -> Postgres - Change Sqlite -> Postgres
- Performance improvements on API and DB calls - Performance improvements on API and DB calls
- Fix assumptions, so we don't rely on them
- DbRepository interfaces - DbRepository interfaces
- Setup DB Migrations - Setup DB Migrations
- Tests - Tests
- Fix ordering for Blocks and Transactions - Fix ordering for Blocks and Transactions
- Fix assumption of 1 block per slot - Fix assumption of 1 block per slot
- Split the single file static into components - Split the single file static into components
- Log colouring
- Store hashes
- Get transaction by hash - Get transaction by hash
- Get block by hash - Get block by hash
# Demo
- Get transaction by id
- Get block by id
- Block viewer
- Transaction viewer
- htm
- Show transactions in table

View File

@ -1,9 +1,9 @@
from fastapi import APIRouter from fastapi import APIRouter
from .v1.router import router as v1_router from .v1.router import create_v1_router
def create_api_router() -> APIRouter: def create_api_router() -> APIRouter:
router = APIRouter() router = APIRouter()
router.include_router(v1_router, prefix="/v1") router.include_router(create_v1_router(), prefix="/v1")
return router return router

View File

@ -1,9 +1,10 @@
import logging import logging
from typing import AsyncIterable, AsyncIterator, List, Union 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] Stream = AsyncIterator[Data]

View File

@ -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 fastapi import Path, Query
from starlette.responses import Response from starlette.responses import JSONResponse, Response
from api.streams import into_ndjson_stream from api.streams import into_ndjson_stream
from api.v1.serializers.blocks import BlockRead
from core.api import NBERequest, NDJsonStreamingResponse from core.api import NBERequest, NDJsonStreamingResponse
from node.models.blocks import Block 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 ( async def _get_latest(request: NBERequest, limit: int) -> List[BlockRead]:
[] blocks = await request.app.state.block_repository.get_latest(limit=limit, ascending=True)
if prefetch_limit == 0 else return [BlockRead.from_block(block) for block in blocks]
await request.app.state.block_repository.get_latest(limit=prefetch_limit, ascending=True)
)
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: 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) bootstrap_blocks: List[BlockRead] = await _prefetch_blocks(request, prefetch_limit)
highest_slot: int = max((block.slot for block in bootstrap_blocks), default=0) latest_block = bootstrap_blocks[-1] if bootstrap_blocks else None
updates_stream = request.app.state.block_repository.updates_stream(slot_from=highest_slot + 1) updates_stream: AsyncIterator[List[BlockRead]] = _updates_stream(request.app, latest_block)
block_stream = into_ndjson_stream(stream=updates_stream, bootstrap_data=bootstrap_blocks) ndjson_blocks_stream = into_ndjson_stream(stream=updates_stream, bootstrap_data=bootstrap_blocks)
return NDJsonStreamingResponse(block_stream) 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)
)

View File

@ -2,10 +2,19 @@ from fastapi import APIRouter
from . import blocks, health, index, transactions 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"]) def create_v1_router() -> APIRouter:
router.add_api_route("/blocks/stream", blocks.stream, methods=["GET"]) 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

View File

View File

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

View File

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

View File

@ -1,30 +1,40 @@
from datetime import datetime from http.client import NOT_FOUND
from typing import List from typing import TYPE_CHECKING, AsyncIterator, List, Optional
from fastapi import Query from fastapi import Path, Query
from starlette.responses import Response from starlette.responses import JSONResponse, Response
from api.streams import into_ndjson_stream from api.streams import into_ndjson_stream
from api.v1.serializers.transactions import TransactionRead
from core.api import NBERequest, NDJsonStreamingResponse from core.api import NBERequest, NDJsonStreamingResponse
from node.models.transactions import Transaction 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]: async def _updates_stream(
return ( app: "NBE", latest_transaction: Optional[Transaction]
[] ) -> AsyncIterator[List[TransactionRead]]:
if prefetch_limit == 0 else _stream = app.state.transaction_repository.updates_stream(transaction_from=latest_transaction)
await request.app.state.transaction_repository.get_latest(limit=prefetch_limit, descending=False) 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: 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) latest_transactions: List[Transaction] = await request.app.state.transaction_repository.get_latest(
highest_timestamp: datetime = max( limit=prefetch_limit, ascending=True, preload_relationships=True
(transaction.timestamp for transaction in bootstrap_transactions), default=datetime.min
) )
updates_stream = request.app.state.transaction_repository.updates_stream( latest_transaction = latest_transactions[-1] if latest_transactions else None
timestamp_from=increment_datetime(highest_timestamp) latest_transaction_read = [TransactionRead.from_transaction(transaction) for transaction in latest_transactions]
)
transaction_stream = into_ndjson_stream(stream=updates_stream, bootstrap_data=bootstrap_transactions) updates_stream: AsyncIterator[List[TransactionRead]] = _updates_stream(request.app, latest_transaction)
return NDJsonStreamingResponse(transaction_stream) 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))

22
src/core/db.py Normal file
View File

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

View File

@ -1,35 +1,16 @@
from asyncio import sleep from asyncio import sleep
from typing import AsyncIterator, List, Literal from typing import AsyncIterator, List, Optional
from rusty_results import Empty, Option, Some 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 sqlalchemy.orm import aliased
from sqlmodel import select 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 db.clients import DbClient
from node.models.blocks import Block 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: def get_latest_statement(limit: int, latest_ascending: bool = True) -> Select:
# Fetch latest # Fetch latest
descending = order_by_json(Block.header, "$.slot", into_type="int", descending=True) descending = order_by_json(Block.header, "$.slot", into_type="int", descending=True)
@ -63,8 +44,21 @@ class BlockRepository:
results: Result[Block] = session.exec(statement) results: Result[Block] = session.exec(statement)
return results.all() return results.all()
async def updates_stream(self, slot_from: int, *, timeout_seconds: int = 1) -> AsyncIterator[List[Block]]: async def get_by_id(self, block_id: int) -> Option[Block]:
slot_cursor = slot_from 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") block_slot_expression = jget(Block.header, "$.slot", into_type="int")
order = order_by_json(Block.header, "$.slot", into_type="int", descending=False) order = order_by_json(Block.header, "$.slot", into_type="int", descending=False)
@ -82,9 +76,10 @@ class BlockRepository:
await sleep(timeout_seconds) await sleep(timeout_seconds)
async def get_earliest(self) -> Option[Block]: 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: 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) results: Result[Block] = session.exec(statement)
if (block := results.first()) is not None: if (block := results.first()) is not None:
return Some(block) return Some(block)

View File

@ -1,14 +1,42 @@
from asyncio import sleep from asyncio import sleep
from datetime import datetime from typing import AsyncIterator, Iterable, List, Optional
from typing import AsyncIterator, Iterable, List
from rusty_results import Empty, Option, Some 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 sqlmodel import select
from core.db import jget, order_by_json
from db.clients import DbClient from db.clients import DbClient
from node.models.transactions import Transaction 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: class TransactionRepository:
@ -20,49 +48,49 @@ class TransactionRepository:
session.add_all(transaction) session.add_all(transaction)
session.commit() session.commit()
async def get_latest(self, limit: int, descending: bool = True) -> List[Transaction]: async def get_latest(self, limit: int, *, ascending: bool = True, **kwargs) -> List[Transaction]:
return [] statement = get_latest_statement(limit, ascending, **kwargs)
statement = select(Transaction).limit(limit)
if descending:
statement = statement.order_by(Transaction.timestamp.desc())
else:
statement = statement.order_by(Transaction.timestamp.asc())
with self.client.session() as session: with self.client.session() as session:
results: Result[Transaction] = session.exec(statement) results: Result[Transaction] = session.exec(statement)
return results.all() return results.all()
async def updates_stream(self, timestamp_from: datetime) -> AsyncIterator[List[Transaction]]: async def get_by_id(self, transaction_id: int) -> Option[Transaction]:
while True: statement = select(Transaction).where(Transaction.id == transaction_id)
if False:
yield [] with self.client.session() as session:
await sleep(10) 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: 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 = ( statement = (
select(Transaction) select(Transaction)
.where(Transaction.timestamp >= _timestamp_from) .options(selectinload(Transaction.block))
.order_by(Transaction.timestamp.asc()) .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: with self.client.session() as session:
transactions: List[Transaction] = session.exec(statement).all() transactions: List[Transaction] = session.exec(statement).all()
if len(transactions) > 0: if len(transactions) > 0:
# POC: Assumes transactions are inserted in order and with a minimum 1 of second difference slot_cursor = transactions[-1].block.slot + 1
_timestamp_from = increment_datetime(transactions[-1].timestamp) yield transactions
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)
else: else:
return Empty() await sleep(timeout_seconds)

View File

@ -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 starlette.responses import FileResponse
from . import STATIC_DIR from . import STATIC_DIR
@ -6,11 +8,13 @@ from . import STATIC_DIR
INDEX_FILE = STATIC_DIR.joinpath("index.html") 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) return FileResponse(INDEX_FILE)
def create_frontend_router() -> APIRouter: def create_frontend_router() -> APIRouter:
router = APIRouter() router = APIRouter()
router.get("/", include_in_schema=False)(spa) router.get("/{path:path}", include_in_schema=False)(spa)
return router return router

View File

@ -11,10 +11,6 @@ class NodeApi(ABC):
async def get_health_check(self) -> Health: async def get_health_check(self) -> Health:
pass pass
@abstractmethod
async def get_transactions(self) -> List[Transaction]:
pass
@abstractmethod @abstractmethod
async def get_blocks(self, **kwargs) -> List[Block]: async def get_blocks(self, **kwargs) -> List[Block]:
pass pass

View File

@ -20,9 +20,6 @@ class FakeNodeApi(NodeApi):
else: else:
return Health.from_healthy() 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]: async def get_blocks(self) -> List[Block]:
return [Block.from_random() for _ in range(1)] return [Block.from_random() for _ in range(1)]

View File

@ -37,12 +37,6 @@ class HttpNodeApi(NodeApi):
else: else:
return Health.from_unhealthy() 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]: async def get_blocks(self, slot_from: int, slot_to: int) -> List[Block]:
query_string = f"slot_from={slot_from}&slot_to={slot_to}" query_string = f"slot_from={slot_from}&slot_to={slot_to}"
endpoint = urljoin(self.base_url, self.ENDPOINT_BLOCKS) endpoint = urljoin(self.base_url, self.ENDPOINT_BLOCKS)

View File

@ -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) 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}...") logger.debug(f"Backfilling {len(blocks)} blocks from slot {slot_from} to {slot_to}...")
await app.state.block_repository.create(*blocks) await app.state.block_repository.create(*blocks)
slot_to = slot_from slot_to = slot_from - 1
logger.info("Backfilling blocks completed.") logger.info("Backfilling blocks completed.")

View File

@ -1,17 +1,20 @@
import logging import logging
import os import os
import random import random
from typing import Any, List, Self from typing import TYPE_CHECKING, Any, List, Self
from pydantic.config import ExtraValues from pydantic.config import ExtraValues
from pydantic_core.core_schema import computed_field
from sqlalchemy import Column from sqlalchemy import Column
from sqlmodel import Field from sqlmodel import Field, Relationship
from core.models import NbeSchema, TimestampedModel from core.models import NbeSchema, TimestampedModel
from core.sqlmodel import PydanticJsonColumn from core.sqlmodel import PydanticJsonColumn
from node.models.transactions import Transaction
from utils.random import random_hash from utils.random import random_hash
if TYPE_CHECKING:
from node.models.transactions import Transaction
def _is_debug__randomize_transactions(): def _is_debug__randomize_transactions():
is_debug = os.getenv("DEBUG", "False").lower() == "true" is_debug = os.getenv("DEBUG", "False").lower() == "true"
@ -81,11 +84,15 @@ class Header(NbeSchema):
class Block(TimestampedModel, table=True): class Block(TimestampedModel, table=True):
__tablename__ = "blocks" __tablename__ = "block"
header: Header = Field(sa_column=Column(PydanticJsonColumn(Header), nullable=False)) header: Header = Field(sa_column=Column(PydanticJsonColumn(Header), nullable=False))
transactions: List[Transaction] = Field( transactions: List["Transaction"] = Relationship(
default_factory=list, sa_column=Column(PydanticJsonColumn(Transaction, many=True), nullable=False) back_populates="block",
sa_relationship_kwargs={
"lazy": "selectin",
"cascade": "all, delete-orphan",
},
) )
@property @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 json_data, strict=strict, extra=extra, context=context, by_alias=by_alias, by_name=by_name
) )
if _is_debug__randomize_transactions(): if _is_debug__randomize_transactions():
from node.models.transactions import Transaction
logger.debug("DEBUG and DEBUG__RANDOMIZE_TRANSACTIONS is enabled, randomizing Block's transactions.") 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) n = 0 if random.randint(0, 1) <= 0.5 else random.randint(1, 10)
self.transactions = [Transaction.from_random() for _ in range(n)] self.transactions = [Transaction.from_random() for _ in range(n)]
@ -120,9 +129,9 @@ class Block(TimestampedModel, table=True):
@classmethod @classmethod
def from_random(cls, slot_from: int = 1, slot_to: int = 100) -> "Block": def from_random(cls, slot_from: int = 1, slot_to: int = 100) -> "Block":
n = random.randint(1, 10) n = 0 if random.randint(0, 1) < 0.3 else random.randint(1, 5)
_transactions = [Transaction.from_random() for _ in range(n)] transactions = [Transaction.from_random() for _ in range(n)]
return Block( return Block(
header=Header.from_random(slot_from, slot_to), header=Header.from_random(slot_from, slot_to),
transactions=[], transactions=transactions,
) )

View File

@ -1,13 +1,17 @@
import random import random
from enum import StrEnum from enum import StrEnum
from typing import List from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import JSON, Column from sqlalchemy import JSON, Column
from sqlmodel import Field from sqlmodel import Field, Relationship
from core.models import NbeSchema, TimestampedModel from core.models import NbeSchema, TimestampedModel
from core.sqlmodel import PydanticJsonColumn
from utils.random import random_address from utils.random import random_address
if TYPE_CHECKING:
from node.models.blocks import Block
Value = int Value = int
Fr = int Fr = int
Gas = float Gas = float
@ -33,7 +37,7 @@ class Note(NbeSchema):
def from_random(cls) -> "Note": def from_random(cls) -> "Note":
return Note( return Note(
value=random.randint(1, 100), 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 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)) 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 execution_gas_price: Gas
storage_gas_price: Gas storage_gas_price: Gas
block: Optional["Block"] = Relationship(back_populates="transactions")
def __str__(self) -> str: def __str__(self) -> str:
return f"Transaction({self.operations})" return f"Transaction({self.operations})"
@ -74,7 +82,7 @@ class Transaction(NbeSchema): # table=true # It currently lives inside Block
@classmethod @classmethod
def from_random(cls) -> "Transaction": 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)] operations = [random.choice(list(Operation)).value for _ in range(n)]
return Transaction( return Transaction(
operations=operations, operations=operations,

View File

@ -30,6 +30,7 @@ def create_router() -> APIRouter:
router.include_router(create_api_router(), prefix="/api") router.include_router(create_api_router(), prefix="/api")
if bool(environ.get("DEBUG")): if bool(environ.get("DEBUG")):
router.add_route("/debug", debug_router) 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 return router

View File

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

View File

@ -30,20 +30,22 @@ const ROUTES = [
}, },
{ {
name: 'blockDetail', name: 'blockDetail',
re: /^\/block\/([^/]+)$/, re: /^\/blocks\/([^/]+)$/,
view: ({ params }) => h(AppShell, null, h(BlockDetailPage, { params })), view: ({ parameters }) => {
return h(AppShell, null, h(BlockDetailPage, { parameters }));
},
}, },
{ {
name: 'transactionDetail', name: 'transactionDetail',
re: /^\/transaction\/([^/]+)$/, re: /^\/transactions\/([^/]+)$/,
view: ({ params }) => h(AppShell, null, h(TransactionDetailPage, { params })), view: ({ parameters }) => h(AppShell, null, h(TransactionDetailPage, { parameters })),
}, },
]; ];
function AppRouter() { function AppRouter() {
const wired = ROUTES.map((r) => ({ const wired = ROUTES.map((route) => ({
re: r.re, re: route.re,
view: (match) => r.view({ params: match }), view: route.view,
})); }));
return h(Router, { routes: wired }); return h(Router, { routes: wired });
} }

View File

@ -1,123 +1,133 @@
// static/pages/BlocksTable.js
import { h } from 'preact'; import { h } from 'preact';
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef } from 'preact/hooks';
import { BLOCKS_ENDPOINT, TABLE_SIZE } from '../lib/api.js?dev=1'; import { PAGE, API, TABLE_SIZE } from '../lib/api.js?dev=1';
import {streamNdjson, ensureFixedRowCount, shortenHex, formatTimestamp, withBenignFilter} from '../lib/utils.js?dev=1'; import { streamNdjson, ensureFixedRowCount, shortenHex } from '../lib/utils.js?dev=1';
export default function BlocksTable() { export default function BlocksTable() {
const tbodyRef = useRef(null); const bodyRef = useRef(null);
const countRef = useRef(null); const countRef = useRef(null);
const abortRef = useRef(null); const abortRef = useRef(null);
const seenKeysRef = useRef(new Set()); const seenKeysRef = useRef(new Set());
useEffect(() => { useEffect(() => {
const tbody = tbodyRef.current; const body = bodyRef.current;
const counter = countRef.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?.abort();
abortRef.current = new AbortController(); abortRef.current = new AbortController();
function pruneAndPad() { const pruneAndPad = () => {
// remove placeholders for (let i = body.rows.length - 1; i >= 0; i--) {
for (let i = tbody.rows.length - 1; i >= 0; i--) { if (body.rows[i].classList.contains('ph')) body.deleteRow(i);
if (tbody.rows[i].classList.contains('ph')) tbody.deleteRow(i);
} }
// trim overflow while ([...body.rows].filter((r) => !r.classList.contains('ph')).length > TABLE_SIZE) {
while ([...tbody.rows].filter((r) => !r.classList.contains('ph')).length > TABLE_SIZE) { const last = body.rows[body.rows.length - 1];
const last = tbody.rows[tbody.rows.length - 1];
const key = last?.dataset?.key; const key = last?.dataset?.key;
if (key) seenKeysRef.current.delete(key); if (key) seenKeysRef.current.delete(key);
tbody.deleteRow(-1); body.deleteRow(-1);
} }
// pad placeholders // keep placeholders in sync with 5 columns
const real = [...tbody.rows].filter((r) => !r.classList.contains('ph')).length; ensureFixedRowCount(body, 5, TABLE_SIZE);
ensureFixedRowCount(tbody, 5, TABLE_SIZE); const real = [...body.rows].filter((r) => !r.classList.contains('ph')).length;
counter.textContent = String(real); 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 navigateToBlockDetail = (blockId) => {
const row = document.createElement('tr'); history.pushState({}, '', PAGE.BLOCK_DETAIL(blockId));
row.dataset.key = key; window.dispatchEvent(new PopStateEvent('popstate'));
};
const cellSlot = document.createElement('td'); const appendRow = (b, key) => {
const spanSlot = document.createElement('span'); const tr = document.createElement('tr');
spanSlot.className = 'mono'; tr.dataset.key = key;
spanSlot.textContent = String(block.slot);
cellSlot.appendChild(spanSlot);
const cellRoot = document.createElement('td'); // ID (clickable)
cellRoot.appendChild(makeLink(`/block/${block.root}`, shortenHex(block.root), block.root)); 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'); // Slot
cellParent.appendChild(makeLink(`/block/${block.parent}`, shortenHex(block.parent), block.parent)); 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'); // Root
const spanTx = document.createElement('span'); const tdRoot = document.createElement('td');
spanTx.className = 'mono'; const spRoot = document.createElement('span');
spanTx.textContent = String(block.transactionCount); spRoot.className = 'mono';
cellTxCount.appendChild(spanTx); spRoot.title = b.root;
spRoot.textContent = shortenHex(b.root);
tdRoot.appendChild(spRoot);
const cellTime = document.createElement('td'); // Parent
const spanTime = document.createElement('span'); const tdParent = document.createElement('td');
spanTime.className = 'mono'; const spParent = document.createElement('span');
spanTime.title = block.time ?? ''; spParent.className = 'mono';
spanTime.textContent = formatTimestamp(block.time); spParent.title = b.parent;
cellTime.appendChild(spanTime); spParent.textContent = shortenHex(b.parent);
tdParent.appendChild(spParent);
row.append(cellSlot, cellRoot, cellParent, cellTxCount, cellTime); // Transactions (array length)
tbody.insertBefore(row, tbody.firstChild); 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(); pruneAndPad();
}; };
const normalize = (raw) => { const normalize = (raw) => {
const header = raw.header ?? 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 { return {
id: Number(raw.id ?? 0), id: Number(raw.id ?? 0),
slot: Number(header?.slot ?? raw.slot ?? 0), slot: Number(header?.slot ?? raw.slot ?? 0),
root: header?.block_root ?? raw.block_root ?? '', root: header?.block_root ?? raw.block_root ?? '',
parent: header?.parent_block ?? raw.parent_block ?? '', parent: header?.parent_block ?? raw.parent_block ?? '',
transactionCount: Array.isArray(raw.transactions) transactionCount: txLen,
? raw.transactions.length
: typeof raw.transaction_count === 'number'
? raw.transaction_count
: 0,
time: created,
}; };
}; };
const url = `${BLOCKS_ENDPOINT}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`;
streamNdjson( streamNdjson(
url, `${API.BLOCKS_STREAM}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`,
(raw) => { (raw) => {
const block = normalize(raw); const b = normalize(raw);
const key = `${block.slot}:${block.id}`; const key = `${b.id}:${b.slot}`;
if (seenKeysRef.current.has(key)) { if (seenKeysRef.current.has(key)) {
pruneAndPad(); pruneAndPad();
return; return;
} }
seenKeysRef.current.add(key); seenKeysRef.current.add(key);
appendRow(block, key); appendRow(b, key);
}, },
{ {
signal: abortRef.current.signal, signal: abortRef.current.signal,
onError: withBenignFilter( onError: (e) => {
(e) => console.error('Blocks stream error:', e), console.error('Blocks stream error:', e);
abortRef.current.signal },
)
}, },
).catch((err) => { );
if (!abortRef.current.signal.aborted) console.error('Blocks stream error:', err);
});
return () => abortRef.current?.abort(); return () => abortRef.current?.abort();
}, []); }, []);
@ -129,7 +139,7 @@ export default function BlocksTable() {
'div', 'div',
{ class: 'card-header' }, { class: 'card-header' },
h('div', null, h('strong', null, 'Blocks '), h('span', { class: 'pill', ref: countRef }, '0')), 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( h(
'div', 'div',
@ -140,18 +150,26 @@ export default function BlocksTable() {
h( h(
'colgroup', 'colgroup',
null, null,
h('col', { style: 'width:90px' }), h('col', { style: 'width:80px' }), // ID
h('col', { style: 'width:260px' }), h('col', { style: 'width:90px' }), // Slot
h('col', { style: 'width:260px' }), h('col', { style: 'width:240px' }), // Root
h('col', { style: 'width:120px' }), h('col', { style: 'width:240px' }), // Parent
h('col', { style: 'width:180px' }), h('col', { style: 'width:120px' }), // Transactions
), ),
h( h(
'thead', 'thead',
null, 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 }),
), ),
), ),
); );

View File

@ -1,7 +1,7 @@
import { h } from 'preact'; import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { HEALTH_ENDPOINT } from '../lib/api.js'; import { API } from '../lib/api.js';
import {streamNdjson, withBenignFilter} from '../lib/utils.js'; import { streamNdjson, withBenignFilter } from '../lib/utils.js';
const STATUS = { const STATUS = {
CONNECTING: 'connecting', CONNECTING: 'connecting',
@ -28,7 +28,7 @@ export default function HealthPill() {
abortRef.current = new AbortController(); abortRef.current = new AbortController();
streamNdjson( streamNdjson(
HEALTH_ENDPOINT, API.HEALTH_ENDPOINT,
(item) => { (item) => {
if (typeof item?.healthy === 'boolean') { if (typeof item?.healthy === 'boolean') {
setStatus(item.healthy ? STATUS.ONLINE : STATUS.OFFLINE); setStatus(item.healthy ? STATUS.ONLINE : STATUS.OFFLINE);
@ -36,15 +36,12 @@ export default function HealthPill() {
}, },
{ {
signal: abortRef.current.signal, signal: abortRef.current.signal,
onError: withBenignFilter( onError: withBenignFilter((err) => {
(err) => { if (!abortRef.current.signal.aborted) {
if (!abortRef.current.signal.aborted) { console.error('Health stream error:', err);
console.error('Health stream error:', err); setStatus(STATUS.OFFLINE);
setStatus(STATUS.OFFLINE); }
} }, abortRef.current.signal),
},
abortRef.current.signal
),
}, },
); );

View File

@ -8,14 +8,13 @@ export default function AppRouter({ routes }) {
const handlePopState = () => setMatch(resolveRoute(location.pathname, routes)); const handlePopState = () => setMatch(resolveRoute(location.pathname, routes));
const handleLinkClick = (event) => { const handleLinkClick = (event) => {
// Only intercept unmodified left-clicks if (event.defaultPrevented || event.button !== 0) return; // only left-click
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = event.target.closest?.('a[href]'); const anchor = event.target.closest?.('a[href]');
if (!anchor) return; if (!anchor) return;
// Respect explicit navigation hints // Respect hints/targets
if (anchor.target && anchor.target !== '_self') return; if (anchor.target && anchor.target !== '_self') return;
if (anchor.hasAttribute('download')) return; if (anchor.hasAttribute('download')) return;
if (anchor.getAttribute('rel')?.includes('external')) return; if (anchor.getAttribute('rel')?.includes('external')) return;
@ -24,13 +23,13 @@ export default function AppRouter({ routes }) {
const href = anchor.getAttribute('href'); const href = anchor.getAttribute('href');
if (!href) return; 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; 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; 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; if (/\.[a-z0-9]+($|\?)/i.test(href)) return;
event.preventDefault(); event.preventDefault();
@ -40,7 +39,6 @@ export default function AppRouter({ routes }) {
window.addEventListener('popstate', handlePopState); window.addEventListener('popstate', handlePopState);
document.addEventListener('click', handleLinkClick); document.addEventListener('click', handleLinkClick);
return () => { return () => {
window.removeEventListener('popstate', handlePopState); window.removeEventListener('popstate', handlePopState);
document.removeEventListener('click', handleLinkClick); document.removeEventListener('click', handleLinkClick);
@ -48,13 +46,15 @@ export default function AppRouter({ routes }) {
}, [routes]); }, [routes]);
const View = match?.view ?? NotFound; const View = match?.view ?? NotFound;
return h(View, { params: match?.params ?? [] }); return h(View, { parameters: match?.parameters ?? [] });
} }
function resolveRoute(pathname, routes) { function resolveRoute(pathname, routes) {
for (const route of routes) { for (const route of routes) {
const result = pathname.match(route.pattern); const rx = route.pattern || route.re;
if (result) return { view: route.view, params: result.slice(1) }; if (!rx) continue;
const m = pathname.match(rx);
if (m) return { view: route.view, parameters: m.slice(1) };
} }
return null; return null;
} }

View File

@ -1,77 +1,139 @@
import { h } from 'preact'; import { h } from 'preact';
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef } from 'preact/hooks';
import { TRANSACTIONS_ENDPOINT, TABLE_SIZE } from '../lib/api.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'; 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() { export default function TransactionsTable() {
const tbodyRef = useRef(null); const tableBodyRef = useRef(null);
const countRef = useRef(null); const counterRef = useRef(null);
const abortRef = useRef(null); const abortControllerRef = useRef(null);
const totalCountRef = useRef(0); const totalCountRef = useRef(0);
useEffect(() => { useEffect(() => {
const tbody = tbodyRef.current; const tableBodyElement = tableBodyRef.current;
const counter = countRef.current; const counterElement = counterRef.current;
ensureFixedRowCount(tbody, 4, TABLE_SIZE); ensureFixedRowCount(tableBodyElement, 4, TABLE_SIZE);
abortRef.current?.abort(); abortControllerRef.current?.abort();
abortRef.current = new AbortController(); abortControllerRef.current = new AbortController();
const makeSpan = (className, text, title) => { const url = `${API.TRANSACTIONS_STREAM}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`;
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 = `${TRANSACTIONS_ENDPOINT}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`;
streamNdjson( streamNdjson(
url, url,
(t) => { (rawTransaction) => {
const row = document.createElement('tr'); try {
const transactionData = normalizeTransaction(rawTransaction);
const row = buildTransactionRow(transactionData);
const cellHash = document.createElement('td'); tableBodyElement.insertBefore(row, tableBodyElement.firstChild);
cellHash.appendChild(makeLink(`/transaction/${t.hash ?? ''}`, shortenHex(t.hash ?? ''), t.hash ?? '')); while (tableBodyElement.rows.length > TABLE_SIZE) tableBodyElement.deleteRow(-1);
counterElement.textContent = String(++totalCountRef.current);
const cellFromTo = document.createElement('td'); } catch (error) {
cellFromTo.appendChild(makeSpan('mono', shortenHex(t.sender ?? ''), t.sender ?? '')); // Fail fast per row, but do not break the stream
cellFromTo.appendChild(document.createTextNode(' \u2192 ')); console.error('Failed to render transaction row:', error);
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);
}, },
{ {
signal: abortRef.current.signal, signal: abortControllerRef.current.signal,
onError: withBenignFilter( onError: withBenignFilter(
(e) => console.error('Transaction stream error:', e), (error) => console.error('Transaction stream error:', error),
abortRef.current.signal abortControllerRef.current.signal,
) ),
}, },
).catch((err) => { ).catch((error) => {
if (!abortRef.current.signal.aborted) console.error('Transactions stream error:', err); if (!abortControllerRef.current.signal.aborted) {
console.error('Transactions stream connection error:', error);
}
}); });
return () => abortRef.current?.abort(); return () => abortControllerRef.current?.abort();
}, []); }, []);
return h( return h(
@ -80,8 +142,8 @@ export default function TransactionsTable() {
h( h(
'div', 'div',
{ class: 'card-header' }, { class: 'card-header' },
h('div', null, h('strong', null, 'Transactions '), h('span', { class: 'pill', ref: countRef }, '0')), h('div', null, h('strong', null, 'Transactions '), h('span', { class: 'pill', ref: counterRef }, '0')),
h('div', { style: 'color:var(--muted); font-size:12px;' }, '/api/v1/transactions/stream'), h('div', { style: 'color:var(--muted); font-size:12px;' }),
), ),
h( h(
'div', 'div',
@ -92,17 +154,26 @@ export default function TransactionsTable() {
h( h(
'colgroup', 'colgroup',
null, null,
h('col', { style: 'width:260px' }), h('col', { style: 'width:120px' }), // ID
h('col', null), h('col', null), // Operations
h('col', { style: 'width:120px' }), h('col', { style: 'width:180px' }), // Outputs (count / total)
h('col', { style: 'width:180px' }), h('col', { style: 'width:180px' }), // Gas (execution / storage)
h('col', { style: 'width:180px' }), // Time
), ),
h( h(
'thead', 'thead',
null, 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 }),
), ),
), ),
); );

View File

@ -1,13 +1,29 @@
export const API_PREFIX = '/api/v1'; export const API_PREFIX = '/api/v1';
export const TABLE_SIZE = 10;
const joinUrl = (...parts) => parts.join('/').replace(/\/{2,}/g, '/'); const joinUrl = (...parts) => parts.join('/').replace(/\/{2,}/g, '/');
const encodeId = (id) => encodeURIComponent(String(id)); const encodeId = (id) => encodeURIComponent(String(id));
export const HEALTH_ENDPOINT = joinUrl(API_PREFIX, 'health/stream'); 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');
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)); const BLOCK_DETAIL_BY_ID = (id) => joinUrl(API_PREFIX, 'blocks', encodeId(id));
export const TRANSACTION_DETAIL = (id) => joinUrl(API_PREFIX, 'transactions', 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,
};

View File

@ -2,11 +2,9 @@ export const isBenignStreamError = (error, signal) => {
return false; return false;
}; };
export const withBenignFilter = export const withBenignFilter = (onError, signal) => (error) => {
(onError, signal) => if (!isBenignStreamError(error, signal)) onError?.(error);
(error) => { };
if (!isBenignStreamError(error, signal)) onError?.(error);
};
export async function streamNdjson(url, handleItem, { signal, onError = () => {} } = {}) { export async function streamNdjson(url, handleItem, { signal, onError = () => {} } = {}) {
const response = await fetch(url, { const response = await fetch(url, {

View File

@ -1,50 +1,152 @@
// static/pages/BlockDetailPage.js
import { h, Fragment } from 'preact'; import { h, Fragment } from 'preact';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useMemo, useState } from 'preact/hooks';
import { shortenHex, formatTimestamp } from '../lib/utils.js?dev=1'; import { API, PAGE } from '../lib/api.js?dev=1';
import { BLOCK_DETAIL } from '../lib/api.js?dev=1';
export default function BlockDetail({ params: routeParams }) { const OPERATIONS_PREVIEW_LIMIT = 2;
const blockId = routeParams[0];
// 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 [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(() => { useEffect(() => {
setBlock(null);
setErrorMessage('');
setErrorKind(null);
if (!isValidId) {
setErrorKind('invalid-id');
setErrorMessage('Invalid block id.');
return;
}
let alive = true;
const controller = new AbortController(); const controller = new AbortController();
(async () => { (async () => {
try { try {
const res = await fetch(BLOCK_DETAIL(blockId), { const res = await fetch(API.BLOCK_DETAIL_BY_ID(blockId), {
signal: controller.signal,
cache: 'no-cache', 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}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); const payload = await res.json();
setBlock(data); if (alive) setBlock(payload);
} catch (err) { } catch (e) {
if (!controller.signal.aborted) setError(err.message || 'Request failed'); if (!alive || e?.name === 'AbortError') return;
setErrorKind('network');
setErrorMessage(e?.message ?? 'Failed to load block');
} }
})(); })();
return () => controller.abort(); return () => {
}, [blockId]); alive = false;
controller.abort();
};
}, [blockId, isValidId]);
const header = block?.header ?? {}; const header = block?.header ?? {};
const transactions = block?.transactions ?? []; const transactions = Array.isArray(block?.transactions) ? block.transactions : [];
const slot = block?.slot ?? header.slot;
return h( return h(
'main', 'main',
{ class: 'wrap' }, { class: 'wrap' },
// Top bar
h( h(
'header', 'header',
{ style: 'display:flex; gap:12px; align-items:center; margin:12px 0;' }, { style: 'display:flex; gap:12px; align-items:center; margin:12px 0;' },
h('a', { class: 'linkish', href: '/' }, '← Back'), 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}`), // Error states
!block && !error && h('p', null, 'Loading…'), 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 && block &&
h( h(
Fragment, Fragment,
@ -54,45 +156,68 @@ export default function BlockDetail({ params: routeParams }) {
h( h(
'div', 'div',
{ class: 'card', style: 'margin-top:12px;' }, { class: 'card', style: 'margin-top:12px;' },
h('div', { class: 'card-header' }, h('strong', null, 'Header')),
h( h(
'div', 'div',
{ style: 'padding:12px 14px' }, { class: 'card-header', style: 'display:flex; align-items:center; gap:8px;' },
h('div', null, h('b', null, 'Slot: '), h('span', { class: 'mono' }, header.slot ?? '')), h('strong', null, 'Header'),
h( h(
'div', 'div',
null, { style: 'margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;' },
h('b', null, 'Root: '), 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( h(
'span', 'span',
{ class: 'mono', title: header.block_root ?? '' },
shortenHex(header.block_root ?? ''),
),
),
h(
'div',
null,
h('b', null, 'Parent: '),
h(
'a',
{ {
class: 'linkish mono', class: 'pill mono',
href: `/block/${header.parent_block ?? ''}`, title: header.block_root ?? '',
title: header.parent_block ?? '', 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( h(
'div', 'div',
null, { style: 'display:flex; gap:8px; flex-wrap:wrap; align-items:flex-start;' },
h('b', null, 'Created: '), block?.parent_id
h('span', { class: 'mono' }, formatTimestamp(block.created_at)), ? 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( h(
'div', 'div',
{ class: 'card', style: 'margin-top:16px;' }, { class: 'card', style: 'margin-top:16px;' },
@ -104,75 +229,90 @@ export default function BlockDetail({ params: routeParams }) {
), ),
h( h(
'div', 'div',
{ class: 'table-wrapper' }, { class: 'table-wrapper', style: 'max-width:100%; overflow:auto;' },
h( h(
'table', 'table',
{ class: 'table--transactions' }, {
h( class: 'table--transactions',
'colgroup', // Fill card by default; expand + scroll if content is wider
null, style: 'min-width:100%; width:max-content; table-layout:auto; border-collapse:collapse;',
h('col', { style: 'width:260px' }), },
h('col', null),
h('col', { style: 'width:120px' }),
h('col', { style: 'width:180px' }),
),
h( h(
'thead', 'thead',
null, null,
h( h(
'tr', 'tr',
null, null,
h('th', null, 'Hash'), h('th', { style: 'text-align:left; padding:8px 10px; white-space:nowrap;' }, 'ID'),
h('th', null, 'From → To'), h(
h('th', null, 'Amount'), 'th',
h('th', null, 'Time'), { 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( h(
'tbody', 'tbody',
null, null,
...transactions.map((tx) => ...transactions.map((t) => {
h( 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', 'tr',
null, { key: t?.id ?? `${count}/${total}` },
// ID (left)
h( h(
'td', 'td',
null, { style: 'text-align:left; padding:8px 10px; white-space:nowrap;' },
h( h(
'a', 'a',
{ {
class: 'linkish mono', class: 'linkish mono',
href: `/transaction/${tx.hash}`, href: PAGE.TRANSACTION_DETAIL(t?.id ?? ''),
title: tx.hash, title: String(t?.id ?? ''),
}, },
shortenHex(tx.hash), String(t?.id ?? ''),
), ),
), ),
// Outputs (center)
h( h(
'td', 'td',
null, {
h( class: 'amount',
'span', style: 'text-align:center; padding:8px 10px; white-space:nowrap;',
{ class: 'mono', title: tx.sender ?? '' }, },
shortenHex(tx.sender ?? ''), `${count} / ${Number(total).toLocaleString(undefined, { maximumFractionDigits: 8 })}`,
),
' \u2192 ',
h(
'span',
{ class: 'mono', title: tx.recipient ?? '' },
shortenHex(tx.recipient ?? ''),
),
), ),
// Gas (center)
h( h(
'td', 'td',
{ class: 'amount' }, {
Number(tx.amount ?? 0).toLocaleString(undefined, { class: 'mono',
maximumFractionDigits: 8, 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),
),
);
}),
), ),
), ),
), ),

View File

@ -1,35 +1,315 @@
import { h } from 'preact'; // static/pages/TransactionDetail.js
import { useEffect, useState } from 'preact/hooks'; import { h, Fragment } from 'preact';
import { shortenHex, formatTimestamp } from '../lib/utils.js?dev=1'; import { useEffect, useMemo, useState } from 'preact/hooks';
import { TRANSACTION_DETAIL } from '../lib/api.js?dev=1'; import { API } from '../lib/api.js?dev=1';
export default function TransactionDetail({ params: routeParams }) { // ————— helpers —————
const transactionId = routeParams[0]; 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); // Try to render bytes in a readable way without guessing too hard
const [error, setError] = useState(null); 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(() => { useEffect(() => {
setTx(null);
setErr(null);
if (!isValidId) {
setErr({ kind: 'invalid', msg: 'Invalid transaction id.' });
return;
}
let alive = true;
const controller = new AbortController(); const controller = new AbortController();
(async () => { (async () => {
try { try {
const response = await fetch(TRANSACTION_DETAIL(transactionId), { const res = await fetch(API.TRANSACTION_DETAIL_BY_ID(id), {
signal: controller.signal,
cache: 'no-cache', cache: 'no-cache',
signal: controller.signal,
}); });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (res.status === 404 || res.status === 410) {
const data = await response.json(); if (alive) setErr({ kind: 'not-found', msg: 'Transaction not found.' });
setTransaction(data); return;
} catch (err) {
if (!controller.signal.aborted) {
setError(err.message || 'Request failed');
} }
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(); return () => {
}, [transactionId]); alive = false;
controller.abort();
};
}, [id, isValidId]);
return h( return h(
'main', 'main',
@ -39,80 +319,23 @@ export default function TransactionDetail({ params: routeParams }) {
'header', 'header',
{ style: 'display:flex; gap:12px; align-items:center; margin:12px 0;' }, { style: 'display:flex; gap:12px; align-items:center; margin:12px 0;' },
h('a', { class: 'linkish', href: '/' }, '← Back'), 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}`), // Errors
!transaction && !error && h('p', null, 'Loading…'), err?.kind === 'invalid' && h('p', { style: 'color:#ff8a8a' }, err.msg),
err?.kind === 'not-found' &&
transaction &&
h( h(
'div', SectionCard,
{ class: 'card', style: 'margin-top:12px;' }, { title: 'Transaction not found' },
h('div', { class: 'card-header' }, h('strong', null, 'Overview')), h('p', null, 'We could not find a transaction with that identifier.'),
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),
),
),
),
), ),
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 })),
); );
} }