mirror of
https://github.com/logos-blockchain/logos-blockchain-block-explorer-template.git
synced 2026-01-02 05:03:07 +00:00
Rework statics, integrate with node.
This commit is contained in:
parent
1325799edb
commit
68c5e45804
22
README.md
22
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
0
src/api/v1/serializers/__init__.py
Normal file
0
src/api/v1/serializers/__init__.py
Normal file
21
src/api/v1/serializers/blocks.py
Normal file
21
src/api/v1/serializers/blocks.py
Normal 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,
|
||||
)
|
||||
24
src/api/v1/serializers/transactions.py
Normal file
24
src/api/v1/serializers/transactions.py
Normal 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,
|
||||
)
|
||||
@ -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))
|
||||
|
||||
22
src/core/db.py
Normal file
22
src/core/db.py
Normal 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
|
||||
@ -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]:
|
||||
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)
|
||||
|
||||
with self.client.session() as session:
|
||||
results: Result[Block] = session.exec(statement)
|
||||
if (block := results.first()) is not None:
|
||||
return Some(block)
|
||||
|
||||
@ -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)
|
||||
|
||||
slot_cursor = transactions[-1].block.slot + 1
|
||||
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:
|
||||
return Empty()
|
||||
await sleep(timeout_seconds)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)]
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.")
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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 }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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) => {
|
||||
onError: withBenignFilter((err) => {
|
||||
if (!abortRef.current.signal.aborted) {
|
||||
console.error('Health stream error:', err);
|
||||
setStatus(STATUS.OFFLINE);
|
||||
}
|
||||
},
|
||||
abortRef.current.signal
|
||||
),
|
||||
}, abortRef.current.signal),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -2,11 +2,9 @@ export const isBenignStreamError = (error, signal) => {
|
||||
return false;
|
||||
};
|
||||
|
||||
export const withBenignFilter =
|
||||
(onError, signal) =>
|
||||
(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, {
|
||||
|
||||
@ -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 ?? ''),
|
||||
{
|
||||
class: 'pill mono',
|
||||
title: header.block_root ?? '',
|
||||
style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;',
|
||||
},
|
||||
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, 'Parent: '),
|
||||
h(
|
||||
{ style: 'display:flex; gap:8px; flex-wrap:wrap; align-items:flex-start;' },
|
||||
block?.parent_id
|
||||
? h(
|
||||
'a',
|
||||
{
|
||||
class: 'linkish mono',
|
||||
href: `/block/${header.parent_block ?? ''}`,
|
||||
title: header.parent_block ?? '',
|
||||
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;',
|
||||
},
|
||||
shortenHex(header.parent_block ?? ''),
|
||||
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(
|
||||
'div',
|
||||
null,
|
||||
h('b', null, 'Created: '),
|
||||
h('span', { class: 'mono' }, formatTimestamp(block.created_at)),
|
||||
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,76 +229,91 @@ 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()}`,
|
||||
),
|
||||
// Operations (left; no wrap)
|
||||
h(
|
||||
'td',
|
||||
{ style: 'text-align:left; padding:8px 10px; white-space:nowrap;' },
|
||||
opsToPills(operations),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
h('td', { class: 'mono' }, formatTimestamp(tx.timestamp)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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…'),
|
||||
// Errors
|
||||
err?.kind === 'invalid' && h('p', { style: 'color:#ff8a8a' }, err.msg),
|
||||
err?.kind === 'not-found' &&
|
||||
h(
|
||||
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}`),
|
||||
|
||||
transaction &&
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Loading
|
||||
!tx && !err && h('p', null, 'Loading…'),
|
||||
|
||||
// Success
|
||||
tx && h(Fragment, null, h(Summary, { tx }), h(Ledger, { ledger: tx.ledger })),
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user