mirror of
https://github.com/logos-blockchain/logos-blockchain-block-explorer-template.git
synced 2026-05-18 07:19:27 +00:00
fix parsing for latest blockchain and add a few features for devnet
This commit is contained in:
parent
02658ac293
commit
1d8c0fdec9
@ -15,6 +15,23 @@ if TYPE_CHECKING:
|
||||
from core.app import NBE
|
||||
|
||||
|
||||
async def list_blocks(
|
||||
request: NBERequest,
|
||||
page: int = Query(0, ge=0),
|
||||
page_size: int = Query(10, ge=1, le=100, alias="page-size"),
|
||||
) -> Response:
|
||||
blocks, total_count = await request.app.state.block_repository.get_paginated(page, page_size)
|
||||
total_pages = (total_count + page_size - 1) // page_size # ceiling division
|
||||
|
||||
return JSONResponse({
|
||||
"blocks": [BlockRead.from_block(block).model_dump(mode="json") for block in blocks],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_count": total_count,
|
||||
"total_pages": total_pages,
|
||||
})
|
||||
|
||||
|
||||
async def _get_blocks_stream_serialized(app: "NBE", block_from: Option[Block]) -> AsyncIterator[List[BlockRead]]:
|
||||
_stream = app.state.block_repository.updates_stream(block_from)
|
||||
async for blocks in _stream:
|
||||
|
||||
@ -18,6 +18,7 @@ def create_v1_router() -> APIRouter:
|
||||
router.add_api_route("/transactions/{transaction_hash:str}", transactions.get, methods=["GET"])
|
||||
|
||||
router.add_api_route("/blocks/stream", blocks.stream, methods=["GET"])
|
||||
router.add_api_route("/blocks/list", blocks.list_blocks, methods=["GET"])
|
||||
router.add_api_route("/blocks/{block_hash:str}", blocks.get, methods=["GET"])
|
||||
|
||||
return router
|
||||
|
||||
@ -11,7 +11,14 @@ def dehexify(data: str) -> bytes:
|
||||
return bytes.fromhex(data)
|
||||
|
||||
|
||||
def validate_hex_bytes(data: str | bytes) -> bytes:
|
||||
if isinstance(data, bytes):
|
||||
return data
|
||||
return bytes.fromhex(data)
|
||||
|
||||
|
||||
HexBytes = Annotated[
|
||||
bytes,
|
||||
BeforeValidator(validate_hex_bytes),
|
||||
PlainSerializer(hexify, return_type=str, when_used="json"),
|
||||
]
|
||||
|
||||
@ -77,6 +77,30 @@ class BlockRepository:
|
||||
else:
|
||||
return Empty()
|
||||
|
||||
async def get_paginated(self, page: int, page_size: int) -> tuple[List[Block], int]:
|
||||
"""
|
||||
Get blocks with pagination, ordered by slot descending (newest first).
|
||||
Returns a tuple of (blocks, total_count).
|
||||
"""
|
||||
offset = page * page_size
|
||||
|
||||
with self.client.session() as session:
|
||||
# Get total count
|
||||
from sqlalchemy import func
|
||||
count_statement = select(func.count()).select_from(Block)
|
||||
total_count = session.exec(count_statement).one()
|
||||
|
||||
# Get paginated blocks
|
||||
statement = (
|
||||
select(Block)
|
||||
.order_by(Block.slot.desc(), Block.id.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
blocks = session.exec(statement).all()
|
||||
|
||||
return blocks, total_count
|
||||
|
||||
async def updates_stream(
|
||||
self, block_from: Option[Block], *, timeout_seconds: int = 1
|
||||
) -> AsyncIterator[List[Block]]:
|
||||
|
||||
@ -1,2 +1 @@
|
||||
from .proof_of_leadership import ProofOfLeadership
|
||||
from .public import Public
|
||||
|
||||
@ -3,7 +3,6 @@ from typing import Optional, Union
|
||||
|
||||
from core.models import NbeSchema
|
||||
from core.types import HexBytes
|
||||
from models.header.public import Public
|
||||
|
||||
|
||||
class ProofOfLeadershipType(Enum):
|
||||
@ -19,7 +18,6 @@ class Groth16ProofOfLeadership(NbeProofOfLeadership):
|
||||
entropy_contribution: HexBytes
|
||||
leader_key: HexBytes
|
||||
proof: HexBytes
|
||||
public: Optional[Public]
|
||||
voucher_cm: HexBytes
|
||||
|
||||
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
from core.models import NbeSchema
|
||||
from core.types import HexBytes
|
||||
|
||||
|
||||
class Public(NbeSchema):
|
||||
aged_root: HexBytes
|
||||
epoch_nonce: HexBytes
|
||||
latest_root: HexBytes
|
||||
slot: int
|
||||
total_stake: int
|
||||
@ -4,14 +4,14 @@ from typing import List, Self
|
||||
from pydantic import Field
|
||||
|
||||
from core.models import NbeSerializer
|
||||
from node.api.serializers.fields import BytesFromIntArray
|
||||
from node.api.serializers.fields import BytesFromHex
|
||||
from node.api.serializers.note import NoteSerializer
|
||||
from utils.protocols import FromRandom
|
||||
from utils.random import random_bytes
|
||||
|
||||
|
||||
class LedgerTransactionSerializer(NbeSerializer, FromRandom):
|
||||
inputs: List[BytesFromIntArray] = Field(description="Fr integer.")
|
||||
inputs: List[BytesFromHex] = Field(description="Fr integer.")
|
||||
outputs: List[NoteSerializer]
|
||||
|
||||
@classmethod
|
||||
@ -21,7 +21,7 @@ class LedgerTransactionSerializer(NbeSerializer, FromRandom):
|
||||
|
||||
return cls.model_validate(
|
||||
{
|
||||
"inputs": [list(random_bytes(2048)) for _ in range(n_inputs)],
|
||||
"inputs": [random_bytes(32).hex() for _ in range(n_inputs)],
|
||||
"outputs": [NoteSerializer.from_random() for _ in range(n_outputs)],
|
||||
}
|
||||
)
|
||||
|
||||
@ -10,7 +10,6 @@ from models.header.proof_of_leadership import (
|
||||
ProofOfLeadership,
|
||||
)
|
||||
from node.api.serializers.fields import BytesFromHex, BytesFromIntArray
|
||||
from node.api.serializers.public import PublicSerializer
|
||||
from utils.protocols import EnforceSubclassFromRandom
|
||||
from utils.random import random_bytes
|
||||
|
||||
@ -27,17 +26,14 @@ class Groth16LeaderProofSerializer(ProofOfLeadershipSerializer, NbeSerializer):
|
||||
proof: BytesFromIntArray = Field(
|
||||
description="Bytes in Integer Array format.",
|
||||
)
|
||||
public: Optional[PublicSerializer] = Field(description="Only received if Node is running in dev mode.")
|
||||
voucher_cm: BytesFromHex = Field(description="Hash.")
|
||||
|
||||
def into_proof_of_leadership(self) -> ProofOfLeadership:
|
||||
public = self.public.into_public() if self.public else None
|
||||
return Groth16ProofOfLeadership.model_validate(
|
||||
{
|
||||
"entropy_contribution": self.entropy_contribution,
|
||||
"leader_key": self.leader_key,
|
||||
"proof": self.proof,
|
||||
"public": public,
|
||||
"voucher_cm": self.voucher_cm,
|
||||
}
|
||||
)
|
||||
@ -49,7 +45,6 @@ class Groth16LeaderProofSerializer(ProofOfLeadershipSerializer, NbeSerializer):
|
||||
"entropy_contribution": random_bytes(32).hex(),
|
||||
"leader_key": random_bytes(32).hex(),
|
||||
"proof": list(random_bytes(128)),
|
||||
"public": PublicSerializer.from_random(slot),
|
||||
"voucher_cm": random_bytes(32).hex(),
|
||||
}
|
||||
)
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
from random import randint
|
||||
from typing import Self
|
||||
|
||||
from pydantic import Field
|
||||
from rusty_results import Option
|
||||
|
||||
from core.models import NbeSerializer
|
||||
from models.header.public import Public
|
||||
from node.api.serializers.fields import BytesFromHex
|
||||
from utils.protocols import FromRandom
|
||||
from utils.random import random_bytes
|
||||
|
||||
|
||||
class PublicSerializer(NbeSerializer, FromRandom):
|
||||
aged_root: BytesFromHex = Field(description="Fr integer in hex format.")
|
||||
epoch_nonce: BytesFromHex = Field(description="Fr integer in hex format.")
|
||||
latest_root: BytesFromHex = Field(description="Fr integer in hex format.")
|
||||
slot: int = Field(description="Integer in u64 format.")
|
||||
total_stake: int = Field(description="Integer in u64 format.")
|
||||
|
||||
def into_public(self) -> Public:
|
||||
return Public.model_validate(
|
||||
{
|
||||
"aged_root": self.aged_root,
|
||||
"epoch_nonce": self.epoch_nonce,
|
||||
"latest_root": self.latest_root,
|
||||
"slot": self.slot,
|
||||
"total_stake": self.total_stake,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_random(cls, slot: Option[int]) -> Self:
|
||||
cls.model_validate(
|
||||
{
|
||||
"aged_root": random_bytes(32).hex(),
|
||||
"epoch_nonce": random_bytes(32).hex(),
|
||||
"latest_root": random_bytes(32).hex(),
|
||||
"slot": slot.unwrap_or(randint(0, 10_000)),
|
||||
"total_stake": randint(0, 10_000),
|
||||
}
|
||||
)
|
||||
@ -1,11 +1,10 @@
|
||||
from typing import List, Self
|
||||
|
||||
from pydantic import Field
|
||||
from rusty_results import Option
|
||||
|
||||
from core.models import NbeSerializer
|
||||
from models.transactions.transaction import Transaction
|
||||
from node.api.serializers.fields import BytesFromHex
|
||||
from node.api.serializers.fields import BytesFromIntArray
|
||||
from node.api.serializers.proof import (
|
||||
OperationProofSerializer,
|
||||
OperationProofSerializerField,
|
||||
@ -15,14 +14,31 @@ from utils.protocols import FromRandom
|
||||
from utils.random import random_bytes
|
||||
|
||||
|
||||
class Groth16ProofSerializer(NbeSerializer, FromRandom):
|
||||
pi_a: BytesFromIntArray
|
||||
pi_b: BytesFromIntArray
|
||||
pi_c: BytesFromIntArray
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
return self.pi_a + self.pi_b + self.pi_c
|
||||
|
||||
@classmethod
|
||||
def from_random(cls) -> Self:
|
||||
return cls.model_validate(
|
||||
{
|
||||
"pi_a": list(random_bytes(32)),
|
||||
"pi_b": list(random_bytes(64)),
|
||||
"pi_c": list(random_bytes(32)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SignedTransactionSerializer(NbeSerializer, FromRandom):
|
||||
transaction: TransactionSerializer = Field(alias="mantle_tx", description="Transaction.")
|
||||
operations_proofs: List[OperationProofSerializerField] = Field(
|
||||
alias="ops_proofs", description="List of OperationProof. Order should match `Self::transaction::operations`."
|
||||
)
|
||||
ledger_transaction_proof: BytesFromHex = Field(
|
||||
alias="ledger_tx_proof", description="Hash.", min_length=128, max_length=128
|
||||
)
|
||||
ledger_transaction_proof: Groth16ProofSerializer = Field(alias="ledger_tx_proof", description="Groth16 proof.")
|
||||
|
||||
def into_transaction(self) -> Transaction:
|
||||
operations_contents = self.transaction.operations_contents
|
||||
@ -48,7 +64,7 @@ class SignedTransactionSerializer(NbeSerializer, FromRandom):
|
||||
"operations": operations,
|
||||
"inputs": ledger_transaction.inputs,
|
||||
"outputs": outputs,
|
||||
"proof": self.ledger_transaction_proof,
|
||||
"proof": self.ledger_transaction_proof.to_bytes(),
|
||||
"execution_gas_price": self.transaction.execution_gas_price,
|
||||
"storage_gas_price": self.transaction.storage_gas_price,
|
||||
}
|
||||
@ -60,5 +76,5 @@ class SignedTransactionSerializer(NbeSerializer, FromRandom):
|
||||
n = len(transaction.operations_contents)
|
||||
operations_proofs = [OperationProofSerializer.from_random() for _ in range(n)]
|
||||
return cls.model_validate(
|
||||
{"mantle_tx": transaction, "ops_proofs": operations_proofs, "ledger_tx_proof": random_bytes(128).hex()}
|
||||
{"mantle_tx": transaction, "ops_proofs": operations_proofs, "ledger_tx_proof": Groth16ProofSerializer.from_random()}
|
||||
)
|
||||
|
||||
@ -1,158 +1,239 @@
|
||||
// static/pages/BlocksTable.js
|
||||
// static/components/BlocksTable.js
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { useEffect, useState, useCallback, useRef } from 'preact/hooks';
|
||||
import { PAGE, API } from '../lib/api.js';
|
||||
import { TABLE_SIZE } from '../lib/constants.js';
|
||||
import { streamNdjson, ensureFixedRowCount, shortenHex } from '../lib/utils.js';
|
||||
import { shortenHex, streamNdjson } from '../lib/utils.js';
|
||||
|
||||
const normalize = (raw) => {
|
||||
const header = raw.header ?? 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(raw.slot ?? header?.slot ?? 0),
|
||||
hash: raw.hash ?? header?.hash ?? '',
|
||||
parent: raw.parent_block_hash ?? header?.parent_block ?? raw.parent_block ?? '',
|
||||
root: raw.block_root ?? header?.block_root ?? '',
|
||||
transactionCount: txLen,
|
||||
};
|
||||
};
|
||||
|
||||
export default function BlocksTable() {
|
||||
const bodyRef = useRef(null);
|
||||
const countRef = useRef(null);
|
||||
const [blocks, setBlocks] = useState([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [live, setLive] = useState(true); // Start in live mode
|
||||
|
||||
const abortRef = useRef(null);
|
||||
const seenKeysRef = useRef(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const body = bodyRef.current;
|
||||
const counter = countRef.current;
|
||||
// Fetch paginated blocks
|
||||
const fetchBlocks = useCallback(async (pageNum) => {
|
||||
// Stop any live stream
|
||||
abortRef.current?.abort();
|
||||
seenKeysRef.current.clear();
|
||||
|
||||
// 5 columns: Hash | Slot | Parent | Block Root | Transactions
|
||||
ensureFixedRowCount(body, 5, TABLE_SIZE);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(API.BLOCKS_LIST(pageNum, TABLE_SIZE));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setBlocks(data.blocks.map(normalize));
|
||||
setTotalPages(data.total_pages);
|
||||
setTotalCount(data.total_count);
|
||||
setPage(data.page);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Start live streaming
|
||||
const startLiveStream = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
seenKeysRef.current.clear();
|
||||
setBlocks([]);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const pruneAndPad = () => {
|
||||
// remove any placeholder rows that snuck in
|
||||
for (let i = body.rows.length - 1; i >= 0; i--) {
|
||||
if (body.rows[i].classList.contains('ph')) body.deleteRow(i);
|
||||
}
|
||||
// keep at most TABLE_SIZE non-placeholder rows
|
||||
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);
|
||||
body.deleteRow(-1);
|
||||
}
|
||||
// pad with placeholders to TABLE_SIZE (5 cols)
|
||||
ensureFixedRowCount(body, 5, TABLE_SIZE);
|
||||
const real = [...body.rows].filter((r) => !r.classList.contains('ph')).length;
|
||||
counter.textContent = String(real);
|
||||
};
|
||||
|
||||
const navigateToBlockDetail = (blockHash) => {
|
||||
history.pushState({}, '', PAGE.BLOCK_DETAIL(blockHash));
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
};
|
||||
|
||||
const appendRow = (b, key) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.key = key;
|
||||
|
||||
// Hash (clickable, replaces ID)
|
||||
const tdId = document.createElement('td');
|
||||
const linkId = document.createElement('a');
|
||||
linkId.className = 'linkish mono';
|
||||
linkId.href = PAGE.BLOCK_DETAIL(b.hash);
|
||||
linkId.textContent = shortenHex(b.hash);
|
||||
linkId.title = b.hash;
|
||||
linkId.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
navigateToBlockDetail(b.hash);
|
||||
});
|
||||
tdId.appendChild(linkId);
|
||||
|
||||
// Slot
|
||||
const tdSlot = document.createElement('td');
|
||||
const spSlot = document.createElement('span');
|
||||
spSlot.className = 'mono';
|
||||
spSlot.textContent = String(b.slot);
|
||||
tdSlot.appendChild(spSlot);
|
||||
|
||||
// Parent (block.parent_block_hash)
|
||||
const tdParent = document.createElement('td');
|
||||
const linkParent = document.createElement('a');
|
||||
linkParent.className = 'linkish mono';
|
||||
linkParent.href = PAGE.BLOCK_DETAIL(b.parent);
|
||||
linkParent.textContent = shortenHex(b.parent);
|
||||
linkParent.title = b.parent;
|
||||
linkParent.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
navigateToBlockDetail(b.parent, e);
|
||||
});
|
||||
tdParent.appendChild(linkParent);
|
||||
|
||||
// Block 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);
|
||||
|
||||
// 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, tdParent, tdRoot, tdCount);
|
||||
body.insertBefore(tr, body.firstChild);
|
||||
pruneAndPad();
|
||||
};
|
||||
|
||||
const normalize = (raw) => {
|
||||
// New backend:
|
||||
// { id, hash, slot, block_root, parent_block_hash, transactions: [...] }
|
||||
// Back-compat (header.* / raw.parent_block) just in case.
|
||||
const header = raw.header ?? 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(raw.slot ?? header?.slot ?? 0),
|
||||
hash: raw.hash ?? header?.hash ?? '',
|
||||
parent: raw.parent_block_hash ?? header?.parent_block ?? raw.parent_block ?? '',
|
||||
root: raw.block_root ?? header?.block_root ?? '',
|
||||
transactionCount: txLen,
|
||||
};
|
||||
};
|
||||
let liveBlocks = [];
|
||||
|
||||
streamNdjson(
|
||||
`${API.BLOCKS_STREAM}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`,
|
||||
(raw) => {
|
||||
const b = normalize(raw);
|
||||
const key = `${b.id}:${b.slot}`;
|
||||
if (seenKeysRef.current.has(key)) {
|
||||
pruneAndPad();
|
||||
return;
|
||||
}
|
||||
if (seenKeysRef.current.has(key)) return;
|
||||
seenKeysRef.current.add(key);
|
||||
appendRow(b, key);
|
||||
|
||||
// Add to front, keep max TABLE_SIZE
|
||||
liveBlocks = [b, ...liveBlocks].slice(0, TABLE_SIZE);
|
||||
setBlocks([...liveBlocks]);
|
||||
setTotalCount(liveBlocks.length);
|
||||
setLoading(false);
|
||||
},
|
||||
{
|
||||
signal: abortRef.current.signal,
|
||||
onError: (e) => {
|
||||
console.error('Blocks stream error:', e);
|
||||
if (e?.name !== 'AbortError') {
|
||||
console.error('Blocks stream error:', e);
|
||||
setError(e?.message || 'Stream error');
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return () => abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
// Handle live mode changes
|
||||
useEffect(() => {
|
||||
if (live) {
|
||||
startLiveStream();
|
||||
}
|
||||
return () => abortRef.current?.abort();
|
||||
}, [live, startLiveStream]);
|
||||
|
||||
// Go to a page (turns off live mode)
|
||||
const goToPage = (newPage) => {
|
||||
if (newPage >= 0) {
|
||||
setLive(false);
|
||||
fetchBlocks(newPage);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle live mode
|
||||
const toggleLive = () => {
|
||||
if (!live) {
|
||||
setLive(true);
|
||||
setPage(0);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToBlockDetail = (blockHash) => {
|
||||
history.pushState({}, '', PAGE.BLOCK_DETAIL(blockHash));
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
};
|
||||
|
||||
const renderRow = (b, idx) => {
|
||||
return h(
|
||||
'tr',
|
||||
{ key: b.id || idx },
|
||||
// Hash
|
||||
h(
|
||||
'td',
|
||||
null,
|
||||
h(
|
||||
'a',
|
||||
{
|
||||
class: 'linkish mono',
|
||||
href: PAGE.BLOCK_DETAIL(b.hash),
|
||||
title: b.hash,
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
navigateToBlockDetail(b.hash);
|
||||
},
|
||||
},
|
||||
shortenHex(b.hash),
|
||||
),
|
||||
),
|
||||
// Slot
|
||||
h('td', null, h('span', { class: 'mono' }, String(b.slot))),
|
||||
// Parent
|
||||
h(
|
||||
'td',
|
||||
null,
|
||||
h(
|
||||
'a',
|
||||
{
|
||||
class: 'linkish mono',
|
||||
href: PAGE.BLOCK_DETAIL(b.parent),
|
||||
title: b.parent,
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
navigateToBlockDetail(b.parent);
|
||||
},
|
||||
},
|
||||
shortenHex(b.parent),
|
||||
),
|
||||
),
|
||||
// Block Root
|
||||
h('td', null, h('span', { class: 'mono', title: b.root }, shortenHex(b.root))),
|
||||
// Transactions
|
||||
h('td', null, h('span', { class: 'mono' }, String(b.transactionCount))),
|
||||
);
|
||||
};
|
||||
|
||||
const renderPlaceholderRow = (idx) => {
|
||||
return h(
|
||||
'tr',
|
||||
{ key: `ph-${idx}`, class: 'ph' },
|
||||
h('td', null, '\u00A0'),
|
||||
h('td', null, '\u00A0'),
|
||||
h('td', null, '\u00A0'),
|
||||
h('td', null, '\u00A0'),
|
||||
h('td', null, '\u00A0'),
|
||||
);
|
||||
};
|
||||
|
||||
const rows = [];
|
||||
for (let i = 0; i < TABLE_SIZE; i++) {
|
||||
if (i < blocks.length) {
|
||||
rows.push(renderRow(blocks[i], i));
|
||||
} else {
|
||||
rows.push(renderPlaceholderRow(i));
|
||||
}
|
||||
}
|
||||
|
||||
// Live button styles
|
||||
const liveButtonStyle = live
|
||||
? `
|
||||
cursor: pointer;
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
border: none;
|
||||
animation: live-pulse 1.5s ease-in-out infinite;
|
||||
`
|
||||
: `
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary, #333);
|
||||
color: var(--muted, #888);
|
||||
border: 1px solid var(--border, #444);
|
||||
`;
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'card' },
|
||||
// Inject keyframes for the pulse animation
|
||||
h('style', null, `
|
||||
@keyframes live-pulse {
|
||||
0%, 100% { box-shadow: 0 0 4px #ff4444, 0 0 8px #ff4444; }
|
||||
50% { box-shadow: 0 0 8px #ff4444, 0 0 16px #ff4444, 0 0 24px #ff6666; }
|
||||
}
|
||||
`),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'card-header' },
|
||||
h('div', null, h('strong', null, 'Blocks '), h('span', { class: 'pill', ref: countRef }, '0')),
|
||||
h('div', { style: 'color:var(--muted); fontSize:12px;' }),
|
||||
{ class: 'card-header', style: 'display:flex; justify-content:space-between; align-items:center;' },
|
||||
h('div', null, h('strong', null, 'Blocks '), h('span', { class: 'pill' }, String(totalCount))),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'pill',
|
||||
style: liveButtonStyle,
|
||||
onClick: toggleLive,
|
||||
title: live ? 'Live updates enabled' : 'Click to enable live updates',
|
||||
},
|
||||
live ? 'LIVE \u2022' : 'LIVE',
|
||||
),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
@ -182,8 +263,43 @@ export default function BlocksTable() {
|
||||
h('th', null, 'Transactions'),
|
||||
),
|
||||
),
|
||||
h('tbody', { ref: bodyRef }),
|
||||
h('tbody', null, ...rows),
|
||||
),
|
||||
),
|
||||
// Pagination controls
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'card-footer',
|
||||
style: 'display:flex; justify-content:space-between; align-items:center; padding:8px 14px; border-top:1px solid var(--border);',
|
||||
},
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'pill',
|
||||
disabled: page === 0 || loading,
|
||||
onClick: () => goToPage(page - 1),
|
||||
style: 'cursor:pointer;',
|
||||
},
|
||||
'Previous',
|
||||
),
|
||||
h(
|
||||
'span',
|
||||
{ style: 'color:var(--muted); font-size:13px;' },
|
||||
live ? 'Streaming live blocks...' : totalPages > 0 ? `Page ${page + 1} of ${totalPages}` : 'No blocks',
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'pill',
|
||||
disabled: (!live && page >= totalPages - 1) || loading,
|
||||
onClick: () => live ? goToPage(0) : goToPage(page + 1),
|
||||
style: 'cursor:pointer;',
|
||||
},
|
||||
'Next',
|
||||
),
|
||||
),
|
||||
// Error display
|
||||
error && h('div', { style: 'padding:8px 14px; color:#ff8a8a;' }, `Error: ${error}`),
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,8 @@ const TRANSACTIONS_STREAM = joinUrl(API_PREFIX, 'transactions/stream');
|
||||
|
||||
const BLOCK_DETAIL_BY_HASH = (hash) => joinUrl(API_PREFIX, 'blocks', encodeHash(hash));
|
||||
const BLOCKS_STREAM = joinUrl(API_PREFIX, 'blocks/stream');
|
||||
const BLOCKS_LIST = (page, pageSize) =>
|
||||
`${joinUrl(API_PREFIX, 'blocks/list')}?page=${encodeURIComponent(page)}&page-size=${encodeURIComponent(pageSize)}`;
|
||||
|
||||
export const API = {
|
||||
HEALTH_ENDPOINT,
|
||||
@ -17,6 +19,7 @@ export const API = {
|
||||
TRANSACTIONS_STREAM,
|
||||
BLOCK_DETAIL_BY_HASH,
|
||||
BLOCKS_STREAM,
|
||||
BLOCKS_LIST,
|
||||
};
|
||||
|
||||
const BLOCK_DETAIL = (hash) => joinUrl('/blocks', encodeHash(hash));
|
||||
|
||||
@ -218,7 +218,7 @@ function InputsTable({ inputs }) {
|
||||
h('col', { style: 'width:80px' }), // #
|
||||
h('col', null), // Value
|
||||
),
|
||||
h('thead', null, h('tr', null, h('th', { style: 'text-align:center;' }, '#'), h('th', null, 'Value'))),
|
||||
h('thead', null, h('tr', null, h('th', { style: 'text-align:center;' }, '#'), h('th', null, 'Note ID'))),
|
||||
h(
|
||||
'tbody',
|
||||
null,
|
||||
@ -299,7 +299,6 @@ function OutputsTable({ outputs }) {
|
||||
function Ledger({ ledger }) {
|
||||
const inputs = Array.isArray(ledger?.inputs) ? ledger.inputs : [];
|
||||
const outputs = Array.isArray(ledger?.outputs) ? ledger.outputs : [];
|
||||
const totalInputValue = inputs.reduce((s, v) => s + toNumber(v), 0);
|
||||
const totalOutputValue = toNumber(ledger?.totalOutputValue);
|
||||
|
||||
return h(
|
||||
@ -318,11 +317,6 @@ function Ledger({ ledger }) {
|
||||
{ style: 'display:flex; alignItems: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 }),
|
||||
),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user