mirror of
https://github.com/logos-blockchain/logos-blockchain-block-explorer-template.git
synced 2026-01-02 05:03:07 +00:00
Use hash to handle blocks and transactions.
This commit is contained in:
parent
a997139155
commit
c3c357d09a
@ -1,13 +1,14 @@
|
|||||||
from http.client import NOT_FOUND
|
from http.client import NOT_FOUND
|
||||||
from typing import TYPE_CHECKING, AsyncIterator, List
|
from typing import TYPE_CHECKING, AsyncIterator, List
|
||||||
|
|
||||||
from fastapi import Path, Query
|
from fastapi import Query
|
||||||
from rusty_results import Empty, Option, Some
|
from rusty_results import Empty, Option, Some
|
||||||
from starlette.responses import JSONResponse, 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 api.v1.serializers.blocks import BlockRead
|
||||||
from core.api import NBERequest, NDJsonStreamingResponse
|
from core.api import NBERequest, NDJsonStreamingResponse
|
||||||
|
from core.types import dehexify
|
||||||
from models.block import Block
|
from models.block import Block
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -30,8 +31,11 @@ async def stream(request: NBERequest, prefetch_limit: int = Query(0, alias="pref
|
|||||||
return NDJsonStreamingResponse(ndjson_blocks_stream)
|
return NDJsonStreamingResponse(ndjson_blocks_stream)
|
||||||
|
|
||||||
|
|
||||||
async def get(request: NBERequest, block_id: int = Path(ge=1)) -> Response:
|
async def get(request: NBERequest, block_hash: str) -> Response:
|
||||||
block = await request.app.state.block_repository.get_by_id(block_id)
|
if not block_hash:
|
||||||
|
return Response(status_code=NOT_FOUND)
|
||||||
|
block_hash = dehexify(block_hash)
|
||||||
|
block = await request.app.state.block_repository.get_by_hash(block_hash)
|
||||||
return block.map(lambda _block: JSONResponse(BlockRead.from_block(_block).model_dump(mode="json"))).unwrap_or_else(
|
return block.map(lambda _block: JSONResponse(BlockRead.from_block(_block).model_dump(mode="json"))).unwrap_or_else(
|
||||||
lambda: Response(status_code=NOT_FOUND)
|
lambda: Response(status_code=NOT_FOUND)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,17 +4,20 @@ from . import blocks, health, index, transactions
|
|||||||
|
|
||||||
|
|
||||||
def create_v1_router() -> APIRouter:
|
def create_v1_router() -> APIRouter:
|
||||||
|
"""
|
||||||
|
Route order must be preserved.
|
||||||
|
"""
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
router.add_api_route("/", index.index, methods=["GET", "HEAD"])
|
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("/health/stream", health.stream, methods=["GET", "HEAD"])
|
||||||
|
router.add_api_route("/health", health.get, 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("/transactions/stream", transactions.stream, methods=["GET"])
|
||||||
|
router.add_api_route("/transactions/{transaction_hash:str}", transactions.get, methods=["GET"])
|
||||||
|
|
||||||
router.add_api_route("/blocks/{block_id:int}", blocks.get, methods=["GET"])
|
|
||||||
router.add_api_route("/blocks/stream", blocks.stream, methods=["GET"])
|
router.add_api_route("/blocks/stream", blocks.stream, methods=["GET"])
|
||||||
|
router.add_api_route("/blocks/{block_hash:str}", blocks.get, methods=["GET"])
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from models.transactions.transaction import Transaction
|
|||||||
|
|
||||||
class TransactionRead(NbeSchema):
|
class TransactionRead(NbeSchema):
|
||||||
id: int
|
id: int
|
||||||
block_id: int
|
block_hash: HexBytes
|
||||||
hash: HexBytes
|
hash: HexBytes
|
||||||
operations: List[Operation]
|
operations: List[Operation]
|
||||||
inputs: List[HexBytes]
|
inputs: List[HexBytes]
|
||||||
@ -23,7 +23,7 @@ class TransactionRead(NbeSchema):
|
|||||||
def from_transaction(cls, transaction: Transaction) -> Self:
|
def from_transaction(cls, transaction: Transaction) -> Self:
|
||||||
return cls(
|
return cls(
|
||||||
id=transaction.id,
|
id=transaction.id,
|
||||||
block_id=transaction.block.id,
|
block_hash=transaction.block.hash,
|
||||||
hash=transaction.hash,
|
hash=transaction.hash,
|
||||||
operations=transaction.operations,
|
operations=transaction.operations,
|
||||||
inputs=transaction.inputs,
|
inputs=transaction.inputs,
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
from http.client import NOT_FOUND
|
from http.client import NOT_FOUND
|
||||||
from typing import TYPE_CHECKING, AsyncIterator, List
|
from typing import TYPE_CHECKING, AsyncIterator, List
|
||||||
|
|
||||||
from fastapi import Path, Query
|
from fastapi import Query
|
||||||
from rusty_results import Empty, Option, Some
|
from rusty_results import Empty, Option, Some
|
||||||
from starlette.responses import JSONResponse, 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 api.v1.serializers.transactions import TransactionRead
|
||||||
from core.api import NBERequest, NDJsonStreamingResponse
|
from core.api import NBERequest, NDJsonStreamingResponse
|
||||||
|
from core.types import dehexify
|
||||||
from models.transactions.transaction import Transaction
|
from models.transactions.transaction import Transaction
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -36,8 +37,11 @@ async def stream(request: NBERequest, prefetch_limit: int = Query(0, alias="pref
|
|||||||
return NDJsonStreamingResponse(ndjson_transactions_stream)
|
return NDJsonStreamingResponse(ndjson_transactions_stream)
|
||||||
|
|
||||||
|
|
||||||
async def get(request: NBERequest, transaction_id: int = Path(ge=1)) -> Response:
|
async def get(request: NBERequest, transaction_hash: str) -> Response:
|
||||||
transaction = await request.app.state.transaction_repository.get_by_id(transaction_id)
|
if not transaction_hash:
|
||||||
|
return Response(status_code=NOT_FOUND)
|
||||||
|
transaction_hash = dehexify(transaction_hash)
|
||||||
|
transaction = await request.app.state.transaction_repository.get_by_hash(transaction_hash)
|
||||||
return transaction.map(
|
return transaction.map(
|
||||||
lambda _transaction: JSONResponse(TransactionRead.from_transaction(_transaction).model_dump(mode="json"))
|
lambda _transaction: JSONResponse(TransactionRead.from_transaction(_transaction).model_dump(mode="json"))
|
||||||
).unwrap_or_else(lambda: Response(status_code=NOT_FOUND))
|
).unwrap_or_else(lambda: Response(status_code=NOT_FOUND))
|
||||||
|
|||||||
@ -7,6 +7,10 @@ def hexify(data: bytes) -> str:
|
|||||||
return data.hex()
|
return data.hex()
|
||||||
|
|
||||||
|
|
||||||
|
def dehexify(data: str) -> bytes:
|
||||||
|
return bytes.fromhex(data)
|
||||||
|
|
||||||
|
|
||||||
HexBytes = Annotated[
|
HexBytes = Annotated[
|
||||||
bytes,
|
bytes,
|
||||||
PlainSerializer(hexify, return_type=str, when_used="json"),
|
PlainSerializer(hexify, return_type=str, when_used="json"),
|
||||||
|
|||||||
@ -46,7 +46,7 @@ class BlockRepository:
|
|||||||
else:
|
else:
|
||||||
return Empty()
|
return Empty()
|
||||||
|
|
||||||
async def get_by_hash(self, block_hash: str) -> Option[Block]:
|
async def get_by_hash(self, block_hash: bytes) -> Option[Block]:
|
||||||
statement = select(Block).where(Block.hash == block_hash)
|
statement = select(Block).where(Block.hash == block_hash)
|
||||||
|
|
||||||
with self.client.session() as session:
|
with self.client.session() as session:
|
||||||
|
|||||||
@ -51,7 +51,7 @@ class TransactionRepository:
|
|||||||
else:
|
else:
|
||||||
return Empty()
|
return Empty()
|
||||||
|
|
||||||
async def get_by_hash(self, transaction_hash: str) -> Option[Transaction]:
|
async def get_by_hash(self, transaction_hash: bytes) -> Option[Transaction]:
|
||||||
statement = select(Transaction).where(Transaction.hash == transaction_hash)
|
statement = select(Transaction).where(Transaction.hash == transaction_hash)
|
||||||
|
|
||||||
with self.client.session() as session:
|
with self.client.session() as session:
|
||||||
|
|||||||
@ -15,8 +15,8 @@ export default function BlocksTable() {
|
|||||||
const body = bodyRef.current;
|
const body = bodyRef.current;
|
||||||
const counter = countRef.current;
|
const counter = countRef.current;
|
||||||
|
|
||||||
// 6 columns: ID | Slot | Hash | Parent | Block Root | Transactions
|
// 5 columns: Hash | Slot | Parent | Block Root | Transactions
|
||||||
ensureFixedRowCount(body, 6, TABLE_SIZE);
|
ensureFixedRowCount(body, 5, TABLE_SIZE);
|
||||||
|
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
abortRef.current = new AbortController();
|
abortRef.current = new AbortController();
|
||||||
@ -33,14 +33,14 @@ export default function BlocksTable() {
|
|||||||
if (key) seenKeysRef.current.delete(key);
|
if (key) seenKeysRef.current.delete(key);
|
||||||
body.deleteRow(-1);
|
body.deleteRow(-1);
|
||||||
}
|
}
|
||||||
// pad with placeholders to TABLE_SIZE (6 cols)
|
// pad with placeholders to TABLE_SIZE (5 cols)
|
||||||
ensureFixedRowCount(body, 6, TABLE_SIZE);
|
ensureFixedRowCount(body, 5, TABLE_SIZE);
|
||||||
const real = [...body.rows].filter((r) => !r.classList.contains('ph')).length;
|
const real = [...body.rows].filter((r) => !r.classList.contains('ph')).length;
|
||||||
counter.textContent = String(real);
|
counter.textContent = String(real);
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateToBlockDetail = (blockId) => {
|
const navigateToBlockDetail = (blockHash) => {
|
||||||
history.pushState({}, '', PAGE.BLOCK_DETAIL(blockId));
|
history.pushState({}, '', PAGE.BLOCK_DETAIL(blockHash));
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -48,15 +48,16 @@ export default function BlocksTable() {
|
|||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.dataset.key = key;
|
tr.dataset.key = key;
|
||||||
|
|
||||||
// ID (clickable)
|
// Hash (clickable, replaces ID)
|
||||||
const tdId = document.createElement('td');
|
const tdId = document.createElement('td');
|
||||||
const linkId = document.createElement('a');
|
const linkId = document.createElement('a');
|
||||||
linkId.className = 'linkish mono';
|
linkId.className = 'linkish mono';
|
||||||
linkId.href = PAGE.BLOCK_DETAIL(b.id);
|
linkId.href = PAGE.BLOCK_DETAIL(b.hash);
|
||||||
linkId.textContent = String(b.id);
|
linkId.textContent = shortenHex(b.hash);
|
||||||
|
linkId.title = b.hash;
|
||||||
linkId.addEventListener('click', (e) => {
|
linkId.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigateToBlockDetail(b.id);
|
navigateToBlockDetail(b.hash);
|
||||||
});
|
});
|
||||||
tdId.appendChild(linkId);
|
tdId.appendChild(linkId);
|
||||||
|
|
||||||
@ -67,21 +68,18 @@ export default function BlocksTable() {
|
|||||||
spSlot.textContent = String(b.slot);
|
spSlot.textContent = String(b.slot);
|
||||||
tdSlot.appendChild(spSlot);
|
tdSlot.appendChild(spSlot);
|
||||||
|
|
||||||
// Hash
|
|
||||||
const tdHash = document.createElement('td');
|
|
||||||
const spHash = document.createElement('span');
|
|
||||||
spHash.className = 'mono';
|
|
||||||
spHash.title = b.hash;
|
|
||||||
spHash.textContent = shortenHex(b.hash);
|
|
||||||
tdHash.appendChild(spHash);
|
|
||||||
|
|
||||||
// Parent (block.parent_block_hash)
|
// Parent (block.parent_block_hash)
|
||||||
const tdParent = document.createElement('td');
|
const tdParent = document.createElement('td');
|
||||||
const spParent = document.createElement('span');
|
const linkParent = document.createElement('a');
|
||||||
spParent.className = 'mono';
|
linkParent.className = 'linkish mono';
|
||||||
spParent.title = b.parent;
|
linkParent.href = PAGE.BLOCK_DETAIL(b.parent);
|
||||||
spParent.textContent = shortenHex(b.parent);
|
linkParent.textContent = shortenHex(b.parent);
|
||||||
tdParent.appendChild(spParent);
|
linkParent.title = b.parent;
|
||||||
|
linkParent.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigateToBlockDetail(b.parent, e);
|
||||||
|
});
|
||||||
|
tdParent.appendChild(linkParent);
|
||||||
|
|
||||||
// Block Root
|
// Block Root
|
||||||
const tdRoot = document.createElement('td');
|
const tdRoot = document.createElement('td');
|
||||||
@ -98,7 +96,7 @@ export default function BlocksTable() {
|
|||||||
spCount.textContent = String(b.transactionCount);
|
spCount.textContent = String(b.transactionCount);
|
||||||
tdCount.appendChild(spCount);
|
tdCount.appendChild(spCount);
|
||||||
|
|
||||||
tr.append(tdId, tdSlot, tdHash, tdParent, tdRoot, tdCount);
|
tr.append(tdId, tdSlot, tdParent, tdRoot, tdCount);
|
||||||
body.insertBefore(tr, body.firstChild);
|
body.insertBefore(tr, body.firstChild);
|
||||||
pruneAndPad();
|
pruneAndPad();
|
||||||
};
|
};
|
||||||
@ -165,9 +163,8 @@ export default function BlocksTable() {
|
|||||||
h(
|
h(
|
||||||
'colgroup',
|
'colgroup',
|
||||||
null,
|
null,
|
||||||
h('col', { style: 'width:80px' }), // ID
|
|
||||||
h('col', { style: 'width:90px' }), // Slot
|
|
||||||
h('col', { style: 'width:240px' }), // Hash
|
h('col', { style: 'width:240px' }), // Hash
|
||||||
|
h('col', { style: 'width:90px' }), // Slot
|
||||||
h('col', { style: 'width:240px' }), // Parent
|
h('col', { style: 'width:240px' }), // Parent
|
||||||
h('col', { style: 'width:240px' }), // Block Root
|
h('col', { style: 'width:240px' }), // Block Root
|
||||||
h('col', { style: 'width:120px' }), // Transactions
|
h('col', { style: 'width:120px' }), // Transactions
|
||||||
@ -178,9 +175,8 @@ export default function BlocksTable() {
|
|||||||
h(
|
h(
|
||||||
'tr',
|
'tr',
|
||||||
null,
|
null,
|
||||||
h('th', null, 'ID'),
|
|
||||||
h('th', null, 'Slot'),
|
|
||||||
h('th', null, 'Hash'),
|
h('th', null, 'Hash'),
|
||||||
|
h('th', null, 'Slot'),
|
||||||
h('th', null, 'Parent'),
|
h('th', null, 'Parent'),
|
||||||
h('th', null, 'Block Root'),
|
h('th', null, 'Block Root'),
|
||||||
h('th', null, 'Transactions'),
|
h('th', null, 'Transactions'),
|
||||||
|
|||||||
@ -78,6 +78,7 @@ function normalizeTransaction(raw) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: raw?.id ?? '',
|
id: raw?.id ?? '',
|
||||||
|
hash: raw?.hash ?? '',
|
||||||
operations: ops,
|
operations: ops,
|
||||||
executionGasPrice: toNumber(raw?.execution_gas_price),
|
executionGasPrice: toNumber(raw?.execution_gas_price),
|
||||||
storageGasPrice: toNumber(raw?.storage_gas_price),
|
storageGasPrice: toNumber(raw?.storage_gas_price),
|
||||||
@ -90,10 +91,10 @@ function normalizeTransaction(raw) {
|
|||||||
function buildTransactionRow(tx) {
|
function buildTransactionRow(tx) {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
// ID
|
// Hash (replaces ID)
|
||||||
const tdId = document.createElement('td');
|
const tdId = document.createElement('td');
|
||||||
tdId.className = 'mono';
|
tdId.className = 'mono';
|
||||||
tdId.appendChild(createLink(`/transactions/${tx.id}`, String(tx.id), String(tx.id)));
|
tdId.appendChild(createLink(`/transactions/${tx.hash}`, shortenHex(tx.hash), tx.hash));
|
||||||
|
|
||||||
// Operations (preview)
|
// Operations (preview)
|
||||||
const tdOps = document.createElement('td');
|
const tdOps = document.createElement('td');
|
||||||
@ -127,7 +128,7 @@ export default function TransactionsTable() {
|
|||||||
const body = bodyRef.current;
|
const body = bodyRef.current;
|
||||||
const counter = countRef.current;
|
const counter = countRef.current;
|
||||||
|
|
||||||
// 4 columns: ID | Operations | Outputs | Gas
|
// 4 columns: Hash | Operations | Outputs | Gas
|
||||||
ensureFixedRowCount(body, 4, TABLE_SIZE);
|
ensureFixedRowCount(body, 4, TABLE_SIZE);
|
||||||
|
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
@ -183,7 +184,7 @@ export default function TransactionsTable() {
|
|||||||
h(
|
h(
|
||||||
'colgroup',
|
'colgroup',
|
||||||
null,
|
null,
|
||||||
h('col', { style: 'width:120px' }), // ID
|
h('col', { style: 'width:240px' }), // Hash
|
||||||
h('col', null), // Operations
|
h('col', null), // Operations
|
||||||
h('col', { style: 'width:200px' }), // Outputs (count / total)
|
h('col', { style: 'width:200px' }), // Outputs (count / total)
|
||||||
h('col', { style: 'width:200px' }), // Gas (execution / storage)
|
h('col', { style: 'width:200px' }), // Gas (execution / storage)
|
||||||
@ -194,7 +195,7 @@ export default function TransactionsTable() {
|
|||||||
h(
|
h(
|
||||||
'tr',
|
'tr',
|
||||||
null,
|
null,
|
||||||
h('th', null, 'ID'),
|
h('th', null, 'Hash'),
|
||||||
h('th', null, 'Operations'),
|
h('th', null, 'Operations'),
|
||||||
h('th', null, 'Outputs (count / total)'),
|
h('th', null, 'Outputs (count / total)'),
|
||||||
h('th', null, 'Gas (execution / storage)'),
|
h('th', null, 'Gas (execution / storage)'),
|
||||||
|
|||||||
@ -1,26 +1,26 @@
|
|||||||
const API_PREFIX = '/api/v1';
|
const API_PREFIX = '/api/v1';
|
||||||
|
|
||||||
const joinUrl = (...parts) => parts.join('/').replace(/\/{2,}/g, '/');
|
const joinUrl = (...parts) => parts.join('/').replace(/\/{2,}/g, '/');
|
||||||
const encodeId = (id) => encodeURIComponent(String(id));
|
const encodeHash = (hash) => encodeURIComponent(String(hash));
|
||||||
|
|
||||||
const HEALTH_ENDPOINT = joinUrl(API_PREFIX, 'health/stream');
|
const HEALTH_ENDPOINT = joinUrl(API_PREFIX, 'health/stream');
|
||||||
|
|
||||||
const TRANSACTION_DETAIL_BY_ID = (id) => joinUrl(API_PREFIX, 'transactions', encodeId(id));
|
const TRANSACTION_DETAIL_BY_HASH = (hash) => joinUrl(API_PREFIX, 'transactions', encodeHash(hash));
|
||||||
const TRANSACTIONS_STREAM = joinUrl(API_PREFIX, 'transactions/stream');
|
const TRANSACTIONS_STREAM = joinUrl(API_PREFIX, 'transactions/stream');
|
||||||
|
|
||||||
const BLOCK_DETAIL_BY_ID = (id) => joinUrl(API_PREFIX, 'blocks', encodeId(id));
|
const BLOCK_DETAIL_BY_HASH = (hash) => joinUrl(API_PREFIX, 'blocks', encodeHash(hash));
|
||||||
const BLOCKS_STREAM = joinUrl(API_PREFIX, 'blocks/stream');
|
const BLOCKS_STREAM = joinUrl(API_PREFIX, 'blocks/stream');
|
||||||
|
|
||||||
export const API = {
|
export const API = {
|
||||||
HEALTH_ENDPOINT,
|
HEALTH_ENDPOINT,
|
||||||
TRANSACTION_DETAIL_BY_ID,
|
TRANSACTION_DETAIL_BY_HASH,
|
||||||
TRANSACTIONS_STREAM,
|
TRANSACTIONS_STREAM,
|
||||||
BLOCK_DETAIL_BY_ID,
|
BLOCK_DETAIL_BY_HASH,
|
||||||
BLOCKS_STREAM,
|
BLOCKS_STREAM,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BLOCK_DETAIL = (id) => joinUrl('/blocks', encodeId(id));
|
const BLOCK_DETAIL = (hash) => joinUrl('/blocks', encodeHash(hash));
|
||||||
const TRANSACTION_DETAIL = (id) => joinUrl('/transactions', encodeId(id));
|
const TRANSACTION_DETAIL = (hash) => joinUrl('/transactions', encodeHash(hash));
|
||||||
|
|
||||||
export const PAGE = {
|
export const PAGE = {
|
||||||
BLOCK_DETAIL,
|
BLOCK_DETAIL,
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { h, Fragment } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { API, PAGE } from '../lib/api.js';
|
import { API, PAGE } from '../lib/api.js';
|
||||||
|
import { shortenHex } from '../lib/utils.js';
|
||||||
|
|
||||||
const OPERATIONS_PREVIEW_LIMIT = 2;
|
const OPERATIONS_PREVIEW_LIMIT = 2;
|
||||||
|
|
||||||
@ -73,15 +74,14 @@ function CopyPill({ text }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BlockDetailPage({ parameters }) {
|
export default function BlockDetailPage({ parameters }) {
|
||||||
const blockIdParameter = parameters[0];
|
const blockHash = parameters[0];
|
||||||
const blockId = Number.parseInt(String(blockIdParameter), 10);
|
const isValidHash = typeof blockHash === 'string' && blockHash.length > 0;
|
||||||
const isValidId = Number.isInteger(blockId) && blockId >= 0;
|
|
||||||
|
|
||||||
const [block, setBlock] = useState(null);
|
const [block, setBlock] = useState(null);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [errorKind, setErrorKind] = useState(null); // 'invalid-id' | 'not-found' | 'network' | null
|
const [errorKind, setErrorKind] = useState(null); // 'invalid-hash' | 'not-found' | 'network' | null
|
||||||
|
|
||||||
const pageTitle = useMemo(() => `Block ${String(blockIdParameter)}`, [blockIdParameter]);
|
const pageTitle = useMemo(() => `Block ${shortenHex(blockHash)}`, [blockHash]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = pageTitle;
|
document.title = pageTitle;
|
||||||
}, [pageTitle]);
|
}, [pageTitle]);
|
||||||
@ -91,9 +91,9 @@ export default function BlockDetailPage({ parameters }) {
|
|||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
setErrorKind(null);
|
setErrorKind(null);
|
||||||
|
|
||||||
if (!isValidId) {
|
if (!isValidHash) {
|
||||||
setErrorKind('invalid-id');
|
setErrorKind('invalid-hash');
|
||||||
setErrorMessage('Invalid block id.');
|
setErrorMessage('Invalid block hash.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +102,7 @@ export default function BlockDetailPage({ parameters }) {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API.BLOCK_DETAIL_BY_ID(blockId), {
|
const res = await fetch(API.BLOCK_DETAIL_BY_HASH(blockHash), {
|
||||||
cache: 'no-cache',
|
cache: 'no-cache',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
@ -127,7 +127,7 @@ export default function BlockDetailPage({ parameters }) {
|
|||||||
alive = false;
|
alive = false;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [blockId, isValidId]);
|
}, [blockHash, isValidHash]);
|
||||||
|
|
||||||
const header = block?.header ?? {}; // back-compat only
|
const header = block?.header ?? {}; // back-compat only
|
||||||
const transactions = Array.isArray(block?.transactions) ? block.transactions : [];
|
const transactions = Array.isArray(block?.transactions) ? block.transactions : [];
|
||||||
@ -135,8 +135,7 @@ export default function BlockDetailPage({ parameters }) {
|
|||||||
// Prefer new top-level fields; fallback to legacy header.*
|
// Prefer new top-level fields; fallback to legacy header.*
|
||||||
const slot = block?.slot ?? header?.slot ?? null;
|
const slot = block?.slot ?? header?.slot ?? null;
|
||||||
const blockRoot = block?.block_root ?? header?.block_root ?? '';
|
const blockRoot = block?.block_root ?? header?.block_root ?? '';
|
||||||
const blockHash = block?.hash ?? header?.hash ?? '';
|
const currentBlockHash = block?.hash ?? header?.hash ?? '';
|
||||||
const parentId = block?.parent_id ?? null;
|
|
||||||
const parentHash = block?.parent_block_hash ?? header?.parent_block ?? '';
|
const parentHash = block?.parent_block_hash ?? header?.parent_block ?? '';
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
@ -152,7 +151,7 @@ export default function BlockDetailPage({ parameters }) {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Error states
|
// Error states
|
||||||
errorKind === 'invalid-id' && h('p', { style: 'color:#ff8a8a' }, errorMessage),
|
errorKind === 'invalid-hash' && h('p', { style: 'color:#ff8a8a' }, errorMessage),
|
||||||
errorKind === 'not-found' &&
|
errorKind === 'not-found' &&
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
@ -202,12 +201,12 @@ export default function BlockDetailPage({ parameters }) {
|
|||||||
'span',
|
'span',
|
||||||
{
|
{
|
||||||
class: 'pill mono',
|
class: 'pill mono',
|
||||||
title: blockHash,
|
title: currentBlockHash,
|
||||||
style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;',
|
style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;',
|
||||||
},
|
},
|
||||||
String(blockHash),
|
String(currentBlockHash),
|
||||||
),
|
),
|
||||||
h(CopyPill, { text: blockHash }),
|
h(CopyPill, { text: currentBlockHash }),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Root (pill + copy)
|
// Root (pill + copy)
|
||||||
@ -227,32 +226,32 @@ export default function BlockDetailPage({ parameters }) {
|
|||||||
h(CopyPill, { text: blockRoot }),
|
h(CopyPill, { text: blockRoot }),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Parent (id link OR parent hash) + copy
|
// Parent (parent hash link) + copy
|
||||||
h('div', null, h('b', null, 'Parent:')),
|
h('div', null, h('b', null, 'Parent:')),
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{ style: 'display:flex; gap:8px; flex-wrap:wrap; align-items:flex-start;' },
|
{ style: 'display:flex; gap:8px; flex-wrap:wrap; align-items:flex-start;' },
|
||||||
parentId != null
|
parentHash
|
||||||
? h(
|
? h(
|
||||||
'a',
|
'a',
|
||||||
{
|
{
|
||||||
class: 'pill mono linkish',
|
class: 'pill mono linkish',
|
||||||
href: PAGE.BLOCK_DETAIL(parentId),
|
href: PAGE.BLOCK_DETAIL(parentHash),
|
||||||
title: String(parentId),
|
title: String(parentHash),
|
||||||
style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;',
|
style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;',
|
||||||
},
|
},
|
||||||
String(parentId),
|
shortenHex(parentHash),
|
||||||
)
|
)
|
||||||
: h(
|
: h(
|
||||||
'span',
|
'span',
|
||||||
{
|
{
|
||||||
class: 'pill mono',
|
class: 'pill mono',
|
||||||
title: parentHash,
|
title: '—',
|
||||||
style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;',
|
style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;',
|
||||||
},
|
},
|
||||||
String(parentHash || '—'),
|
'—',
|
||||||
),
|
),
|
||||||
h(CopyPill, { text: parentId ?? parentHash }),
|
h(CopyPill, { text: parentHash }),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -282,7 +281,11 @@ export default function BlockDetailPage({ parameters }) {
|
|||||||
h(
|
h(
|
||||||
'tr',
|
'tr',
|
||||||
null,
|
null,
|
||||||
h('th', { style: 'text-align:left; padding:8px 10px; white-space:nowrap;' }, 'ID'),
|
h(
|
||||||
|
'th',
|
||||||
|
{ style: 'text-align:left; padding:8px 10px; white-space:nowrap;' },
|
||||||
|
'Hash',
|
||||||
|
),
|
||||||
h(
|
h(
|
||||||
'th',
|
'th',
|
||||||
{ style: 'text-align:center; padding:8px 10px; white-space:nowrap;' },
|
{ style: 'text-align:center; padding:8px 10px; white-space:nowrap;' },
|
||||||
@ -311,8 +314,8 @@ export default function BlockDetailPage({ parameters }) {
|
|||||||
|
|
||||||
return h(
|
return h(
|
||||||
'tr',
|
'tr',
|
||||||
{ key: t?.id ?? `${count}/${total}` },
|
{ key: t?.hash ?? `${count}/${total}` },
|
||||||
// ID (left)
|
// Hash (left)
|
||||||
h(
|
h(
|
||||||
'td',
|
'td',
|
||||||
{ style: 'text-align:left; padding:8px 10px; white-space:nowrap;' },
|
{ style: 'text-align:left; padding:8px 10px; white-space:nowrap;' },
|
||||||
@ -320,10 +323,10 @@ export default function BlockDetailPage({ parameters }) {
|
|||||||
'a',
|
'a',
|
||||||
{
|
{
|
||||||
class: 'linkish mono',
|
class: 'linkish mono',
|
||||||
href: PAGE.TRANSACTION_DETAIL(t?.id ?? ''),
|
href: PAGE.TRANSACTION_DETAIL(t?.hash ?? ''),
|
||||||
title: String(t?.id ?? ''),
|
title: String(t?.hash ?? ''),
|
||||||
},
|
},
|
||||||
String(t?.id ?? ''),
|
shortenHex(t?.hash ?? ''),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Outputs (center)
|
// Outputs (center)
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
// static/pages/TransactionDetail.js
|
// static/pages/TransactionDetail.js
|
||||||
import { h, Fragment } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { API } from '../lib/api.js';
|
import { API, PAGE } from '../lib/api.js';
|
||||||
|
import { shortenHex } from '../lib/utils.js';
|
||||||
|
|
||||||
// ————— helpers —————
|
// ————— helpers —————
|
||||||
const isNumber = (v) => typeof v === 'number' && !Number.isNaN(v);
|
const isNumber = (v) => typeof v === 'number' && !Number.isNaN(v);
|
||||||
@ -105,7 +106,7 @@ function normalizeTransaction(raw) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: raw?.id ?? '',
|
id: raw?.id ?? '',
|
||||||
blockId: raw?.block_id ?? null,
|
blockHash: raw?.block_hash ?? null,
|
||||||
hash: renderBytes(raw?.hash),
|
hash: renderBytes(raw?.hash),
|
||||||
proof: renderBytes(raw?.proof),
|
proof: renderBytes(raw?.proof),
|
||||||
operations: ops, // keep objects, we’ll label in UI
|
operations: ops, // keep objects, we’ll label in UI
|
||||||
@ -143,15 +144,15 @@ function Summary({ tx }) {
|
|||||||
{ style: 'display:grid; gap:8px;' },
|
{ style: 'display:grid; gap:8px;' },
|
||||||
|
|
||||||
// Block link
|
// Block link
|
||||||
tx.blockId != null &&
|
tx.blockHash != null &&
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
null,
|
null,
|
||||||
h('b', null, 'Block: '),
|
h('b', null, 'Block: '),
|
||||||
h(
|
h(
|
||||||
'a',
|
'a',
|
||||||
{ class: 'linkish mono', href: API.BLOCK_DETAIL_BY_ID(tx.blockId), title: String(tx.blockId) },
|
{ class: 'linkish mono', href: PAGE.BLOCK_DETAIL(tx.blockHash), title: String(tx.blockHash) },
|
||||||
String(tx.blockId),
|
shortenHex(tx.blockHash),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@ -349,14 +350,13 @@ function Ledger({ ledger }) {
|
|||||||
|
|
||||||
// ————— page —————
|
// ————— page —————
|
||||||
export default function TransactionDetail({ parameters }) {
|
export default function TransactionDetail({ parameters }) {
|
||||||
const idParam = parameters?.[0];
|
const transactionHash = parameters?.[0];
|
||||||
const id = Number.parseInt(String(idParam), 10);
|
const isValidHash = typeof transactionHash === 'string' && transactionHash.length > 0;
|
||||||
const isValidId = Number.isInteger(id) && id >= 0;
|
|
||||||
|
|
||||||
const [tx, setTx] = useState(null);
|
const [tx, setTx] = useState(null);
|
||||||
const [err, setErr] = useState(null); // { kind: 'invalid'|'not-found'|'network', msg: string }
|
const [err, setErr] = useState(null); // { kind: 'invalid'|'not-found'|'network', msg: string }
|
||||||
|
|
||||||
const pageTitle = useMemo(() => `Transaction ${String(idParam)}`, [idParam]);
|
const pageTitle = useMemo(() => `Transaction ${shortenHex(transactionHash)}`, [transactionHash]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = pageTitle;
|
document.title = pageTitle;
|
||||||
}, [pageTitle]);
|
}, [pageTitle]);
|
||||||
@ -365,8 +365,8 @@ export default function TransactionDetail({ parameters }) {
|
|||||||
setTx(null);
|
setTx(null);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
|
|
||||||
if (!isValidId) {
|
if (!isValidHash) {
|
||||||
setErr({ kind: 'invalid', msg: 'Invalid transaction id.' });
|
setErr({ kind: 'invalid', msg: 'Invalid transaction hash.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -375,7 +375,7 @@ export default function TransactionDetail({ parameters }) {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API.TRANSACTION_DETAIL_BY_ID(id), {
|
const res = await fetch(API.TRANSACTION_DETAIL_BY_HASH(transactionHash), {
|
||||||
cache: 'no-cache',
|
cache: 'no-cache',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
@ -397,7 +397,7 @@ export default function TransactionDetail({ parameters }) {
|
|||||||
alive = false;
|
alive = false;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [id, isValidId]);
|
}, [transactionHash, isValidHash]);
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
'main',
|
'main',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user