Rework statics, integrate with node.

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

View File

@ -1,23 +1,25 @@
# Nomos Block Explorer
# TODO
## Assumptions
There are a few assumptions made to facilitate the development of the PoC:
- One block per slot.
- If a range has been backfilled, it has been fully successfully backfilled.
- Backfilling strategy assumes there's, at most, one gap to fill.
## TODO
- Better backfilling
- Ensure transactions
- Upsert on backfill
- Change Sqlite -> Postgres
- Performance improvements on API and DB calls
- Fix assumptions, so we don't rely on them
- DbRepository interfaces
- Setup DB Migrations
- Tests
- Fix ordering for Blocks and Transactions
- Fix assumption of 1 block per slot
- Split the single file static into components
- Log colouring
- Store hashes
- Get transaction by hash
- Get block by hash
# Demo
- Get transaction by id
- Get block by id
- Block viewer
- Transaction viewer
- htm
- Show transactions in table

View File

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

View File

@ -1,9 +1,10 @@
import logging
from typing import AsyncIterable, AsyncIterator, List, Union
from core.models import IdNbeModel
from core.models import NbeModel, NbeSchema
Data = Union[IdNbeModel, List[IdNbeModel]]
T = Union[NbeModel, NbeSchema]
Data = Union[T, List[T]]
Stream = AsyncIterator[Data]

View File

@ -1,24 +1,43 @@
from typing import List
from http.client import NOT_FOUND
from typing import TYPE_CHECKING, AsyncIterator, List, Optional
from fastapi import Query
from starlette.responses import Response
from fastapi import Path, Query
from starlette.responses import JSONResponse, Response
from api.streams import into_ndjson_stream
from api.v1.serializers.blocks import BlockRead
from core.api import NBERequest, NDJsonStreamingResponse
from node.models.blocks import Block
if TYPE_CHECKING:
from core.app import NBE
async def _prefetch_blocks(request: NBERequest, prefetch_limit: int) -> List[Block]:
return (
[]
if prefetch_limit == 0 else
await request.app.state.block_repository.get_latest(limit=prefetch_limit, ascending=True)
)
async def _get_latest(request: NBERequest, limit: int) -> List[BlockRead]:
blocks = await request.app.state.block_repository.get_latest(limit=limit, ascending=True)
return [BlockRead.from_block(block) for block in blocks]
async def _prefetch_blocks(request: NBERequest, prefetch_limit: int) -> List[BlockRead]:
return [] if prefetch_limit == 0 else await _get_latest(request, prefetch_limit)
async def _updates_stream(app: "NBE", latest_block: Optional[Block]) -> AsyncIterator[List[BlockRead]]:
_stream = app.state.block_repository.updates_stream(block_from=latest_block)
async for blocks in _stream:
yield [BlockRead.from_block(block) for block in blocks]
async def stream(request: NBERequest, prefetch_limit: int = Query(0, alias="prefetch-limit", ge=0)) -> Response:
bootstrap_blocks: List[Block] = await _prefetch_blocks(request, prefetch_limit)
highest_slot: int = max((block.slot for block in bootstrap_blocks), default=0)
updates_stream = request.app.state.block_repository.updates_stream(slot_from=highest_slot + 1)
block_stream = into_ndjson_stream(stream=updates_stream, bootstrap_data=bootstrap_blocks)
return NDJsonStreamingResponse(block_stream)
bootstrap_blocks: List[BlockRead] = await _prefetch_blocks(request, prefetch_limit)
latest_block = bootstrap_blocks[-1] if bootstrap_blocks else None
updates_stream: AsyncIterator[List[BlockRead]] = _updates_stream(request.app, latest_block)
ndjson_blocks_stream = into_ndjson_stream(stream=updates_stream, bootstrap_data=bootstrap_blocks)
return NDJsonStreamingResponse(ndjson_blocks_stream)
async def get(request: NBERequest, block_id: int = Path(ge=1)) -> Response:
block = await request.app.state.block_repository.get_by_id(block_id)
return block.map(lambda _block: JSONResponse(BlockRead.from_block(_block).model_dump(mode="json"))).unwrap_or_else(
lambda: Response(status_code=NOT_FOUND)
)

View File

@ -2,10 +2,19 @@ from fastapi import APIRouter
from . import blocks, health, index, transactions
router = APIRouter()
router.add_api_route("/", index.index, methods=["GET", "HEAD"])
router.add_api_route("/health", health.get, methods=["GET", "HEAD"])
router.add_api_route("/health/stream", health.stream, methods=["GET", "HEAD"])
router.add_api_route("/transactions/stream", transactions.stream, methods=["GET"])
router.add_api_route("/blocks/stream", blocks.stream, methods=["GET"])
def create_v1_router() -> APIRouter:
router = APIRouter()
router.add_api_route("/", index.index, methods=["GET", "HEAD"])
router.add_api_route("/health", health.get, methods=["GET", "HEAD"])
router.add_api_route("/health/stream", health.stream, methods=["GET", "HEAD"])
router.add_api_route("/transactions/{transaction_id:int}", transactions.get, methods=["GET"])
router.add_api_route("/transactions/stream", transactions.stream, methods=["GET"])
router.add_api_route("/blocks/{block_id:int}", blocks.get, methods=["GET"])
router.add_api_route("/blocks/stream", blocks.stream, methods=["GET"])
return router

View File

View File

@ -0,0 +1,21 @@
from typing import List, Self
from core.models import NbeSchema
from node.models.blocks import Block, Header
from node.models.transactions import Transaction
class BlockRead(NbeSchema):
id: int
slot: int
header: Header
transactions: List[Transaction]
@classmethod
def from_block(cls, block: Block) -> Self:
return cls(
id=block.id,
slot=block.header.slot,
header=block.header,
transactions=block.transactions,
)

View File

@ -0,0 +1,24 @@
from typing import List, Self
from core.models import NbeSchema
from node.models.transactions import Gas, LedgerTransaction, Transaction
class TransactionRead(NbeSchema):
id: int
block_id: int
operations: List[str]
ledger_transaction: LedgerTransaction
execution_gas_price: Gas
storage_gas_price: Gas
@classmethod
def from_transaction(cls, transaction: Transaction) -> Self:
return cls(
id=transaction.id,
block_id=transaction.block_id,
operations=transaction.operations,
ledger_transaction=transaction.ledger_transaction,
execution_gas_price=transaction.execution_gas_price,
storage_gas_price=transaction.storage_gas_price,
)

View File

