mirror of
https://github.com/logos-blockchain/logos-blockchain-block-explorer-template.git
synced 2026-01-02 05:03:07 +00:00
319 lines
15 KiB
HTML
319 lines
15 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Nomos Block Explorer</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<style>
|
|
:root {
|
|
--bg:#0b0e14; --card:#131722; --fg:#e6edf3; --muted:#9aa4ad;
|
|
--accent:#3fb950; --warn:#ffb86b;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { margin:0; font:14px/1.4 system-ui,sans-serif; background:var(--bg); color:var(--fg); }
|
|
header { display:flex; gap:12px; align-items:center; padding:14px 16px; background:#0e1320; position: sticky; top:0; }
|
|
h1 { font-size:16px; margin:0; }
|
|
.pill { padding:4px 8px; border-radius:999px; background:#1b2133; color:var(--muted); font-size:12px; }
|
|
.pill.online { background: rgba(63,185,80,.15); color: var(--accent); }
|
|
.pill.offline { background: rgba(255,184,107,.15); color: var(--warn); }
|
|
.flash { animation: flash 700ms ease-out; }
|
|
@keyframes flash { from { box-shadow:0 0 0 0 rgba(63,185,80,.9) } to { box-shadow:0 0 0 14px rgba(63,185,80,0) } }
|
|
main { max-width: 1400px; margin: 20px auto; padding: 0 16px 40px; }
|
|
.card { background: var(--card); border: 1px solid #20263a; border-radius: 10px; overflow: hidden; }
|
|
.card-header { display:flex; justify-content:space-between; align-items:center; padding:12px 14px; border-bottom: 1px solid #1f2435; }
|
|
|
|
.table-wrapper { overflow: auto; -webkit-overflow-scrolling: touch; max-height: 60vh; scrollbar-gutter: stable both-edges; padding-right: 8px; }
|
|
table { border-collapse: collapse; table-layout: fixed; width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
.table-wrapper .table--blocks { min-width: 860px; }
|
|
.table-wrapper .table--txs { min-width: 980px; }
|
|
|
|
th, td { text-align:left; padding:8px 10px; border-bottom:1px solid #1f2435; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; vertical-align: top; }
|
|
th { color: var(--muted); font-weight: normal; font-size: 13px; position: sticky; top: 0; background: var(--card); z-index: 1; }
|
|
tbody td { height: 28px; }
|
|
tr:nth-child(odd) { background: #121728; }
|
|
|
|
.twocol { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 16px; }
|
|
@media (max-width: 960px) { .twocol { grid-template-columns: 1fr; } }
|
|
|
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
.amount { font-variant-numeric: tabular-nums; }
|
|
.linkish { color: var(--fg); text-decoration: none; border-bottom: 1px dotted #2a3350; }
|
|
.linkish:hover { border-bottom-color: var(--fg); }
|
|
|
|
/* Placeholder rows for fixed table height */
|
|
tr.ph td { opacity: .35; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Nomos Block Explorer</h1>
|
|
<span id="status-pill" class="pill">Connecting…</span>
|
|
</header>
|
|
|
|
<main>
|
|
<section class="twocol">
|
|
<!-- Blocks -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div><strong>Blocks</strong> <span class="pill" id="blocks-count">0</span></div>
|
|
<div style="color:var(--muted); font-size:12px;">/api/v1/blocks/stream</div>
|
|
</div>
|
|
<div class="table-wrapper">
|
|
<table class="table--blocks">
|
|
<colgroup>
|
|
<col style="width:90px" /> <!-- Slot -->
|
|
<col style="width:260px" /> <!-- Block Root -->
|
|
<col style="width:260px" /> <!-- Parent -->
|
|
<col style="width:80px" /> <!-- Txs -->
|
|
<col style="width:180px" /> <!-- Time -->
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th>Slot</th>
|
|
<th>Block Root</th>
|
|
<th>Parent</th>
|
|
<th>Txs</th>
|
|
<th>Time</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="blocks-body"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Transactions -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div><strong>Transactions</strong> <span class="pill" id="txs-count">0</span></div>
|
|
<div style="color:var(--muted); font-size:12px;">/api/v1/transactions/stream</div>
|
|
</div>
|
|
<div class="table-wrapper">
|
|
<table class="table--txs">
|
|
<colgroup>
|
|
<col style="width:260px" />
|
|
<col />
|
|
<col style="width:120px" />
|
|
<col style="width:180px" />
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th>Hash</th>
|
|
<th>From → To</th>
|
|
<th>Amount</th>
|
|
<th>Time</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="txs-body"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
const API_PREFIX = "/api/v1";
|
|
const HEALTH_ENDPOINT = `${API_PREFIX}/health/stream`;
|
|
const BLOCKS_ENDPOINT = `${API_PREFIX}/blocks/stream`;
|
|
const TXS_ENDPOINT = `${API_PREFIX}/transactions/stream`;
|
|
const TABLE_SIZE = 10;
|
|
|
|
// ------------------ RELOAD/UNLOAD ABORT PATCH (prevents “Error in input stream”) ------------------
|
|
// We keep AbortControllers for each stream and abort them when the page unloads/reloads,
|
|
// so the fetch ReadableStreams don't throw TypeError during navigation.
|
|
let healthController = null;
|
|
let blocksController = null;
|
|
let txsController = null;
|
|
addEventListener("beforeunload", () => {
|
|
healthController?.abort();
|
|
blocksController?.abort();
|
|
txsController?.abort();
|
|
}, { passive: true });
|
|
addEventListener("pagehide", () => {
|
|
healthController?.abort();
|
|
blocksController?.abort();
|
|
txsController?.abort();
|
|
}, { passive: true });
|
|
// --------------------------------------------------------------------------------------------------
|
|
|
|
// ---- Health pill ----
|
|
const pill = document.getElementById("status-pill");
|
|
let state = "connecting";
|
|
function setState(next) {
|
|
if (next === state) return;
|
|
state = next;
|
|
pill.className = "pill";
|
|
if (state === "online") { pill.textContent = "Online"; pill.classList.add("online","flash"); }
|
|
else if (state === "offline") { pill.textContent = "Offline"; pill.classList.add("offline","flash"); }
|
|
else { pill.textContent = "Connecting…"; }
|
|
setTimeout(() => pill.classList.remove("flash"), 750);
|
|
}
|
|
function applyHealth(obj) { if (typeof obj?.healthy === "boolean") setState(obj.healthy ? "online" : "offline"); }
|
|
async function connectHealth() {
|
|
if (healthController) healthController.abort();
|
|
healthController = new AbortController();
|
|
setState("connecting");
|
|
try {
|
|
const res = await fetch(HEALTH_ENDPOINT, { signal: healthController.signal, cache: "no-cache" });
|
|
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
|
const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = "";
|
|
while (true) {
|
|
let chunk;
|
|
try { chunk = await reader.read(); } catch { if (healthController.signal.aborted) return; else break; }
|
|
const { value, done } = chunk; if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
const lines = buf.split("\n"); buf = lines.pop() ?? "";
|
|
for (const line of lines) { if (!line.trim()) continue; try { applyHealth(JSON.parse(line)); } catch {} }
|
|
}
|
|
} catch (e) { if (!healthController?.signal.aborted) setState("offline"); }
|
|
}
|
|
connectHealth();
|
|
|
|
// ---- NDJSON reader (shared) ----
|
|
async function streamNDJSON(url, onItem, { signal } = {}) {
|
|
const res = await fetch(url, { headers: { "accept": "application/x-ndjson" }, signal, cache: "no-cache" });
|
|
if (!res.ok || !res.body) throw new Error(`Stream failed: ${res.status}`);
|
|
const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = "";
|
|
while (true) {
|
|
let chunk;
|
|
try { chunk = await reader.read(); }
|
|
catch { if (signal?.aborted) return; else break; } // quiet on navigation abort
|
|
const { value, done } = chunk; if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
let idx;
|
|
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
const line = buf.slice(0, idx).trim(); buf = buf.slice(idx + 1);
|
|
if (!line) continue;
|
|
try { onItem(JSON.parse(line)); } catch {}
|
|
}
|
|
}
|
|
const last = buf.trim(); if (last) { try { onItem(JSON.parse(last)); } catch {} }
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
function shortHex(s, left = 10, right = 8) {
|
|
if (!s) return "";
|
|
return s.length <= left + right + 1 ? s : `${s.slice(0,left)}…${s.slice(-right)}`;
|
|
}
|
|
function fmtTime(ts) {
|
|
if (ts == null) return "";
|
|
let d = typeof ts === "number" ? new Date(ts < 1e12 ? ts * 1000 : ts) : new Date(ts);
|
|
if (isNaN(d)) return "";
|
|
return d.toLocaleString(undefined, {
|
|
year: "numeric", month: "2-digit", day: "2-digit",
|
|
hour: "2-digit", minute: "2-digit", second: "2-digit"
|
|
});
|
|
}
|
|
|
|
// Keep table exactly TABLE_SIZE rows using placeholders
|
|
function ensureSize(tbody, cols, size) {
|
|
// remove existing placeholders first
|
|
for (let i = tbody.rows.length - 1; i >= 0; i--) {
|
|
if (tbody.rows[i].classList.contains("ph")) tbody.deleteRow(i);
|
|
}
|
|
let real = tbody.rows.length;
|
|
// pad
|
|
for (let i = 0; i < size - real; i++) {
|
|
const tr = document.createElement("tr"); tr.className = "ph";
|
|
for (let c = 0; c < cols; c++) { const td = document.createElement("td"); td.innerHTML = " "; tr.appendChild(td); }
|
|
tbody.appendChild(tr);
|
|
}
|
|
}
|
|
|
|
// ---- Blocks (dedupe by slot:id + fixed-size table) ----
|
|
(function initBlocks() {
|
|
const body = document.getElementById("blocks-body");
|
|
const counter = document.getElementById("blocks-count");
|
|
const seen = new Set(); // keys "slot:id"
|
|
|
|
function pruneAndPad() {
|
|
// remove placeholders
|
|
for (let i = body.rows.length - 1; i >= 0; i--) if (body.rows[i].classList.contains("ph")) body.deleteRow(i);
|
|
// cap real rows and drop keys for removed rows
|
|
while ([...body.rows].filter(r => !r.classList.contains("ph")).length > TABLE_SIZE) {
|
|
const last = body.rows[body.rows.length - 1];
|
|
const key = last?.dataset?.key;
|
|
if (key) seen.delete(key);
|
|
body.deleteRow(-1);
|
|
}
|
|
// pad back to TABLE_SIZE
|
|
const real = [...body.rows].filter(r => !r.classList.contains("ph")).length;
|
|
for (let i = 0; i < TABLE_SIZE - real; i++) {
|
|
const tr = document.createElement("tr"); tr.className = "ph";
|
|
for (let c = 0; c < 5; c++) { const td = document.createElement("td"); td.innerHTML = " "; tr.appendChild(td); }
|
|
body.appendChild(tr);
|
|
}
|
|
counter.textContent = String(real);
|
|
}
|
|
|
|
function appendBlockRow(b, key) {
|
|
const tr = document.createElement("tr");
|
|
tr.dataset.key = key;
|
|
const td = (html) => { const x = document.createElement("td"); x.innerHTML = html; return x; };
|
|
tr.appendChild(td(`<span class="mono">${b.slot}</span>`));
|
|
tr.appendChild(td(`<span class="mono" title="${b.root}">${shortHex(b.root)}</span>`));
|
|
tr.appendChild(td(`<span class="mono" title="${b.parent}">${shortHex(b.parent)}</span>`));
|
|
tr.appendChild(td(`<span class="mono">${b.txCount}</span>`));
|
|
tr.appendChild(td(`<span class="mono" title="${b.time ?? ""}">${fmtTime(b.time)}</span>`));
|
|
body.insertBefore(tr, body.firstChild);
|
|
pruneAndPad();
|
|
}
|
|
|
|
function normalizeBlock(raw) {
|
|
const h = raw.header ?? raw;
|
|
const created = raw.created_at ?? raw.header?.created_at ?? null;
|
|
return {
|
|
id: Number(raw.id ?? 0), // include id for (slot,id) dedupe
|
|
slot: Number(h?.slot ?? raw.slot ?? 0),
|
|
root: h?.block_root ?? raw.block_root ?? "",
|
|
parent: h?.parent_block ?? raw.parent_block ?? "",
|
|
txCount: Array.isArray(raw.transactions) ? raw.transactions.length
|
|
: (typeof raw.transaction_count === "number" ? raw.transaction_count : 0),
|
|
time: created
|
|
};
|
|
}
|
|
|
|
ensureSize(body, 5, TABLE_SIZE);
|
|
|
|
// start stream (with reload-abort controller)
|
|
if (blocksController) blocksController.abort();
|
|
blocksController = new AbortController();
|
|
|
|
streamNDJSON(BLOCKS_ENDPOINT, (raw) => {
|
|
const b = normalizeBlock(raw);
|
|
const key = `${b.slot}:${b.id}`;
|
|
if (seen.has(key)) { pruneAndPad(); return; }
|
|
seen.add(key);
|
|
appendBlockRow(b, key);
|
|
}, { signal: blocksController.signal }).catch(err => {
|
|
if (!blocksController.signal.aborted) console.error("Blocks stream error:", err);
|
|
});
|
|
})();
|
|
|
|
// ---- Transactions (kept simple placeholder; adapt to your API shape) ----
|
|
(function initTxs() {
|
|
const body = document.getElementById("txs-body");
|
|
const counter = document.getElementById("txs-count");
|
|
let n = 0;
|
|
ensureSize(body, 4, TABLE_SIZE);
|
|
|
|
if (txsController) txsController.abort();
|
|
txsController = new AbortController();
|
|
|
|
streamNDJSON(TXS_ENDPOINT, (t) => {
|
|
const tr = document.createElement("tr");
|
|
const td = (html) => { const x = document.createElement("td"); x.innerHTML = html; return x; };
|
|
tr.appendChild(td(`<span class="mono" title="${t.hash ?? ""}">${shortHex(t.hash ?? "")}</span>`));
|
|
tr.appendChild(td(`<span class="mono" title="${t.sender ?? ""}">${shortHex(t.sender ?? "")}</span> → <span class="mono" title="${t.recipient ?? ""}">${shortHex(t.recipient ?? "")}</span>`));
|
|
tr.appendChild(td(`<span class="amount">${(t.amount ?? 0).toLocaleString(undefined, { maximumFractionDigits: 8 })}</span>`));
|
|
tr.appendChild(td(`<span class="mono" title="${t.timestamp ?? ""}">${fmtTime(t.timestamp)}</span>`));
|
|
body.insertBefore(tr, body.firstChild);
|
|
while (body.rows.length > TABLE_SIZE) body.deleteRow(-1);
|
|
counter.textContent = String(++n);
|
|
}, { signal: txsController.signal }).catch(err => {
|
|
if (!txsController.signal.aborted) console.error("Tx stream error:", err);
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|