mirror of
https://github.com/logos-blockchain/logos-blockchain-block-explorer-template.git
synced 2026-01-02 13:13:10 +00:00
259 lines
11 KiB
HTML
259 lines
11 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; }
|
|
|
|
/* SCROLLER */
|
|
.table-wrapper {
|
|
overflow-y: auto;
|
|
overflow-x: auto; /* horizontal scrolling back */
|
|
-webkit-overflow-scrolling: touch;
|
|
max-height: 60vh;
|
|
scrollbar-gutter: stable both-edges;
|
|
padding-right: 8px;
|
|
}
|
|
|
|
table {
|
|
border-collapse: collapse;
|
|
table-layout: fixed;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
width: 100%;
|
|
}
|
|
/* Force tables to be wider than the container when needed so horizontal scroll appears */
|
|
.table-wrapper .table--blocks { min-width: 560px; }
|
|
.table-wrapper .table--txs { min-width: 980px; } /* Hash + From→To + Amount + Time */
|
|
|
|
th, td {
|
|
text-align:left;
|
|
padding:8px 10px;
|
|
border-bottom:1px solid #1f2435;
|
|
vertical-align: top;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
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; } }
|
|
|
|
.table--txs th:last-child, .table--txs td:last-child { padding-right: 16px; }
|
|
|
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
|
.amount { font-variant-numeric: tabular-nums; }
|
|
</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:100px" />
|
|
<col style="width:260px" />
|
|
<col style="width:80px" />
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th>Slot</th>
|
|
<th>Hash</th>
|
|
<th>Txs</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 /> <!-- From → To -->
|
|
<col style="width:120px" />
|
|
<col style="width:180px" /> <!-- Time -->
|
|
</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;
|
|
|
|
// ---- Health pill ----
|
|
const pill = document.getElementById("status-pill");
|
|
let healthController = null;
|
|
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) {
|
|
const { value, done } = await reader.read(); 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 ----
|
|
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) {
|
|
const { value, done } = await reader.read(); 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 {} }
|
|
}
|
|
|
|
// ---- Table helpers ----
|
|
function ensureSize(tbody, colCount) {
|
|
while (tbody.rows.length < TABLE_SIZE) {
|
|
const row = document.createElement("tr");
|
|
for (let i = 0; i < colCount; i++) {
|
|
const td = document.createElement("td");
|
|
td.innerHTML = " ";
|
|
row.appendChild(td);
|
|
}
|
|
tbody.appendChild(row);
|
|
}
|
|
}
|
|
function appendRow(tbody, cells) {
|
|
const tr = document.createElement("tr");
|
|
for (const cell of cells) {
|
|
const td = document.createElement("td");
|
|
if (cell && cell.html !== undefined) td.innerHTML = cell.html;
|
|
else td.textContent = String(cell ?? "");
|
|
if (cell && cell.class) td.className = cell.class;
|
|
tr.appendChild(td);
|
|
}
|
|
tbody.insertBefore(tr, tbody.firstChild);
|
|
while (tbody.rows.length > TABLE_SIZE) tbody.deleteRow(-1);
|
|
}
|
|
function shortHex(s, left = 6, right = 4) { if (!s) return ""; return s.length <= left + right + 2 ? s : `${s.slice(0,left)}…${s.slice(-right)}`; }
|
|
|
|
// Flexible timestamp formatter
|
|
function fmtTime(ts) {
|
|
if (ts == null) return "";
|
|
let d;
|
|
if (typeof ts === "number") d = new Date(ts < 1e12 ? ts * 1000 : ts);
|
|
else d = 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"
|
|
});
|
|
}
|
|
|
|
// ---- Blocks ----
|
|
(function initBlocks() {
|
|
const body = document.getElementById("blocks-body");
|
|
const counter = document.getElementById("blocks-count");
|
|
let n = 0;
|
|
ensureSize(body, 3);
|
|
streamNDJSON(BLOCKS_ENDPOINT, (b) => {
|
|
appendRow(body, [
|
|
{ html: `<span class="mono">${b.slot}</span>` },
|
|
{ html: `<span class="mono" title="${b.hash}">${shortHex(b.hash, 10, 8)}</span>` },
|
|
b.transaction_count,
|
|
]);
|
|
counter.textContent = (++n).toString();
|
|
}).catch(err => console.error("Blocks stream error:", err));
|
|
})();
|
|
|
|
// ---- Transactions ----
|
|
(function initTxs() {
|
|
const body = document.getElementById("txs-body");
|
|
const counter = document.getElementById("txs-count");
|
|
let n = 0;
|
|
ensureSize(body, 4); // 4 columns now
|
|
streamNDJSON(TXS_ENDPOINT, (t) => {
|
|
appendRow(body, [
|
|
{ html: `<span class="mono" title="${t.hash}">${shortHex(t.hash, 10, 8)}</span>` },
|
|
{ html: `<span class="mono" title="${t.sender}">${shortHex(t.sender)}</span> → <span class="mono" title="${t.recipient}">${shortHex(t.recipient)}</span>` },
|
|
{ html: `<span class="amount">${Number(t.amount).toLocaleString(undefined, { maximumFractionDigits: 8 })}</span>` },
|
|
{ html: `<span class="mono" title="${t.timestamp}">${fmtTime(t.timestamp)}</span>` },
|
|
]);
|
|
counter.textContent = (++n).toString();
|
|
}).catch(err => console.error("Tx stream error:", err));
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|