2025-10-03 22:27:30 +02:00

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 = "&nbsp;";
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>