mirror of
https://github.com/logos-blockchain/logos-blockchain-block-explorer-template.git
synced 2026-02-17 19:43:08 +00:00
281 lines
9.2 KiB
JavaScript
281 lines
9.2 KiB
JavaScript
// static/components/BlocksTable.js
|
|
import { h } from 'preact';
|
|
import { useEffect, useState, useCallback, useRef } from 'preact/hooks';
|
|
import { PAGE, API } from '../lib/api.js';
|
|
import { TABLE_SIZE } from '../lib/constants.js';
|
|
import { shortenHex, streamNdjson } from '../lib/utils.js';
|
|
import { subscribeFork } from '../lib/fork.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),
|
|
height: Number(raw.height ?? 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({ live }) {
|
|
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 [fork, setFork] = useState(null);
|
|
|
|
const abortRef = useRef(null);
|
|
const seenKeysRef = useRef(new Set());
|
|
|
|
// Subscribe to fork-choice changes
|
|
useEffect(() => {
|
|
return subscribeFork((newFork) => setFork(newFork));
|
|
}, []);
|
|
|
|
// Fetch paginated blocks
|
|
const fetchBlocks = useCallback(async (pageNum, currentFork) => {
|
|
// Stop any live stream
|
|
abortRef.current?.abort();
|
|
seenKeysRef.current.clear();
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch(API.BLOCKS_LIST(pageNum, TABLE_SIZE, currentFork));
|
|
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((currentFork) => {
|
|
abortRef.current?.abort();
|
|
abortRef.current = new AbortController();
|
|
seenKeysRef.current.clear();
|
|
setBlocks([]);
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
let liveBlocks = [];
|
|
|
|
const url = `${API.BLOCKS_STREAM(currentFork)}&prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`;
|
|
streamNdjson(
|
|
url,
|
|
(raw) => {
|
|
const b = normalize(raw);
|
|
const key = `${b.id}:${b.slot}`;
|
|
if (seenKeysRef.current.has(key)) return;
|
|
seenKeysRef.current.add(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) => {
|
|
if (e?.name !== 'AbortError') {
|
|
console.error('Blocks stream error:', e);
|
|
setError(e?.message || 'Stream error');
|
|
}
|
|
},
|
|
},
|
|
);
|
|
}, []);
|
|
|
|
// Handle live mode and fork changes
|
|
useEffect(() => {
|
|
if (fork == null) return;
|
|
if (live) {
|
|
startLiveStream(fork);
|
|
} else {
|
|
setPage(0);
|
|
fetchBlocks(0, fork);
|
|
}
|
|
return () => abortRef.current?.abort();
|
|
}, [live, fork, startLiveStream]);
|
|
|
|
// Go to a page
|
|
const goToPage = (newPage) => {
|
|
if (newPage >= 0 && fork != null) {
|
|
fetchBlocks(newPage, fork);
|
|
}
|
|
};
|
|
|
|
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),
|
|
),
|
|
),
|
|
// Height
|
|
h('td', null, h('span', { class: 'mono' }, String(b.height))),
|
|
// 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'),
|
|
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));
|
|
}
|
|
}
|
|
|
|
return h(
|
|
'div',
|
|
{ class: 'card' },
|
|
h(
|
|
'div',
|
|
{ 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(
|
|
'div',
|
|
{ class: 'table-wrapper' },
|
|
h(
|
|
'table',
|
|
{ class: 'table--blocks' },
|
|
h(
|
|
'colgroup',
|
|
null,
|
|
h('col', { style: 'width:200px' }), // Hash
|
|
h('col', { style: 'width:70px' }), // Height
|
|
h('col', { style: 'width:80px' }), // Slot
|
|
h('col', { style: 'width:200px' }), // Parent
|
|
h('col', { style: 'width:200px' }), // Block Root
|
|
h('col', { style: 'width:100px' }), // Transactions
|
|
),
|
|
h(
|
|
'thead',
|
|
null,
|
|
h(
|
|
'tr',
|
|
null,
|
|
h('th', null, 'Hash'),
|
|
h('th', null, 'Height'),
|
|
h('th', null, 'Slot'),
|
|
h('th', null, 'Parent'),
|
|
h('th', null, 'Block Root'),
|
|
h('th', null, 'Transactions'),
|
|
),
|
|
),
|
|
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: live || 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: () => goToPage(page + 1),
|
|
style: 'cursor:pointer;',
|
|
},
|
|
'Next',
|
|
),
|
|
),
|
|
// Error display
|
|
error && h('div', { style: 'padding:8px 14px; color:#ff8a8a;' }, `Error: ${error}`),
|
|
);
|
|
}
|