2025-10-30 11:48:34 +01:00
|
|
|
// static/pages/TransactionsTable.js
|
2025-10-17 14:46:44 +02:00
|
|
|
import { h } from 'preact';
|
|
|
|
|
import { useEffect, useRef } from 'preact/hooks';
|
2026-02-13 10:25:14 +04:00
|
|
|
import { API, PAGE } from '../lib/api.js';
|
2025-10-30 15:00:06 +01:00
|
|
|
import { TABLE_SIZE } from '../lib/constants.js';
|
2025-10-20 15:42:12 +02:00
|
|
|
import {
|
|
|
|
|
streamNdjson,
|
|
|
|
|
ensureFixedRowCount,
|
2025-10-30 11:48:34 +01:00
|
|
|
shortenHex, // (kept in case you want to use later)
|
2025-10-20 15:42:12 +02:00
|
|
|
withBenignFilter,
|
2025-10-30 15:00:06 +01:00
|
|
|
} from '../lib/utils.js';
|
2025-10-20 15:42:12 +02:00
|
|
|
|
|
|
|
|
const OPERATIONS_PREVIEW_LIMIT = 2;
|
|
|
|
|
|
2025-10-30 11:48:34 +01:00
|
|
|
// ---------- small DOM helpers ----------
|
2025-10-20 15:42:12 +02:00
|
|
|
function createSpan(className, text, title) {
|
2025-10-30 11:48:34 +01:00
|
|
|
const el = document.createElement('span');
|
|
|
|
|
if (className) el.className = className;
|
|
|
|
|
if (title) el.title = title;
|
|
|
|
|
el.textContent = text;
|
|
|
|
|
return el;
|
2025-10-20 15:42:12 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createLink(href, text, title) {
|
2025-10-30 11:48:34 +01:00
|
|
|
const el = document.createElement('a');
|
|
|
|
|
el.className = 'linkish mono';
|
|
|
|
|
el.href = href;
|
|
|
|
|
if (title) el.title = title;
|
|
|
|
|
el.textContent = text;
|
|
|
|
|
return el;
|
2025-10-20 15:42:12 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-30 11:48:34 +01:00
|
|
|
// ---------- coercion / formatting helpers ----------
|
|
|
|
|
const toNumber = (v) => {
|
|
|
|
|
if (v == null) return 0;
|
|
|
|
|
if (typeof v === 'number') return v;
|
|
|
|
|
if (typeof v === 'bigint') return Number(v);
|
|
|
|
|
if (typeof v === 'string') {
|
|
|
|
|
const s = v.trim();
|
|
|
|
|
if (/^0x[0-9a-f]+$/i.test(s)) return Number(BigInt(s));
|
|
|
|
|
const n = Number(s);
|
|
|
|
|
return Number.isFinite(n) ? n : 0;
|
|
|
|
|
}
|
|
|
|
|
if (typeof v === 'object' && v !== null && 'value' in v) return toNumber(v.value);
|
|
|
|
|
return 0;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-13 01:03:47 +04:00
|
|
|
function tryDecodeUtf8Hex(hex) {
|
|
|
|
|
if (typeof hex !== 'string' || hex.length === 0 || hex.length % 2 !== 0) return null;
|
|
|
|
|
try {
|
|
|
|
|
const bytes = new Uint8Array(hex.length / 2);
|
|
|
|
|
for (let i = 0; i < hex.length; i += 2) {
|
|
|
|
|
const b = parseInt(hex.substring(i, i + 2), 16);
|
|
|
|
|
if (Number.isNaN(b)) return null;
|
|
|
|
|
bytes[i / 2] = b;
|
|
|
|
|
}
|
|
|
|
|
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
|
|
|
if (/[\x20-\x7e]/.test(text)) return text;
|
|
|
|
|
return null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function opPreview(op) {
|
|
|
|
|
const content = op?.content ?? op;
|
|
|
|
|
const type = content?.type ?? (typeof op === 'string' ? op : 'op');
|
|
|
|
|
|
|
|
|
|
if (type === 'ChannelInscribe' && content) {
|
|
|
|
|
const chanShort = typeof content.channel_id === 'string' ? content.channel_id.slice(0, 8) : '?';
|
|
|
|
|
let inscPreview = '';
|
|
|
|
|
if (typeof content.inscription === 'string') {
|
|
|
|
|
const decoded = tryDecodeUtf8Hex(content.inscription);
|
|
|
|
|
if (decoded != null) {
|
|
|
|
|
inscPreview = decoded.length > 20 ? decoded.slice(0, 20) + '\u2026' : decoded;
|
|
|
|
|
} else {
|
|
|
|
|
inscPreview = content.inscription.slice(0, 12) + '\u2026';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return `${type}(${chanShort}\u2026, ${inscPreview})`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (type === 'ChannelBlob' && content) {
|
|
|
|
|
const chanShort = typeof content.channel === 'string' ? content.channel.slice(0, 8) : '?';
|
|
|
|
|
const size = content.blob_size != null ? `${content.blob_size}B` : '?';
|
|
|
|
|
return `${type}(${chanShort}\u2026, ${size})`;
|
2025-10-30 11:48:34 +01:00
|
|
|
}
|
2026-02-13 01:03:47 +04:00
|
|
|
|
|
|
|
|
if (type === 'ChannelSetKeys' && content) {
|
|
|
|
|
const chanShort = typeof content.channel === 'string' ? content.channel.slice(0, 8) : '?';
|
|
|
|
|
const nKeys = Array.isArray(content.keys) ? content.keys.length : '?';
|
|
|
|
|
return `${type}(${chanShort}\u2026, ${nKeys} keys)`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return type;
|
|
|
|
|
}
|
2025-10-30 11:48:34 +01:00
|
|
|
|
|
|
|
|
function formatOperationsPreview(ops) {
|
|
|
|
|
if (!ops?.length) return '—';
|
2026-02-13 01:03:47 +04:00
|
|
|
const previews = ops.map(opPreview);
|
|
|
|
|
if (previews.length <= OPERATIONS_PREVIEW_LIMIT) return previews.join(', ');
|
|
|
|
|
const head = previews.slice(0, OPERATIONS_PREVIEW_LIMIT).join(', ');
|
|
|
|
|
const remainder = previews.length - OPERATIONS_PREVIEW_LIMIT;
|
2025-10-30 11:48:34 +01:00
|
|
|
return `${head} +${remainder}`;
|
|
|
|
|
}
|
2025-10-20 15:42:12 +02:00
|
|
|
|
2025-10-30 11:48:34 +01:00
|
|
|
// ---------- 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? }
|
|
|
|
|
const ops = Array.isArray(raw?.operations) ? raw.operations : Array.isArray(raw?.ops) ? raw.ops : [];
|
2025-10-20 15:42:12 +02:00
|
|
|
|
2025-10-30 11:48:34 +01:00
|
|
|
const outputs = Array.isArray(raw?.outputs) ? raw.outputs : [];
|
|
|
|
|
const totalOutputValue = outputs.reduce((sum, note) => sum + toNumber(note?.value), 0);
|
2025-10-20 15:42:12 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: raw?.id ?? '',
|
2025-12-19 10:11:49 +01:00
|
|
|
hash: raw?.hash ?? '',
|
2025-10-30 11:48:34 +01:00
|
|
|
operations: ops,
|
|
|
|
|
executionGasPrice: toNumber(raw?.execution_gas_price),
|
|
|
|
|
storageGasPrice: toNumber(raw?.storage_gas_price),
|
|
|
|
|
numberOfOutputs: outputs.length,
|
2025-10-20 15:42:12 +02:00
|
|
|
totalOutputValue,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-30 11:48:34 +01:00
|
|
|
// ---------- row builder ----------
|
|
|
|
|
function buildTransactionRow(tx) {
|
|
|
|
|
const tr = document.createElement('tr');
|
2025-10-20 15:42:12 +02:00
|
|
|
|
2025-12-19 10:11:49 +01:00
|
|
|
// Hash (replaces ID)
|
2025-10-30 11:48:34 +01:00
|
|
|
const tdId = document.createElement('td');
|
|
|
|
|
tdId.className = 'mono';
|
2026-02-13 10:25:14 +04:00
|
|
|
tdId.appendChild(createLink(PAGE.TRANSACTION_DETAIL(tx.hash), shortenHex(tx.hash), tx.hash));
|
2025-10-30 11:48:34 +01:00
|
|
|
|
|
|
|
|
// Operations (preview)
|
|
|
|
|
const tdOps = document.createElement('td');
|
2026-02-13 01:03:47 +04:00
|
|
|
tdOps.style.whiteSpace = 'normal';
|
|
|
|
|
tdOps.style.lineHeight = '1.4';
|
2025-10-30 11:48:34 +01:00
|
|
|
const preview = formatOperationsPreview(tx.operations);
|
2026-02-13 01:03:47 +04:00
|
|
|
const fullPreview = Array.isArray(tx.operations) ? tx.operations.map(opPreview).join(', ') : '';
|
|
|
|
|
tdOps.appendChild(createSpan('', preview, fullPreview));
|
2025-10-20 15:42:12 +02:00
|
|
|
|
2025-10-30 11:48:34 +01:00
|
|
|
// Outputs (count / total)
|
|
|
|
|
const tdOut = document.createElement('td');
|
|
|
|
|
tdOut.className = 'amount';
|
|
|
|
|
tdOut.textContent = `${tx.numberOfOutputs} / ${tx.totalOutputValue.toLocaleString(undefined, { maximumFractionDigits: 8 })}`;
|
2025-10-20 15:42:12 +02:00
|
|
|
|
2026-02-13 01:03:47 +04:00
|
|
|
tr.append(tdId, tdOps, tdOut);
|
2025-10-30 11:48:34 +01:00
|
|
|
return tr;
|
2025-10-20 15:42:12 +02:00
|
|
|
}
|
2025-10-17 14:46:44 +02:00
|
|
|
|
2025-10-30 11:48:34 +01:00
|
|
|
// ---------- component ----------
|
2025-10-17 14:46:44 +02:00
|
|
|
export default function TransactionsTable() {
|
2025-10-30 11:48:34 +01:00
|
|
|
const bodyRef = useRef(null);
|
|
|
|
|
const countRef = useRef(null);
|
|
|
|
|
const abortRef = useRef(null);
|
2025-10-17 15:33:00 +02:00
|
|
|
const totalCountRef = useRef(0);
|
2025-10-17 14:46:44 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-10-30 11:48:34 +01:00
|
|
|
const body = bodyRef.current;
|
|
|
|
|
const counter = countRef.current;
|
|
|
|
|
|
2026-02-13 01:03:47 +04:00
|
|
|
// 3 columns: Hash | Operations | Outputs
|
|
|
|
|
ensureFixedRowCount(body, 3, TABLE_SIZE);
|
2025-10-17 15:33:00 +02:00
|
|
|
|
2025-10-30 11:48:34 +01:00
|
|
|
abortRef.current?.abort();
|
|
|
|
|
abortRef.current = new AbortController();
|
2025-10-17 15:33:00 +02:00
|
|
|
|
2025-10-20 15:42:12 +02:00
|
|
|
const url = `${API.TRANSACTIONS_STREAM}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`;
|
2025-10-17 15:33:00 +02:00
|
|
|
|
2025-10-20 15:42:12 +02:00
|
|
|
streamNdjson(
|
|
|
|
|
url,
|
2025-10-30 11:48:34 +01:00
|
|
|
(raw) => {
|
2025-10-20 15:42:12 +02:00
|
|
|
try {
|
2025-10-30 11:48:34 +01:00
|
|
|
const tx = normalizeTransaction(raw);
|
|
|
|
|
const row = buildTransactionRow(tx);
|
|
|
|
|
body.insertBefore(row, body.firstChild);
|
|
|
|
|
|
|
|
|
|
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);
|
2025-10-20 15:42:12 +02:00
|
|
|
}
|
2025-10-17 15:33:00 +02:00
|
|
|
},
|
|
|
|
|
{
|
2025-10-30 11:48:34 +01:00
|
|
|
signal: abortRef.current.signal,
|
2025-10-17 15:33:00 +02:00
|
|
|
onError: withBenignFilter(
|
2025-10-30 11:48:34 +01:00
|
|
|
(err) => console.error('Transactions stream error:', err),
|
|
|
|
|
abortRef.current.signal,
|
2025-10-20 15:42:12 +02:00
|
|
|
),
|
2025-10-17 15:33:00 +02:00
|
|
|
},
|
2025-10-30 11:48:34 +01:00
|
|
|
).catch((err) => {
|
|
|
|
|
if (!abortRef.current.signal.aborted) {
|
|
|
|
|
console.error('Transactions stream connection error:', err);
|
2025-10-20 15:42:12 +02:00
|
|
|
}
|
2025-10-17 14:46:44 +02:00
|
|
|
});
|
|
|
|
|
|
2025-10-30 11:48:34 +01:00
|
|
|
return () => abortRef.current?.abort();
|
2025-10-17 14:46:44 +02:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return h(
|
|
|
|
|
'div',
|
|
|
|
|
{ class: 'card' },
|
|
|
|
|
h(
|
|
|
|
|
'div',
|
|
|
|
|
{ class: 'card-header' },
|
2025-10-30 11:48:34 +01:00
|
|
|
h('div', null, h('strong', null, 'Transactions '), h('span', { class: 'pill', ref: countRef }, '0')),
|
2025-10-20 15:42:12 +02:00
|
|
|
h('div', { style: 'color:var(--muted); font-size:12px;' }),
|
2025-10-17 14:46:44 +02:00
|
|
|
),
|
|
|
|
|
h(
|
|
|
|
|
'div',
|
|
|
|
|
{ class: 'table-wrapper' },
|
|
|
|
|
h(
|
|
|
|
|
'table',
|
|
|
|
|
{ class: 'table--transactions' },
|
|
|
|
|
h(
|
|
|
|
|
'colgroup',
|
|
|
|
|
null,
|
2025-12-19 10:11:49 +01:00
|
|
|
h('col', { style: 'width:240px' }), // Hash
|
2025-10-20 15:42:12 +02:00
|
|
|
h('col', null), // Operations
|
2025-10-30 11:48:34 +01:00
|
|
|
h('col', { style: 'width:200px' }), // Outputs (count / total)
|
2025-10-17 14:46:44 +02:00
|
|
|
),
|
|
|
|
|
h(
|
|
|
|
|
'thead',
|
|
|
|
|
null,
|
2025-10-20 15:42:12 +02:00
|
|
|
h(
|
|
|
|
|
'tr',
|
|
|
|
|
null,
|
2025-12-19 10:11:49 +01:00
|
|
|
h('th', null, 'Hash'),
|
2025-10-20 15:42:12 +02:00
|
|
|
h('th', null, 'Operations'),
|
|
|
|
|
h('th', null, 'Outputs (count / total)'),
|
|
|
|
|
),
|
2025-10-17 14:46:44 +02:00
|
|
|
),
|
2025-10-30 11:48:34 +01:00
|
|
|
h('tbody', { ref: bodyRef }),
|
2025-10-17 14:46:44 +02:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|