@ -1,30 +1,40 @@
from datetime import datetime
from typing import List
from http.client import NOT_FOUND
from typing import TYPE_CHECKING, AsyncIterator, List, Optional
from fastapi import Query
from starlette.responses import Response
from fastapi import Path, Query
from starlette.responses import JSONResponse, Response
from api.streams import into_ndjson_stream
from api.v1.serializers.transactions import TransactionRead
from core.api import NBERequest, NDJsonStreamingResponse
from node.models.transactions import Transaction
from utils.datetime import increment_datetime
if TYPE_CHECKING:
from core.app import NBE
async def _prefetch_transactions(request: NBERequest, prefetch_limit: int) -> List[Transaction]:
return (
[]
if prefetch_limit == 0 else
await request.app.state.transaction_repository.get_latest(limit=prefetch_limit, descending=False)
)
async def _updates_stream(
app: "NBE", latest_transaction: Optional[Transaction]
) -> AsyncIterator[List[TransactionRead]]:
_stream = app.state.transaction_repository.updates_stream(transaction_from=latest_transaction)
async for transactions in _stream:
yield [TransactionRead.from_transaction(transaction) for transaction in transactions]
async def stream(request: NBERequest, prefetch_limit: int = Query(0, alias="prefetch-limit", ge=0)) -> Response:
bootstrap_transactions: List[Transaction] = await _prefetch_transactions(request, prefetch_limit)
highest_timestamp: datetime = max(
(transaction.timestamp for transaction in bootstrap_transactions), default=datetime.min
latest_transactions: List[Transaction] = await request.app.state.transaction_repository.get_latest(
limit=prefetch_limit, ascending=True, preload_relationships=True
)
updates_stream = request.app.state.transaction_repository.updates_stream(
timestamp_from=increment_datetime(highest_timestamp)
)
transaction_stream = into_ndjson_stream(stream=updates_stream, bootstrap_data=bootstrap_transactions)
return NDJsonStreamingResponse(transaction_stream)
latest_transaction = latest_transactions[-1] if latest_transactions else None
latest_transaction_read = [TransactionRead.from_transaction(transaction) for transaction in latest_transactions]
updates_stream: AsyncIterator[List[TransactionRead]] = _updates_stream(request.app, latest_transaction)
ndjson_transactions_stream = into_ndjson_stream(stream=updates_stream, bootstrap_data=latest_transaction_read)
return NDJsonStreamingResponse(ndjson_transactions_stream)
async def get(request: NBERequest, transaction_id: int = Path(ge=1)) -> Response:
transaction = await request.app.state.transaction_repository.get_by_id(transaction_id)
return transaction.map(
lambda _transaction: JSONResponse(TransactionRead.from_transaction(_transaction).model_dump(mode="json"))
).unwrap_or_else(lambda: Response(status_code=NOT_FOUND))

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

@ -0,0 +1,22 @@
from typing import Literal
from sqlalchemy import Float, Integer, String, cast, func
def order_by_json(
sql_expr, path: str, *, into_type: Literal["int", "float", "text"] = "text", descending: bool = False
):
expression = jget(sql_expr, path, into_type=into_type)
return expression.desc() if descending else expression.asc()
def jget(sql_expr, path: str, *, into_type: Literal["int", "float", "text"] = "text"):
expression = func.json_extract(sql_expr, path)
match into_type:
case "int":
expression = cast(expression, Integer)
case "float":
expression = cast(expression, Float)
case "text":
expression = cast(expression, String)
return expression

View File

@ -1,35 +1,16 @@
from asyncio import sleep
from typing import AsyncIterator, List, Literal
from typing import AsyncIterator, List, Optional
from rusty_results import Empty, Option, Some
from sqlalchemy import Float, Integer, Result, String, cast, func
from sqlalchemy import Result, Select
from sqlalchemy.orm import aliased
from sqlmodel import select
from sqlmodel.sql._expression_select_cls import Select
from core.db import jget, order_by_json
from db.clients import DbClient
from node.models.blocks import Block
def order_by_json(
sql_expr, path: str, *, into_type: Literal["int", "float", "text"] = "text", descending: bool = False
):
expression = jget(sql_expr, path, into_type=into_type)
return expression.desc() if descending else expression.asc()
def jget(sql_expr, path: str, *, into_type: Literal["int", "float", "text"] = "text"):
expression = func.json_extract(sql_expr, path)
match into_type:
case "int":
expression = cast(expression, Integer)
case "float":
expression = cast(expression, Float)
case "text":
expression = cast(expression, String)
return expression
def get_latest_statement(limit: int, latest_ascending: bool = True) -> Select:
# Fetch latest
descending = order_by_json(Block.header, "$.slot", into_type="int", descending=True)
@ -63,8 +44,21 @@ class BlockRepository:
results: Result[Block] = session.exec(statement)
return results.all()
async def updates_stream(self, slot_from: int, *, timeout_seconds: int = 1) -> AsyncIterator[List[Block]]:
slot_cursor = slot_from
async def get_by_id(self, block_id: int) -> Option[Block]:
statement = select(Block).where(Block.id == block_id)
with self.client.session() as session:
result: Result[Block] = session.exec(statement)
if (block := result.first()) is not None:
return Some(block)
else:
return Empty()
async def updates_stream(
self, block_from: Optional[Block], *, timeout_seconds: int = 1
) -> AsyncIterator[List[Block]]:
# FIXME
slot_cursor = block_from.slot + 1 if block_from is not None else 0
block_slot_expression = jget(Block.header, "$.slot", into_type="int")
order = order_by_json(Block.header, "$.slot", into_type="int", descending=False)
@ -82,9 +76,10 @@ class BlockRepository:
await sleep(timeout_seconds)
async def get_earliest(self) -> Option[Block]:
with self.client.session() as session:
order = order_by_json(Block.header, "$.slot", into_type="int", descending=False)
statement = select(Block).order_by(order).limit(1)
with self.client.session() as session:
results: Result[Block] = session.exec(statement)
if (block := results.first()) is not None:
return Some(block)

View File

@ -1,14 +1,42 @@
from asyncio import sleep
from datetime import datetime
from typing import AsyncIterator, Iterable, List
from typing import AsyncIterator, Iterable, List, Optional
from rusty_results import Empty, Option, Some
from sqlalchemy import Result
from sqlalchemy import Result, Select
from sqlalchemy.orm import aliased, selectinload
from sqlmodel import select
from core.db import jget, order_by_json
from db.clients import DbClient
from node.models.transactions import Transaction
from utils.datetime import increment_datetime
def get_latest_statement(
limit: int, output_ascending: bool = True, preload_relationships: bool = False, **kwargs
) -> Select:
from node.models.blocks import Block
# Join with Block to order by Block's slot
slot_expr = jget(Block.header, "$.slot", into_type="int").label("slot")
slot_desc = order_by_json(Block.header, "$.slot", into_type="int", descending=True)
inner = (
select(Transaction, slot_expr)
.join(Block, Transaction.block_id == Block.id, isouter=False)
.order_by(slot_desc, Block.id.desc())
.limit(limit)
.subquery()
)
# Reorder
latest = aliased(Transaction, inner)
output_slot_order = inner.c.slot.asc() if output_ascending else inner.c.slot.desc()
output_id_order = (
latest.id.asc() if output_ascending else latest.id.desc()
) # TODO: Double check it's Transaction.id
statement = select(latest).order_by(output_slot_order, output_id_order)
if preload_relationships:
statement = statement.options(selectinload(latest.block))
return statement
class TransactionRepository:
@ -20,49 +48,49 @@ class TransactionRepository:
session.add_all(transaction)
session.commit()
async def get_latest(self, limit: int, descending: bool = True) -> List[Transaction]:
return []
statement = select(Transaction).limit(limit)
if descending:
statement = statement.order_by(Transaction.timestamp.desc())
else:
statement = statement.order_by(Transaction.timestamp.asc())
async def get_latest(self, limit: int, *, ascending: bool = True, **kwargs) -> List[Transaction]:
statement = get_latest_statement(limit, ascending, **kwargs)
with self.client.session() as session:
results: Result[Transaction] = session.exec(statement)
return results.all()
async def updates_stream(self, timestamp_from: datetime) -> AsyncIterator[List[Transaction]]:
while True:
if False:
yield []
await sleep(10)
async def get_by_id(self, transaction_id: int) -> Option[Transaction]:
statement = select(Transaction).where(Transaction.id == transaction_id)
with self.client.session() as session:
result: Result[Transaction] = session.exec(statement)
if (transaction := result.first()) is not None:
return Some(transaction)
else:
return Empty()
async def updates_stream(
self, transaction_from: Optional[Transaction], *, timeout_seconds: int = 1
) -> AsyncIterator[List[Transaction]]:
from node.models.blocks import Block
slot_cursor: int = transaction_from.block.slot + 1 if transaction_from is not None else 0
slot_expression = jget(Block.header, "$.slot", into_type="int")
slot_order = order_by_json(Block.header, "$.slot", into_type="int", descending=False)
_timestamp_from = timestamp_from
while True:
where_clause_slot = slot_expression >= slot_cursor
where_clause_id = Transaction.id > transaction_from.id if transaction_from is not None else True
statement = (
select(Transaction)
.where(Transaction.timestamp >= _timestamp_from)
.order_by(Transaction.timestamp.asc())
.options(selectinload(Transaction.block))
.join(Block, Transaction.block_id == Block.id)
.where(where_clause_slot, where_clause_id)
.order_by(slot_order, Block.id.asc(), Transaction.id.asc())
)
with self.client.session() as session:
transactions: List[Transaction] = session.exec(statement).all()
if len(transactions) > 0:
# POC: Assumes transactions are inserted in order and with a minimum 1 of second difference
_timestamp_from = increment_datetime(transactions[-1].timestamp)
slot_cursor = transactions[-1].block.slot + 1
yield transactions
async def get_earliest(self) -> Option[Transaction]:
return Empty()
with self.client.session() as session:
statement = select(Transaction).order_by(Transaction.slot.asc()).limit(1)
results: Result[Transaction] = session.exec(statement)
if (transaction := results.first()) is not None:
return Some(transaction)
else:
return Empty()
await sleep(timeout_seconds)

View File

@ -1,4 +1,6 @@
from fastapi import APIRouter
from http.client import SERVICE_UNAVAILABLE
from fastapi import APIRouter, HTTPException
from starlette.responses import FileResponse
from . import STATIC_DIR
@ -6,11 +8,13 @@ from . import STATIC_DIR
INDEX_FILE = STATIC_DIR.joinpath("index.html")
def spa() -> FileResponse:
def spa(path: str) -> FileResponse:
if path.startswith(("api", "static")):
raise HTTPException(SERVICE_UNAVAILABLE, detail="Routing is incorrectly configured.")
return FileResponse(INDEX_FILE)
def create_frontend_router() -> APIRouter:
router = APIRouter()
router.get("/", include_in_schema=False)(spa)
router.get("/{path:path}", include_in_schema=False)(spa)
return router

View File

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

View File

@ -20,9 +20,6 @@ class FakeNodeApi(NodeApi):
else:
return Health.from_healthy()
async def get_transactions(self) -> List[Transaction]:
return [Transaction.from_random() for _ in range(get_weighted_amount())]
async def get_blocks(self) -> List[Block]:
return [Block.from_random() for _ in range(1)]

View File

@ -37,12 +37,6 @@ class HttpNodeApi(NodeApi):
else:
return Health.from_unhealthy()
async def get_transactions(self) -> List[Transaction]:
url = urljoin(self.base_url, self.ENDPOINT_TRANSACTIONS)
response = requests.get(url, timeout=60)
json = response.json()
return [Transaction.model_validate(item) for item in json]
async def get_blocks(self, slot_from: int, slot_to: int) -> List[Block]:
query_string = f"slot_from={slot_from}&slot_to={slot_to}"
endpoint = urljoin(self.base_url, self.ENDPOINT_BLOCKS)

View File

@ -149,7 +149,7 @@ async def backfill_blocks(app: "NBE", *, db_hit_interval_seconds: int, batch_siz
blocks = await app.state.node_api.get_blocks(slot_from=slot_from, slot_to=slot_to)
logger.debug(f"Backfilling {len(blocks)} blocks from slot {slot_from} to {slot_to}...")
await app.state.block_repository.create(*blocks)
slot_to = slot_from
slot_to = slot_from - 1
logger.info("Backfilling blocks completed.")

View File

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

View File

@ -1,13 +1,17 @@
import random
from enum import StrEnum
from typing import List
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import JSON, Column
from sqlmodel import Field
from sqlmodel import Field, Relationship
from core.models import NbeSchema, TimestampedModel
from core.sqlmodel import PydanticJsonColumn
from utils.random import random_address
if TYPE_CHECKING:
from node.models.blocks import Block
Value = int
Fr = int
Gas = float
@ -33,7 +37,7 @@ class Note(NbeSchema):
def from_random(cls) -> "Note":
return Note(
value=random.randint(1, 100),
public_key=random_address(),
public_key=random_address().encode("utf-8"),
)
@ -53,19 +57,23 @@ class LedgerTransaction(NbeSchema):
)
class Transaction(NbeSchema): # table=true # It currently lives inside Block
class Transaction(TimestampedModel, table=True):
"""
MantleTx
"""
# __tablename__ = "transactions"
__tablename__ = "transaction"
# TODO: hash
block_id: int = Field(foreign_key="block.id", nullable=False, index=True)
operations: List[str] = Field(alias="ops", default_factory=list, sa_column=Column(JSON, nullable=False))
ledger_transaction: LedgerTransaction = Field(default_factory=dict, sa_column=Column(JSON, nullable=False))
ledger_transaction: LedgerTransaction = Field(
default_factory=dict, sa_column=Column(PydanticJsonColumn(LedgerTransaction), nullable=False)
)
execution_gas_price: Gas
storage_gas_price: Gas
block: Optional["Block"] = Relationship(back_populates="transactions")
def __str__(self) -> str:
return f"Transaction({self.operations})"
@ -74,7 +82,7 @@ class Transaction(NbeSchema): # table=true # It currently lives inside Block
@classmethod
def from_random(cls) -> "Transaction":
n = random.randint(1, 10)
n = random.randint(1, 3)
operations = [random.choice(list(Operation)).value for _ in range(n)]
return Transaction(
operations=operations,

View File

@ -30,6 +30,7 @@ def create_router() -> APIRouter:
router.include_router(create_api_router(), prefix="/api")
if bool(environ.get("DEBUG")):
router.add_route("/debug", debug_router)
router.include_router(create_frontend_router())
router.include_router(create_frontend_router()) # Needs to go last since it contains a catch-all route
return router

View File

@ -1,8 +0,0 @@
from datetime import datetime, timedelta
# Increments a timestamp by the smallest possible unit (1 microsecond), in terms of DB precision.
# This is used to avoid returning the same record again when querying for updates.
# FIXME: Hardcoded
def increment_datetime(timestamp: datetime) -> datetime:
return timestamp + timedelta(microseconds=1)

View File

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

View File

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

View File

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

View File

@ -8,14 +8,13 @@ export default function AppRouter({ routes }) {
const handlePopState = () => setMatch(resolveRoute(location.pathname, routes));
const handleLinkClick = (event) => {
// Only intercept unmodified left-clicks
if (event.defaultPrevented || event.button !== 0) return;
if (event.defaultPrevented || event.button !== 0) return; // only left-click
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = event.target.closest?.('a[href]');
if (!anchor) return;
// Respect explicit navigation hints
// Respect hints/targets
if (anchor.target && anchor.target !== '_self') return;
if (anchor.hasAttribute('download')) return;
if (anchor.getAttribute('rel')?.includes('external')) return;
@ -24,13 +23,13 @@ export default function AppRouter({ routes }) {
const href = anchor.getAttribute('href');
if (!href) return;
// Allow in-page, mailto, and other schemes to pass through
// Allow in-page, mailto, tel, etc.
if (href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return;
// Different origin → let the browser handle it
// Cross-origin goes to browser
if (anchor.origin !== location.origin) return;
// Likely a static asset (e.g., ".css", ".png") → let it pass
// Likely a static asset
if (/\.[a-z0-9]+($|\?)/i.test(href)) return;
event.preventDefault();
@ -40,7 +39,6 @@ export default function AppRouter({ routes }) {
window.addEventListener('popstate', handlePopState);
document.addEventListener('click', handleLinkClick);
return () => {
window.removeEventListener('popstate', handlePopState);
document.removeEventListener('click', handleLinkClick);
@ -48,13 +46,15 @@ export default function AppRouter({ routes }) {
}, [routes]);
const View = match?.view ?? NotFound;
return h(View, { params: match?.params ?? [] });
return h(View, { parameters: match?.parameters ?? [] });
}
function resolveRoute(pathname, routes) {
for (const route of routes) {
const result = pathname.match(route.pattern);
if (result) return { view: route.view, params: result.slice(1) };
const rx = route.pattern || route.re;
if (!rx) continue;
const m = pathname.match(rx);
if (m) return { view: route.view, parameters: m.slice(1) };
}
return null;
}

View File

@ -1,77 +1,139 @@
import { h } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { TRANSACTIONS_ENDPOINT, TABLE_SIZE } from '../lib/api.js?dev=1';
import {streamNdjson, ensureFixedRowCount, shortenHex, formatTimestamp, withBenignFilter} from '../lib/utils.js?dev=1';
import { API, TABLE_SIZE } from '../lib/api.js?dev=1';
import {
streamNdjson,
ensureFixedRowCount,
shortenHex,
formatTimestamp,
withBenignFilter,
} from '../lib/utils.js?dev=1';
const OPERATIONS_PREVIEW_LIMIT = 2;
function createSpan(className, text, title) {
const element = document.createElement('span');
if (className) element.className = className;
if (title) element.title = title;
element.textContent = text;
return element;
}
function createLink(href, text, title) {
const element = document.createElement('a');
element.className = 'linkish mono';
element.href = href;
if (title) element.title = title;
element.textContent = text;
return element;
}
function normalizeTransaction(raw) {
// Defensive parsing and intent-revealing structure
const operations = Array.isArray(raw?.ops) ? raw.ops : Array.isArray(raw?.operations) ? raw.operations : [];
const ledgerOutputs = Array.isArray(raw?.ledger_transaction?.outputs) ? raw.ledger_transaction.outputs : [];
const totalOutputValue = ledgerOutputs.reduce((sum, note) => sum + Number(note?.value ?? 0), 0);
return {
id: raw?.id ?? '',
operations,
createdAt: raw?.created_at ?? raw?.timestamp ?? '',
executionGasPrice: Number(raw?.execution_gas_price ?? 0),
storageGasPrice: Number(raw?.storage_gas_price ?? 0),
numberOfOutputs: ledgerOutputs.length,
totalOutputValue,
};
}
function formatOperationsPreview(operations) {
if (operations.length === 0) return '—';
if (operations.length <= OPERATIONS_PREVIEW_LIMIT) return operations.join(', ');
const head = operations.slice(0, OPERATIONS_PREVIEW_LIMIT).join(', ');
const remainder = operations.length - OPERATIONS_PREVIEW_LIMIT;
return `${head} +${remainder}`;
}
function buildTransactionRow(transactionData) {
const row = document.createElement('tr');
// ID
const cellId = document.createElement('td');
cellId.className = 'mono';
cellId.appendChild(
createLink(`/transactions/${transactionData.id}`, String(transactionData.id), String(transactionData.id)),
);
// Operations
const cellOperations = document.createElement('td');
const operationsPreview = formatOperationsPreview(transactionData.operations);
cellOperations.appendChild(createSpan('', operationsPreview, transactionData.operations.join(', ')));
// Outputs (count / total value)
const cellOutputs = document.createElement('td');
cellOutputs.className = 'amount';
cellOutputs.textContent = `${transactionData.numberOfOutputs} / ${transactionData.totalOutputValue.toLocaleString(undefined, { maximumFractionDigits: 8 })}`;
// Gas (execution / storage)
const cellGas = document.createElement('td');
cellGas.className = 'mono';
cellGas.textContent = `${transactionData.executionGasPrice.toLocaleString()} / ${transactionData.storageGasPrice.toLocaleString()}`;
// Time
const cellTime = document.createElement('td');
const timeSpan = createSpan('mono', formatTimestamp(transactionData.createdAt), String(transactionData.createdAt));
cellTime.appendChild(timeSpan);
row.append(cellId, cellOperations, cellOutputs, cellGas, cellTime);
return row;
}
export default function TransactionsTable() {
const tbodyRef = useRef(null);
const countRef = useRef(null);
const abortRef = useRef(null);
const tableBodyRef = useRef(null);
const counterRef = useRef(null);
const abortControllerRef = useRef(null);
const totalCountRef = useRef(0);
useEffect(() => {
const tbody = tbodyRef.current;
const counter = countRef.current;
ensureFixedRowCount(tbody, 4, TABLE_SIZE);
const tableBodyElement = tableBodyRef.current;
const counterElement = counterRef.current;
ensureFixedRowCount(tableBodyElement, 4, TABLE_SIZE);
abortRef.current?.abort();
abortRef.current = new AbortController();
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
const makeSpan = (className, text, title) => {
const s = document.createElement('span');
if (className) s.className = className;
if (title) s.title = title;
s.textContent = text;
return s;
};
const makeLink = (href, text, title) => {
const a = document.createElement('a');
a.className = 'linkish mono';
a.href = href;
if (title) a.title = title;
a.textContent = text;
return a;
};
const url = `${API.TRANSACTIONS_STREAM}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`;
const url = `${TRANSACTIONS_ENDPOINT}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`;
streamNdjson(
url,
(t) => {
const row = document.createElement('tr');
(rawTransaction) => {
try {
const transactionData = normalizeTransaction(rawTransaction);
const row = buildTransactionRow(transactionData);
const cellHash = document.createElement('td');
cellHash.appendChild(makeLink(`/transaction/${t.hash ?? ''}`, shortenHex(t.hash ?? ''), t.hash ?? ''));
const cellFromTo = document.createElement('td');
cellFromTo.appendChild(makeSpan('mono', shortenHex(t.sender ?? ''), t.sender ?? ''));
cellFromTo.appendChild(document.createTextNode(' \u2192 '));
cellFromTo.appendChild(makeSpan('mono', shortenHex(t.recipient ?? ''), t.recipient ?? ''));
const cellAmount = document.createElement('td');
cellAmount.className = 'amount';
cellAmount.textContent = Number(t.amount ?? 0).toLocaleString(undefined, { maximumFractionDigits: 8 });
const cellTime = document.createElement('td');
const spanTime = makeSpan('mono', formatTimestamp(t.timestamp), t.timestamp ?? '');
cellTime.appendChild(spanTime);
row.append(cellHash, cellFromTo, cellAmount, cellTime);
tbody.insertBefore(row, tbody.firstChild);
while (tbody.rows.length > TABLE_SIZE) tbody.deleteRow(-1);
counter.textContent = String(++totalCountRef.current);
tableBodyElement.insertBefore(row, tableBodyElement.firstChild);
while (tableBodyElement.rows.length > TABLE_SIZE) tableBodyElement.deleteRow(-1);
counterElement.textContent = String(++totalCountRef.current);
} catch (error) {
// Fail fast per row, but do not break the stream
console.error('Failed to render transaction row:', error);
}
},
{
signal: abortRef.current.signal,
signal: abortControllerRef.current.signal,
onError: withBenignFilter(
(e) => console.error('Transaction stream error:', e),
abortRef.current.signal
)
(error) => console.error('Transaction stream error:', error),
abortControllerRef.current.signal,
),
},
).catch((err) => {
if (!abortRef.current.signal.aborted) console.error('Transactions stream error:', err);
).catch((error) => {
if (!abortControllerRef.current.signal.aborted) {
console.error('Transactions stream connection error:', error);
}
});
return () => abortRef.current?.abort();
return () => abortControllerRef.current?.abort();
}, []);
return h(
@ -80,8 +142,8 @@ export default function TransactionsTable() {
h(
'div',
{ class: 'card-header' },
h('div', null, h('strong', null, 'Transactions '), h('span', { class: 'pill', ref: countRef }, '0')),
h('div', { style: 'color:var(--muted); font-size:12px;' }, '/api/v1/transactions/stream'),
h('div', null, h('strong', null, 'Transactions '), h('span', { class: 'pill', ref: counterRef }, '0')),
h('div', { style: 'color:var(--muted); font-size:12px;' }),
),
h(
'div',
@ -92,17 +154,26 @@ export default function TransactionsTable() {
h(
'colgroup',
null,
h('col', { style: 'width:260px' }),
h('col', null),
h('col', { style: 'width:120px' }),
h('col', { style: 'width:180px' }),
h('col', { style: 'width:120px' }), // ID
h('col', null), // Operations
h('col', { style: 'width:180px' }), // Outputs (count / total)
h('col', { style: 'width:180px' }), // Gas (execution / storage)
h('col', { style: 'width:180px' }), // Time
),
h(
'thead',
null,
h('tr', null, h('th', null, 'Hash'), h('th', null, 'From → To'), h('th', null, 'Amount'), h('th', null, 'Time')),
h(
'tr',
null,
h('th', null, 'ID'),
h('th', null, 'Operations'),
h('th', null, 'Outputs (count / total)'),
h('th', null, 'Gas (execution / storage)'),
h('th', null, 'Time'),
),
h('tbody', { ref: tbodyRef }),
),
h('tbody', { ref: tableBodyRef }),
),
),
);

View File

@ -1,13 +1,29 @@
export const API_PREFIX = '/api/v1';
export const TABLE_SIZE = 10;
const joinUrl = (...parts) => parts.join('/').replace(/\/{2,}/g, '/');
const encodeId = (id) => encodeURIComponent(String(id));
export const HEALTH_ENDPOINT = joinUrl(API_PREFIX, 'health/stream');
export const BLOCKS_ENDPOINT = joinUrl(API_PREFIX, 'blocks/stream');
export const TRANSACTIONS_ENDPOINT = joinUrl(API_PREFIX, 'transactions/stream');
const HEALTH_ENDPOINT = joinUrl(API_PREFIX, 'health/stream');
export const TABLE_SIZE = 10;
const TRANSACTION_DETAIL_BY_ID = (id) => joinUrl(API_PREFIX, 'transactions', encodeId(id));
const TRANSACTIONS_STREAM = joinUrl(API_PREFIX, 'transactions/stream');
export const BLOCK_DETAIL = (id) => joinUrl(API_PREFIX, 'blocks', encodeId(id));
export const TRANSACTION_DETAIL = (id) => joinUrl(API_PREFIX, 'transactions', encodeId(id));
const BLOCK_DETAIL_BY_ID = (id) => joinUrl(API_PREFIX, 'blocks', encodeId(id));
const BLOCKS_STREAM = joinUrl(API_PREFIX, 'blocks/stream');
export const API = {
HEALTH_ENDPOINT,
TRANSACTION_DETAIL_BY_ID,
TRANSACTIONS_STREAM,
BLOCK_DETAIL_BY_ID,
BLOCKS_STREAM,
};
const BLOCK_DETAIL = (id) => joinUrl('/blocks', encodeId(id));
const TRANSACTION_DETAIL = (id) => joinUrl('/transactions', encodeId(id));
export const PAGE = {
BLOCK_DETAIL,
TRANSACTION_DETAIL,
};

View File

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

View File

@ -1,50 +1,152 @@
// static/pages/BlockDetailPage.js
import { h, Fragment } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { shortenHex, formatTimestamp } from '../lib/utils.js?dev=1';
import { BLOCK_DETAIL } from '../lib/api.js?dev=1';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { API, PAGE } from '../lib/api.js?dev=1';
export default function BlockDetail({ params: routeParams }) {
const blockId = routeParams[0];
const OPERATIONS_PREVIEW_LIMIT = 2;
// Helpers
function opsToPills(ops, limit = OPERATIONS_PREVIEW_LIMIT) {
const arr = Array.isArray(ops) ? ops : [];
if (!arr.length) return h('span', { style: 'color:var(--muted); white-space:nowrap;' }, '—');
const shown = arr.slice(0, limit);
const extra = arr.length - shown.length;
return h(
'div',
{ style: 'display:flex; gap:6px; flex-wrap:nowrap; align-items:center; white-space:nowrap;' },
...shown.map((op, i) =>
h('span', { key: `${op}-${i}`, class: 'pill', title: op, style: 'flex:0 0 auto;' }, op),
),
extra > 0 && h('span', { class: 'pill', title: `${extra} more`, style: 'flex:0 0 auto;' }, `+${extra}`),
);
}
function computeOutputsSummary(ledgerTransaction) {
const outputs = Array.isArray(ledgerTransaction?.outputs) ? ledgerTransaction.outputs : [];
const count = outputs.length;
const total = outputs.reduce((sum, o) => sum + Number(o?.value ?? 0), 0);
return { count, total };
}
function CopyPill({ text }) {
const onCopy = async (e) => {
e.preventDefault();
try {
await navigator.clipboard.writeText(String(text ?? ''));
} catch {}
};
return h(
'a',
{
class: 'pill linkish mono',
style: 'cursor:pointer; user-select:none;',
href: '#',
onClick: onCopy,
onKeyDown: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onCopy(e);
}
},
tabIndex: 0,
role: 'button',
},
'Copy',
);
}
export default function BlockDetailPage({ parameters }) {
const blockIdParameter = parameters[0];
const blockId = Number.parseInt(String(blockIdParameter), 10);
const isValidId = Number.isInteger(blockId) && blockId >= 0;
const [block, setBlock] = useState(null);
const [error, setError] = useState(null);
const [errorMessage, setErrorMessage] = useState('');
const [errorKind, setErrorKind] = useState(null); // 'invalid-id' | 'not-found' | 'network' | null
const pageTitle = useMemo(() => `Block ${String(blockIdParameter)}`, [blockIdParameter]);
useEffect(() => {
document.title = pageTitle;
}, [pageTitle]);
useEffect(() => {
setBlock(null);
setErrorMessage('');
setErrorKind(null);
if (!isValidId) {
setErrorKind('invalid-id');
setErrorMessage('Invalid block id.');
return;
}
let alive = true;
const controller = new AbortController();
(async () => {
try {
const res = await fetch(BLOCK_DETAIL(blockId), {
signal: controller.signal,
const res = await fetch(API.BLOCK_DETAIL_BY_ID(blockId), {
cache: 'no-cache',
signal: controller.signal,
});
if (res.status === 404 || res.status === 410) {
if (alive) {
setErrorKind('not-found');
setErrorMessage('Block not found.');
}
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setBlock(data);
} catch (err) {
if (!controller.signal.aborted) setError(err.message || 'Request failed');
const payload = await res.json();
if (alive) setBlock(payload);
} catch (e) {
if (!alive || e?.name === 'AbortError') return;
setErrorKind('network');
setErrorMessage(e?.message ?? 'Failed to load block');
}
})();
return () => controller.abort();
}, [blockId]);
return () => {
alive = false;
controller.abort();
};
}, [blockId, isValidId]);
const header = block?.header ?? {};
const transactions = block?.transactions ?? [];
const transactions = Array.isArray(block?.transactions) ? block.transactions : [];
const slot = block?.slot ?? header.slot;
return h(
'main',
{ class: 'wrap' },
// Top bar
h(
'header',
{ style: 'display:flex; gap:12px; align-items:center; margin:12px 0;' },
h('a', { class: 'linkish', href: '/' }, '← Back'),
h('h1', { style: 'margin:0' }, `Block ${shortenHex(blockId, 12, 12)}`),
h('h1', { style: 'margin:0' }, pageTitle),
),
error && h('p', { style: 'color:#ff8a8a' }, `Error: ${error}`),
!block && !error && h('p', null, 'Loading…'),
// Error states
errorKind === 'invalid-id' && h('p', { style: 'color:#ff8a8a' }, errorMessage),
errorKind === 'not-found' &&
h(
'div',
{ class: 'card', style: 'margin-top:12px;' },
h('div', { class: 'card-header' }, h('strong', null, 'Block not found')),
h(
'div',
{ style: 'padding:12px 14px' },
h('p', null, 'We could not find a block with that identifier.'),
),
),
errorKind === 'network' && h('p', { style: 'color:#ff8a8a' }, `Error: ${errorMessage}`),
// Loading
!block && !errorKind && h('p', null, 'Loading…'),
// Success
block &&
h(
Fragment,
@ -54,45 +156,68 @@ export default function BlockDetail({ params: routeParams }) {
h(
'div',
{ class: 'card', style: 'margin-top:12px;' },
h('div', { class: 'card-header' }, h('strong', null, 'Header')),
h(
'div',
{ style: 'padding:12px 14px' },
h('div', null, h('b', null, 'Slot: '), h('span', { class: 'mono' }, header.slot ?? '')),
{ class: 'card-header', style: 'display:flex; align-items:center; gap:8px;' },
h('strong', null, 'Header'),
h(
'div',
null,
h('b', null, 'Root: '),
{ style: 'margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;' },
slot != null && h('span', { class: 'pill', title: 'Slot' }, `Slot ${String(slot)}`),
),
),
h(
'div',
{ style: 'padding:12px 14px; display:grid; grid-template-columns: 120px 1fr; gap:8px 12px;' },
// Root (pill + copy)
h('div', null, h('b', null, 'Root:')),
h(
'div',
{ style: 'display:flex; gap:8px; flex-wrap:wrap; align-items:flex-start;' },
h(
'span',
{ class: 'mono', title: header.block_root ?? '' },
shortenHex(header.block_root ?? ''),
{
class: 'pill mono',
title: header.block_root ?? '',
style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;',
},
String(header.block_root ?? ''),
),
h(CopyPill, { text: header.block_root }),
),
// Parent (pill + copy)
h('div', null, h('b', null, 'Parent:')),
h(
'div',
null,
h('b', null, 'Parent: '),
h(
{ style: 'display:flex; gap:8px; flex-wrap:wrap; align-items:flex-start;' },
block?.parent_id
? h(
'a',
{
class: 'linkish mono',
href: `/block/${header.parent_block ?? ''}`,
title: header.parent_block ?? '',
class: 'pill mono linkish',
href: PAGE.BLOCK_DETAIL(block.parent_id),
title: String(block.parent_id),
style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;',
},
shortenHex(header.parent_block ?? ''),
String(block.parent_id),
)
: h(
'span',
{
class: 'pill mono',
title: header.parent_block ?? '',
style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;',
},
String(header.parent_block ?? ''),
),
),
h(
'div',
null,
h('b', null, 'Created: '),
h('span', { class: 'mono' }, formatTimestamp(block.created_at)),
h(CopyPill, { text: block?.parent_id ?? header.parent_block }),
),
),
),
// Transactions card
// Transactions card — rows fill width; Outputs & Gas centered
h(
'div',
{ class: 'card', style: 'margin-top:16px;' },
@ -104,76 +229,91 @@ export default function BlockDetail({ params: routeParams }) {
),
h(
'div',
{ class: 'table-wrapper' },
{ class: 'table-wrapper', style: 'max-width:100%; overflow:auto;' },
h(
'table',
{ class: 'table--transactions' },
h(
'colgroup',
null,
h('col', { style: 'width:260px' }),
h('col', null),
h('col', { style: 'width:120px' }),
h('col', { style: 'width:180px' }),
),
{
class: 'table--transactions',
// Fill card by default; expand + scroll if content is wider
style: 'min-width:100%; width:max-content; table-layout:auto; border-collapse:collapse;',
},
h(
'thead',
null,
h(
'tr',
null,
h('th', null, 'Hash'),
h('th', null, 'From → To'),
h('th', null, 'Amount'),
h('th', null, 'Time'),
h('th', { style: 'text-align:left; padding:8px 10px; white-space:nowrap;' }, 'ID'),
h(
'th',
{ style: 'text-align:center; padding:8px 10px; white-space:nowrap;' },
'Outputs (count / total)',
),
h(
'th',
{ style: 'text-align:center; padding:8px 10px; white-space:nowrap;' },
'Gas (execution / storage)',
),
h(
'th',
{ style: 'text-align:left; padding:8px 10px; white-space:nowrap;' },
'Operations',
),
),
),
h(
'tbody',
null,
...transactions.map((tx) =>
h(
...transactions.map((t) => {
const operations = Array.isArray(t?.operations) ? t.operations : [];
const { count, total } = computeOutputsSummary(t?.ledger_transaction);
const executionGas = Number(t?.execution_gas_price ?? 0);
const storageGas = Number(t?.storage_gas_price ?? 0);
return h(
'tr',
null,
{ key: t?.id ?? `${count}/${total}` },
// ID (left)
h(
'td',
null,
{ style: 'text-align:left; padding:8px 10px; white-space:nowrap;' },
h(
'a',
{
class: 'linkish mono',
href: `/transaction/${tx.hash}`,
title: tx.hash,
href: PAGE.TRANSACTION_DETAIL(t?.id ?? ''),
title: String(t?.id ?? ''),
},
shortenHex(tx.hash),
String(t?.id ?? ''),
),
),
// Outputs (center)
h(
'td',
null,
h(
'span',
{ class: 'mono', title: tx.sender ?? '' },
shortenHex(tx.sender ?? ''),
),
' \u2192 ',
h(
'span',
{ class: 'mono', title: tx.recipient ?? '' },
shortenHex(tx.recipient ?? ''),
),
{
class: 'amount',
style: 'text-align:center; padding:8px 10px; white-space:nowrap;',
},
`${count} / ${Number(total).toLocaleString(undefined, { maximumFractionDigits: 8 })}`,
),
// Gas (center)
h(
'td',
{ class: 'amount' },
Number(tx.amount ?? 0).toLocaleString(undefined, {
maximumFractionDigits: 8,
{
class: 'mono',
style: 'text-align:center; padding:8px 10px; white-space:nowrap;',
},
`${executionGas.toLocaleString()} / ${storageGas.toLocaleString()}`,
),
// Operations (left; no wrap)
h(
'td',
{ style: 'text-align:left; padding:8px 10px; white-space:nowrap;' },
opsToPills(operations),
),
);
}),
),
h('td', { class: 'mono' }, formatTimestamp(tx.timestamp)),
),
),
),
),
),
),

View File

@ -1,35 +1,315 @@
import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { shortenHex, formatTimestamp } from '../lib/utils.js?dev=1';
import { TRANSACTION_DETAIL } from '../lib/api.js?dev=1';
// static/pages/TransactionDetail.js
import { h, Fragment } from 'preact';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { API } from '../lib/api.js?dev=1';
export default function TransactionDetail({ params: routeParams }) {
const transactionId = routeParams[0];
// ————— helpers —————
const isNumber = (v) => typeof v === 'number' && !Number.isNaN(v);
const toLocaleNum = (n, opts = {}) => Number(n ?? 0).toLocaleString(undefined, { maximumFractionDigits: 8, ...opts });
const [transaction, setTransaction] = useState(null);
const [error, setError] = useState(null);
// Try to render bytes in a readable way without guessing too hard
function renderBytes(value) {
if (typeof value === 'string') return value; // hex/base64/etc.
if (Array.isArray(value) && value.every((x) => Number.isInteger(x) && x >= 0 && x <= 255)) {
return '0x' + value.map((b) => b.toString(16).padStart(2, '0')).join('');
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
// ————— normalizer (robust to partial data) —————
function normalizeTransaction(raw) {
const ops = Array.isArray(raw?.operations) ? raw.operations : [];
const lt = raw?.ledger_transaction ?? {};
const inputs = Array.isArray(lt?.inputs) ? lt.inputs : [];
const outputs = Array.isArray(lt?.outputs) ? lt.outputs : [];
const totalOutputValue = outputs.reduce((sum, note) => sum + Number(note?.value ?? 0), 0);
return {
id: raw?.id ?? '',
blockId: raw?.block_id ?? null,
operations: ops.map(String),
executionGasPrice: isNumber(raw?.execution_gas_price)
? raw.execution_gas_price
: Number(raw?.execution_gas_price ?? 0),
storageGasPrice: isNumber(raw?.storage_gas_price) ? raw.storage_gas_price : Number(raw?.storage_gas_price ?? 0),
ledger: { inputs, outputs, totalOutputValue },
};
}
// ————— UI bits —————
function SectionCard({ title, children, style }) {
return h(
'div',
{ class: 'card', style: `margin-top:12px; ${style ?? ''}` },
h('div', { class: 'card-header' }, h('strong', null, title)),
h('div', { style: 'padding:12px 14px' }, children),
);
}
function Summary({ tx }) {
return h(
SectionCard,
{ title: 'Summary' },
h(
'div',
{ style: 'display:grid; gap:8px;' },
// (ID removed)
tx.blockId != null &&
h(
'div',
null,
h('b', null, 'Block: '),
h(
'a',
{ class: 'linkish mono', href: API.BLOCK_DETAIL_BY_ID(tx.blockId), title: String(tx.blockId) },
String(tx.blockId),
),
),
h(
'div',
null,
h('b', null, 'Execution Gas: '),
h('span', { class: 'mono' }, toLocaleNum(tx.executionGasPrice)),
),
h(
'div',
null,
h('b', null, 'Storage Gas: '),
h('span', { class: 'mono' }, toLocaleNum(tx.storageGasPrice)),
),
h(
'div',
null,
h('b', null, 'Operations: '),
tx.operations?.length
? h(
'span',
{ style: 'display:inline-flex; gap:6px; flex-wrap:wrap; vertical-align:middle;' },
...tx.operations.map((op, i) => h('span', { key: i, class: 'pill', title: op }, op)),
)
: h('span', { style: 'color:var(--muted)' }, '—'),
),
),
);
}
function InputsTable({ inputs }) {
if (!inputs?.length) {
return h('div', { style: 'color:var(--muted)' }, '—');
}
return h(
'div',
{ class: 'table-wrapper', style: 'margin-top:6px;' },
h(
'table',
{ class: 'table--transactions' },
h(
'colgroup',
null,
h('col', { style: 'width:80px' }), // #
h('col', null), // Value (fills)
),
h('thead', null, h('tr', null, h('th', { style: 'text-align:center;' }, '#'), h('th', null, 'Value'))),
h(
'tbody',
null,
...inputs.map((fr, i) =>
h(
'tr',
{ key: i },
h('td', { style: 'text-align:center;' }, String(i)),
h(
'td',
null,
h(
'span',
{ class: 'mono', style: 'overflow-wrap:anywhere; word-break:break-word;' },
String(fr),
),
),
),
),
),
),
);
}
function OutputsTable({ outputs }) {
if (!outputs?.length) {
return h('div', { style: 'color:var(--muted)' }, '—');
}
return h(
'div',
{ class: 'table-wrapper', style: 'margin-top:6px;' },
h(
'table',
{ class: 'table--transactions' },
h(
'colgroup',
null,
h('col', { style: 'width:80px' }), // # (compact, centered)
h('col', null), // Public Key (fills)
h('col', { style: 'width:180px' }), // Value (compact, right)
),
h(
'thead',
null,
h(
'tr',
null,
h('th', { style: 'text-align:center;' }, '#'),
h('th', null, 'Public Key'), // ← back to Public Key second
h('th', { style: 'text-align:right;' }, 'Value'), // ← Value last
),
),
h(
'tbody',
null,
...outputs.map((note, idx) =>
h(
'tr',
{ key: idx },
// # (index)
h('td', { style: 'text-align:center;' }, String(idx)),
// Public Key (fills, wraps)
h(
'td',
null,
h(
'span',
{
class: 'mono',
style: 'display:inline-block; overflow-wrap:anywhere; word-break:break-word;',
title: renderBytes(note?.public_key),
},
renderBytes(note?.public_key),
),
),
// Value (right-aligned)
h('td', { class: 'amount', style: 'text-align:right;' }, toLocaleNum(note?.value)),
),
),
),
),
);
}
function Ledger({ ledger }) {
const { inputs, outputs, totalOutputValue } = ledger;
// Sum inputs as integers (Fr is declared as int in your schema)
const totalInputValue = inputs.reduce((sum, v) => sum + Number(v ?? 0), 0);
return h(
SectionCard,
{ title: 'Ledger Transaction' },
h(
'div',
{ style: 'display:grid; gap:16px;' },
// Inputs (with Total on the right)
h(
'div',
null,
h(
'div',
{ style: 'display:flex; align-items:center; gap:8px;' },
h('b', null, 'Inputs'),
h('span', { class: 'pill' }, String(inputs.length)),
h(
'span',
{ class: 'amount', style: 'margin-left:auto;' },
`Total: ${toLocaleNum(totalInputValue)}`,
),
),
h(InputsTable, { inputs }),
),
// Outputs (unchanged header total)
h(
'div',
null,
h(
'div',
{ style: 'display:flex; align-items:center; gap:8px;' },
h('b', null, 'Outputs'),
h('span', { class: 'pill' }, String(outputs.length)),
h(
'span',
{ class: 'amount', style: 'margin-left:auto;' },
`Total: ${toLocaleNum(totalOutputValue)}`,
),
),
h(OutputsTable, { outputs }),
),
),
);
}
// ————— page —————
export default function TransactionDetail({ parameters }) {
const idParam = parameters?.[0];
const id = Number.parseInt(String(idParam), 10);
const isValidId = Number.isInteger(id) && id >= 0;
const [tx, setTx] = useState(null);
const [err, setErr] = useState(null); // { kind: 'invalid'|'not-found'|'network', msg: string }
const pageTitle = useMemo(() => `Transaction ${String(idParam)}`, [idParam]);
useEffect(() => {
document.title = pageTitle;
}, [pageTitle]);
useEffect(() => {
setTx(null);
setErr(null);
if (!isValidId) {
setErr({ kind: 'invalid', msg: 'Invalid transaction id.' });
return;
}
let alive = true;
const controller = new AbortController();
(async () => {
try {
const response = await fetch(TRANSACTION_DETAIL(transactionId), {
signal: controller.signal,
const res = await fetch(API.TRANSACTION_DETAIL_BY_ID(id), {
cache: 'no-cache',
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
setTransaction(data);
} catch (err) {
if (!controller.signal.aborted) {
setError(err.message || 'Request failed');
if (res.status === 404 || res.status === 410) {
if (alive) setErr({ kind: 'not-found', msg: 'Transaction not found.' });
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
if (!alive) return;
setTx(normalizeTransaction(payload));
} catch (e) {
if (!alive || e?.name === 'AbortError') return;
setErr({ kind: 'network', msg: e?.message ?? 'Failed to load transaction' });
}
})();
return () => controller.abort();
}, [transactionId]);
return () => {
alive = false;
controller.abort();
};
}, [id, isValidId]);
return h(
'main',
@ -39,80 +319,23 @@ export default function TransactionDetail({ params: routeParams }) {
'header',
{ style: 'display:flex; gap:12px; align-items:center; margin:12px 0;' },
h('a', { class: 'linkish', href: '/' }, '← Back'),
h('h1', { style: 'margin:0' }, `Transaction ${shortenHex(transactionId, 12, 12)}`),
h('h1', { style: 'margin:0' }, pageTitle),
),
error && h('p', { style: 'color:#ff8a8a' }, `Error: ${error}`),
!transaction && !error && h('p', null, 'Loading…'),
// Errors
err?.kind === 'invalid' && h('p', { style: 'color:#ff8a8a' }, err.msg),
err?.kind === 'not-found' &&
h(
SectionCard,
{ title: 'Transaction not found' },
h('p', null, 'We could not find a transaction with that identifier.'),
),
err?.kind === 'network' && h('p', { style: 'color:#ff8a8a' }, `Error: ${err.msg}`),
transaction &&
h(
'div',
{ class: 'card', style: 'margin-top:12px;' },
h('div', { class: 'card-header' }, h('strong', null, 'Overview')),
h(
'div',
{ style: 'padding:12px 14px' },
h(
'div',
null,
h('b', null, 'Hash: '),
h('span', { class: 'mono', title: transaction.hash }, shortenHex(transaction.hash)),
),
h(
'div',
null,
h('b', null, 'From: '),
h(
'span',
{ class: 'mono', title: transaction.sender ?? '' },
shortenHex(transaction.sender ?? ''),
),
),
h(
'div',
null,
h('b', null, 'To: '),
h(
'span',
{ class: 'mono', title: transaction.recipient ?? '' },
shortenHex(transaction.recipient ?? ''),
),
),
h(
'div',
null,
h('b', null, 'Amount: '),
h(
'span',
{ class: 'amount' },
Number(transaction.amount ?? 0).toLocaleString(undefined, {
maximumFractionDigits: 8,
}),
),
),
h(
'div',
null,
h('b', null, 'Time: '),
h('span', { class: 'mono' }, formatTimestamp(transaction.timestamp)),
),
transaction.block_root &&
h(
'div',
null,
h('b', null, 'Block: '),
h(
'a',
{
class: 'linkish mono',
href: `/block/${transaction.block_root}`,
title: transaction.block_root,
},
shortenHex(transaction.block_root),
),
),
),
),
// Loading
!tx && !err && h('p', null, 'Loading…'),
// Success
tx && h(Fragment, null, h(Summary, { tx }), h(Ledger, { ledger: tx.ledger })),
);
}