mirror of
https://github.com/logos-blockchain/logos-blockchain-block-explorer-template.git
synced 2026-01-02 13:13:10 +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
|
# 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
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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 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
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 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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)]
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@ -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, {
|
||||||
|
|||||||
@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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 })),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user