312 lines
10 KiB
JavaScript
Raw Normal View History

// static/components/BlocksTable.js
2025-10-17 14:46:44 +02:00
import { h } from 'preact';
import { useEffect, useState, useCallback, useRef } from 'preact/hooks';
2025-10-30 15:00:06 +01:00
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),
2026-02-05 19:45:42 +04:00
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,
};
};
2025-10-17 14:46:44 +02:00
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);
2025-10-17 14:46:44 +02:00
const seenKeysRef = useRef(new Set());
// Fetch paginated blocks
const fetchBlocks = useCallback(async (pageNum) => {
// Stop any live stream
abortRef.current?.abort();
seenKeysRef.current.clear();
2025-10-20 15:42:12 +02:00
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);
}
}, []);
2025-10-17 14:46:44 +02:00
// Start live streaming
const startLiveStream = useCallback(() => {
abortRef.current?.abort();
abortRef.current = new AbortController();
seenKeysRef.current.clear();
setBlocks([]);
setLoading(true);
setError(null);
2025-10-17 14:46:44 +02:00
let liveBlocks = [];
2025-10-17 14:46:44 +02:00
streamNdjson(
2025-10-20 15:42:12 +02:00
`${API.BLOCKS_STREAM}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`,
2025-10-17 14:46:44 +02:00
(raw) => {
2025-10-20 15:42:12 +02:00
const b = normalize(raw);
const key = `${b.id}:${b.slot}`;
if (seenKeysRef.current.has(key)) return;
2025-10-17 14:46:44 +02:00
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);
2025-10-17 14:46:44 +02:00
},
{
signal: abortRef.current.signal,
2025-10-20 15:42:12 +02:00
onError: (e) => {
if (e?.name !== 'AbortError') {
console.error('Blocks stream error:', e);
setError(e?.message || 'Stream error');
}
2025-10-20 15:42:12 +02:00
},
2025-10-17 14:46:44 +02:00
},
2025-10-20 15:42:12 +02:00
);
}, []);
2025-10-17 14:46:44 +02:00
// 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),
),
),
2026-02-05 19:45:42 +04:00
// 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'),
2026-02-05 19:45:42 +04:00
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);
`;
2025-10-17 14:46:44 +02:00
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; }
}
`),
2025-10-17 14:46:44 +02:00
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',
),
2025-10-17 14:46:44 +02:00
),
h(
'div',
{ class: 'table-wrapper' },
h(
'table',
{ class: 'table--blocks' },
h(
'colgroup',
null,
2026-02-05 19:45:42 +04:00
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
2025-10-17 14:46:44 +02:00
),
h(
'thead',
null,
2025-10-20 15:42:12 +02:00
h(
'tr',
null,
2025-10-30 11:48:34 +01:00
h('th', null, 'Hash'),
2026-02-05 19:45:42 +04:00
h('th', null, 'Height'),
h('th', null, 'Slot'),
2025-10-20 15:42:12 +02:00
h('th', null, 'Parent'),
2025-10-30 11:48:34 +01:00
h('th', null, 'Block Root'),
2025-10-20 15:42:12 +02:00
h('th', null, 'Transactions'),
),
2025-10-17 14:46:44 +02:00
),
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',
2025-10-17 14:46:44 +02:00
),
),
// Error display
error && h('div', { style: 'padding:8px 14px; color:#ff8a8a;' }, `Error: ${error}`),
2025-10-17 14:46:44 +02:00
);
}