2026-02-05 19:45:42 +04:00

312 lines
10 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';
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() {
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());
// Fetch paginated blocks
const fetchBlocks = useCallback(async (pageNum) => {
// 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));
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);
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)) 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 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),
),
),
// 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));
}
}
// 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', 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',
{ 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: 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}`),
);
}