mirror of
https://github.com/logos-blockchain/logos-blockchain-block-explorer-template.git
synced 2026-02-17 11:33:06 +00:00
pagination in tx page
This commit is contained in:
parent
e2b61db480
commit
a8d350b601
@ -15,6 +15,7 @@ def create_v1_router() -> APIRouter:
|
||||
router.add_api_route("/health", health.get, methods=["GET", "HEAD"])
|
||||
|
||||
router.add_api_route("/transactions/stream", transactions.stream, methods=["GET"])
|
||||
router.add_api_route("/transactions/list", transactions.list_transactions, methods=["GET"])
|
||||
router.add_api_route("/transactions/{transaction_hash:str}", transactions.get, methods=["GET"])
|
||||
|
||||
router.add_api_route("/fork-choice", fork_choice.get, methods=["GET"])
|
||||
|
||||
@ -41,6 +41,26 @@ async def stream(
|
||||
return NDJsonStreamingResponse(ndjson_transactions_stream)
|
||||
|
||||
|
||||
async def list_transactions(
|
||||
request: NBERequest,
|
||||
page: int = Query(0, ge=0),
|
||||
page_size: int = Query(10, ge=1, le=100, alias="page-size"),
|
||||
fork: int = Query(...),
|
||||
) -> Response:
|
||||
transactions, total_count = await request.app.state.transaction_repository.get_paginated(
|
||||
page, page_size, fork=fork
|
||||
)
|
||||
total_pages = (total_count + page_size - 1) // page_size
|
||||
|
||||
return JSONResponse({
|
||||
"transactions": [TransactionRead.from_transaction(tx).model_dump(mode="json") for tx in transactions],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_count": total_count,
|
||||
"total_pages": total_pages,
|
||||
})
|
||||
|
||||
|
||||
async def get(request: NBERequest, transaction_hash: str, fork: int = Query(...)) -> Response:
|
||||
if not transaction_hash:
|
||||
return Response(status_code=NOT_FOUND)
|
||||
|
||||
@ -2,7 +2,7 @@ from asyncio import sleep
|
||||
from typing import AsyncIterator, List
|
||||
|
||||
from rusty_results import Empty, Option, Some
|
||||
from sqlalchemy import Result, Select
|
||||
from sqlalchemy import Result, Select, func as sa_func
|
||||
from sqlalchemy.orm import aliased, selectinload
|
||||
from sqlmodel import select
|
||||
|
||||
@ -77,6 +77,37 @@ class TransactionRepository:
|
||||
results: Result[Transaction] = session.exec(statement)
|
||||
return results.all()
|
||||
|
||||
async def get_paginated(self, page: int, page_size: int, *, fork: int) -> tuple[List[Transaction], int]:
|
||||
"""
|
||||
Get transactions with pagination, ordered by block height descending (newest first).
|
||||
Returns a tuple of (transactions, total_count).
|
||||
"""
|
||||
offset = page * page_size
|
||||
|
||||
with self.client.session() as session:
|
||||
# Get total count for this fork
|
||||
count_statement = (
|
||||
select(sa_func.count())
|
||||
.select_from(Transaction)
|
||||
.join(Block, Transaction.block_id == Block.id)
|
||||
.where(Block.fork == fork)
|
||||
)
|
||||
total_count = session.exec(count_statement).one()
|
||||
|
||||
# Get paginated transactions
|
||||
statement = (
|
||||
select(Transaction)
|
||||
.options(selectinload(Transaction.block))
|
||||
.join(Block, Transaction.block_id == Block.id)
|
||||
.where(Block.fork == fork)
|
||||
.order_by(Block.height.desc(), Transaction.id.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
transactions = session.exec(statement).all()
|
||||
|
||||
return transactions, total_count
|
||||
|
||||
async def updates_stream(
|
||||
self, transaction_from: Option[Transaction], *, fork: int, timeout_seconds: int = 1
|
||||
) -> AsyncIterator[List[Transaction]]:
|
||||
|
||||
@ -25,14 +25,13 @@ const normalize = (raw) => {
|
||||
};
|
||||
};
|
||||
|
||||
export default function BlocksTable() {
|
||||
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 [live, setLive] = useState(true); // Start in live mode
|
||||
const [fork, setFork] = useState(null);
|
||||
|
||||
const abortRef = useRef(null);
|
||||
@ -110,27 +109,19 @@ export default function BlocksTable() {
|
||||
if (live) {
|
||||
startLiveStream(fork);
|
||||
} else {
|
||||
fetchBlocks(page, fork);
|
||||
setPage(0);
|
||||
fetchBlocks(0, fork);
|
||||
}
|
||||
return () => abortRef.current?.abort();
|
||||
}, [live, fork, startLiveStream]);
|
||||
|
||||
// Go to a page (turns off live mode)
|
||||
// Go to a page
|
||||
const goToPage = (newPage) => {
|
||||
if (newPage >= 0 && fork != null) {
|
||||
setLive(false);
|
||||
fetchBlocks(newPage, fork);
|
||||
}
|
||||
};
|
||||
|
||||
// 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'));
|
||||
@ -209,46 +200,13 @@ export default function BlocksTable() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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',
|
||||
@ -294,7 +252,7 @@ export default function BlocksTable() {
|
||||
'button',
|
||||
{
|
||||
class: 'pill',
|
||||
disabled: page === 0 || loading,
|
||||
disabled: live || page === 0 || loading,
|
||||
onClick: () => goToPage(page - 1),
|
||||
style: 'cursor:pointer;',
|
||||
},
|
||||
@ -309,8 +267,8 @@ export default function BlocksTable() {
|
||||
'button',
|
||||
{
|
||||
class: 'pill',
|
||||
disabled: (!live && page >= totalPages - 1) || loading,
|
||||
onClick: () => live ? goToPage(0) : goToPage(page + 1),
|
||||
disabled: live || page >= totalPages - 1 || loading,
|
||||
onClick: () => goToPage(page + 1),
|
||||
style: 'cursor:pointer;',
|
||||
},
|
||||
'Next',
|
||||
|
||||
@ -1,36 +1,13 @@
|
||||
// static/components/TransactionsTable.js
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useEffect, useState, useCallback, useRef } from 'preact/hooks';
|
||||
import { API, PAGE } from '../lib/api.js';
|
||||
import { TABLE_SIZE } from '../lib/constants.js';
|
||||
import {
|
||||
streamNdjson,
|
||||
ensureFixedRowCount,
|
||||
shortenHex, // (kept in case you want to use later)
|
||||
withBenignFilter,
|
||||
} from '../lib/utils.js';
|
||||
import { shortenHex, streamNdjson } from '../lib/utils.js';
|
||||
import { subscribeFork } from '../lib/fork.js';
|
||||
|
||||
const OPERATIONS_PREVIEW_LIMIT = 2;
|
||||
|
||||
// ---------- small DOM helpers ----------
|
||||
function createSpan(className, text, title) {
|
||||
const el = document.createElement('span');
|
||||
if (className) el.className = className;
|
||||
if (title) el.title = title;
|
||||
el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function createLink(href, text, title) {
|
||||
const el = document.createElement('a');
|
||||
el.className = 'linkish mono';
|
||||
el.href = href;
|
||||
if (title) el.title = title;
|
||||
el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---------- coercion / formatting helpers ----------
|
||||
const toNumber = (v) => {
|
||||
if (v == null) return 0;
|
||||
@ -97,7 +74,7 @@ function opPreview(op) {
|
||||
}
|
||||
|
||||
function formatOperationsPreview(ops) {
|
||||
if (!ops?.length) return '—';
|
||||
if (!ops?.length) return '\u2014';
|
||||
const previews = ops.map(opPreview);
|
||||
if (previews.length <= OPERATIONS_PREVIEW_LIMIT) return previews.join(', ');
|
||||
const head = previews.slice(0, OPERATIONS_PREVIEW_LIMIT).join(', ');
|
||||
@ -106,10 +83,8 @@ function formatOperationsPreview(ops) {
|
||||
}
|
||||
|
||||
// ---------- normalize API → view model ----------
|
||||
function normalizeTransaction(raw) {
|
||||
// { id, block_id, hash, operations:[Operation], inputs:[HexBytes], outputs:[Note], proof, execution_gas_price, storage_gas_price, created_at? }
|
||||
function normalize(raw) {
|
||||
const ops = Array.isArray(raw?.operations) ? raw.operations : Array.isArray(raw?.ops) ? raw.ops : [];
|
||||
|
||||
const outputs = Array.isArray(raw?.outputs) ? raw.outputs : [];
|
||||
const totalOutputValue = outputs.reduce((sum, note) => sum + toNumber(note?.value), 0);
|
||||
|
||||
@ -124,102 +99,166 @@ function normalizeTransaction(raw) {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- row builder ----------
|
||||
function buildTransactionRow(tx) {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
// Hash (replaces ID)
|
||||
const tdId = document.createElement('td');
|
||||
tdId.className = 'mono';
|
||||
tdId.appendChild(createLink(PAGE.TRANSACTION_DETAIL(tx.hash), shortenHex(tx.hash), tx.hash));
|
||||
|
||||
// Operations (preview)
|
||||
const tdOps = document.createElement('td');
|
||||
tdOps.style.whiteSpace = 'normal';
|
||||
tdOps.style.lineHeight = '1.4';
|
||||
const preview = formatOperationsPreview(tx.operations);
|
||||
const fullPreview = Array.isArray(tx.operations) ? tx.operations.map(opPreview).join(', ') : '';
|
||||
tdOps.appendChild(createSpan('', preview, fullPreview));
|
||||
|
||||
// Outputs (count / total)
|
||||
const tdOut = document.createElement('td');
|
||||
tdOut.className = 'amount';
|
||||
tdOut.textContent = `${tx.numberOfOutputs} / ${tx.totalOutputValue.toLocaleString(undefined, { maximumFractionDigits: 8 })}`;
|
||||
|
||||
tr.append(tdId, tdOps, tdOut);
|
||||
return tr;
|
||||
}
|
||||
|
||||
// ---------- component ----------
|
||||
export default function TransactionsTable() {
|
||||
const bodyRef = useRef(null);
|
||||
const countRef = useRef(null);
|
||||
const abortRef = useRef(null);
|
||||
const totalCountRef = useRef(0);
|
||||
export default function TransactionsTable({ live }) {
|
||||
const [transactions, setTransactions] = 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));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fork == null) return;
|
||||
// Fetch paginated transactions
|
||||
const fetchTransactions = useCallback(async (pageNum, currentFork) => {
|
||||
abortRef.current?.abort();
|
||||
seenKeysRef.current.clear();
|
||||
|
||||
const body = bodyRef.current;
|
||||
const counter = countRef.current;
|
||||
|
||||
// Clear existing rows on fork change
|
||||
while (body.rows.length > 0) body.deleteRow(0);
|
||||
totalCountRef.current = 0;
|
||||
counter.textContent = '0';
|
||||
|
||||
// 3 columns: Hash | Operations | Outputs
|
||||
ensureFixedRowCount(body, 3, TABLE_SIZE);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(API.TRANSACTIONS_LIST(pageNum, TABLE_SIZE, currentFork));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setTransactions(data.transactions.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();
|
||||
setTransactions([]);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = `${API.TRANSACTIONS_STREAM_WITH_FORK(fork)}&prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`;
|
||||
let liveTxs = [];
|
||||
|
||||
const url = `${API.TRANSACTIONS_STREAM_WITH_FORK(currentFork)}&prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`;
|
||||
streamNdjson(
|
||||
url,
|
||||
(raw) => {
|
||||
try {
|
||||
const tx = normalizeTransaction(raw);
|
||||
const row = buildTransactionRow(tx);
|
||||
body.insertBefore(row, body.firstChild);
|
||||
const tx = normalize(raw);
|
||||
const key = `${tx.id}:${tx.hash}`;
|
||||
if (seenKeysRef.current.has(key)) return;
|
||||
seenKeysRef.current.add(key);
|
||||
|
||||
while (body.rows.length > TABLE_SIZE) body.deleteRow(-1);
|
||||
counter.textContent = String(++totalCountRef.current);
|
||||
} catch (err) {
|
||||
console.error('Failed to render transaction row:', err, raw);
|
||||
}
|
||||
liveTxs = [tx, ...liveTxs].slice(0, TABLE_SIZE);
|
||||
setTransactions([...liveTxs]);
|
||||
setTotalCount(liveTxs.length);
|
||||
setLoading(false);
|
||||
},
|
||||
{
|
||||
signal: abortRef.current.signal,
|
||||
onError: withBenignFilter(
|
||||
(err) => console.error('Transactions stream error:', err),
|
||||
abortRef.current.signal,
|
||||
),
|
||||
onError: (e) => {
|
||||
if (e?.name !== 'AbortError') {
|
||||
console.error('Transactions stream error:', e);
|
||||
setError(e?.message || 'Stream error');
|
||||
}
|
||||
},
|
||||
},
|
||||
).catch((err) => {
|
||||
if (!abortRef.current.signal.aborted) {
|
||||
console.error('Transactions stream connection error:', err);
|
||||
}
|
||||
});
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Handle live mode and fork changes
|
||||
useEffect(() => {
|
||||
if (fork == null) return;
|
||||
if (live) {
|
||||
startLiveStream(fork);
|
||||
} else {
|
||||
setPage(0);
|
||||
fetchTransactions(0, fork);
|
||||
}
|
||||
return () => abortRef.current?.abort();
|
||||
}, [fork]);
|
||||
}, [live, fork, startLiveStream]);
|
||||
|
||||
// Go to a page
|
||||
const goToPage = (newPage) => {
|
||||
if (newPage >= 0 && fork != null) {
|
||||
fetchTransactions(newPage, fork);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToTxDetail = (txHash) => {
|
||||
history.pushState({}, '', PAGE.TRANSACTION_DETAIL(txHash));
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
};
|
||||
|
||||
const renderRow = (tx, idx) => {
|
||||
const opsPreview = formatOperationsPreview(tx.operations);
|
||||
const fullPreview = Array.isArray(tx.operations) ? tx.operations.map(opPreview).join(', ') : '';
|
||||
const outputsText = `${tx.numberOfOutputs} / ${tx.totalOutputValue.toLocaleString(undefined, { maximumFractionDigits: 8 })}`;
|
||||
|
||||
return h(
|
||||
'tr',
|
||||
{ key: tx.id || idx },
|
||||
// Hash
|
||||
h(
|
||||
'td',
|
||||
null,
|
||||
h(
|
||||
'a',
|
||||
{
|
||||
class: 'linkish mono',
|
||||
href: PAGE.TRANSACTION_DETAIL(tx.hash),
|
||||
title: tx.hash,
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
navigateToTxDetail(tx.hash);
|
||||
},
|
||||
},
|
||||
shortenHex(tx.hash),
|
||||
),
|
||||
),
|
||||
// Operations
|
||||
h('td', { style: 'white-space:normal; line-height:1.4;' }, h('span', { title: fullPreview }, opsPreview)),
|
||||
// Outputs
|
||||
h('td', { class: 'amount' }, outputsText),
|
||||
);
|
||||
};
|
||||
|
||||
const renderPlaceholderRow = (idx) => {
|
||||
return h(
|
||||
'tr',
|
||||
{ key: `ph-${idx}`, class: 'ph' },
|
||||
h('td', null, '\u00A0'),
|
||||
h('td', null, '\u00A0'),
|
||||
h('td', null, '\u00A0'),
|
||||
);
|
||||
};
|
||||
|
||||
const rows = [];
|
||||
for (let i = 0; i < TABLE_SIZE; i++) {
|
||||
if (i < transactions.length) {
|
||||
rows.push(renderRow(transactions[i], i));
|
||||
} else {
|
||||
rows.push(renderPlaceholderRow(i));
|
||||
}
|
||||
}
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'card' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'card-header' },
|
||||
h('div', null, h('strong', null, 'Transactions '), h('span', { class: 'pill', ref: countRef }, '0')),
|
||||
h('div', { style: 'color:var(--muted); font-size:12px;' }),
|
||||
{ class: 'card-header', style: 'display:flex; justify-content:space-between; align-items:center;' },
|
||||
h('div', null, h('strong', null, 'Transactions '), h('span', { class: 'pill' }, String(totalCount))),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
@ -245,8 +284,43 @@ export default function TransactionsTable() {
|
||||
h('th', null, 'Outputs (count / total)'),
|
||||
),
|
||||
),
|
||||
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: live || page === 0 || loading,
|
||||
onClick: () => goToPage(page - 1),
|
||||
style: 'cursor:pointer;',
|
||||
},
|
||||
'Previous',
|
||||
),
|
||||
h(
|
||||
'span',
|
||||
{ style: 'color:var(--muted); font-size:13px;' },
|
||||
live ? 'Streaming live transactions...' : totalPages > 0 ? `Page ${page + 1} of ${totalPages}` : 'No transactions',
|
||||
),
|
||||
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}`),
|
||||
);
|
||||
}
|
||||
|
||||
@ -22,6 +22,8 @@ const BLOCKS_LIST = (page, pageSize, fork) =>
|
||||
|
||||
const TRANSACTIONS_STREAM_WITH_FORK = (fork) =>
|
||||
`${joinUrl(API_PREFIX, 'transactions/stream')}?fork=${encodeURIComponent(fork)}`;
|
||||
const TRANSACTIONS_LIST = (page, pageSize, fork) =>
|
||||
`${joinUrl(API_PREFIX, 'transactions/list')}?page=${encodeURIComponent(page)}&page-size=${encodeURIComponent(pageSize)}&fork=${encodeURIComponent(fork)}`;
|
||||
|
||||
export const API = {
|
||||
HEALTH_ENDPOINT,
|
||||
@ -29,6 +31,7 @@ export const API = {
|
||||
TRANSACTION_DETAIL_BY_HASH,
|
||||
TRANSACTIONS_STREAM,
|
||||
TRANSACTIONS_STREAM_WITH_FORK,
|
||||
TRANSACTIONS_LIST,
|
||||
BLOCK_DETAIL_BY_HASH,
|
||||
BLOCKS_STREAM,
|
||||
BLOCKS_LIST,
|
||||
|
||||
@ -1,11 +1,48 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import BlocksTable from '../components/BlocksTable.js';
|
||||
import TransactionsTable from '../components/TransactionsTable.js';
|
||||
|
||||
export default function HomeView() {
|
||||
const [live, setLive] = useState(true);
|
||||
|
||||
const toggleLive = () => setLive((prev) => !prev);
|
||||
|
||||
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(
|
||||
'main',
|
||||
{ class: 'wrap' },
|
||||
h('section', { class: 'two-columns twocol' }, h(BlocksTable, {}), h(TransactionsTable, {})),
|
||||
h(
|
||||
'div',
|
||||
{ style: 'display:flex; justify-content:flex-end; margin-bottom:12px;' },
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'pill',
|
||||
style: liveButtonStyle,
|
||||
onClick: toggleLive,
|
||||
title: live ? 'Live updates enabled' : 'Click to enable live updates',
|
||||
},
|
||||
live ? 'LIVE \u2022' : 'LIVE',
|
||||
),
|
||||
),
|
||||
h('section', { class: 'two-columns twocol' },
|
||||
h(BlocksTable, { live }),
|
||||
h(TransactionsTable, { live }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -151,3 +151,8 @@ tr:nth-child(odd) {
|
||||
tr.ph td {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
@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; }
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user