Migrate to spa.

This commit is contained in:
Alejandro Cabeza Romero 2025-10-17 14:46:44 +02:00
parent 09efa68903
commit bbd57c4217
No known key found for this signature in database
GPG Key ID: DA3D14AE478030FD
25 changed files with 1249 additions and 332 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ sqlite.db
*.ignore*
.env
uv.lock
node_modules

View File

@ -16,3 +16,12 @@ repos:
rev: 6.1.0
hooks:
- id: isort
- repo: local
hooks:
- id: prettier-statics
name: Prettier (Statics)
language: system
entry: hooks/prettier.sh
pass_filenames: true
files: '\.(js|jsx|ts|tsx|css|scss|less|html|json|md|yaml|yml)$'

11
.prettierignore Normal file
View File

@ -0,0 +1,11 @@
# Ignore everything by default
**
# Include static files
!static
!static/**
# Still ignore generated stuff inside static
static/**/dist/**
static/**/build/**
static/**/node_modules/**

14
.prettierrc Normal file
View File

@ -0,0 +1,14 @@
{
"printWidth": 120,
"tabWidth": 4,
"useTabs": false,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"bracketSameLine": false,
"semi": true,
"endOfLine": "lf",
"proseWrap": "preserve"
}

View File

@ -11,7 +11,14 @@
- Fix ordering for Blocks and Transactions
- Fix assumption of 1 block per slot
- Split the single file static into components
- Get transaction by hash
- Get block by hash
# Demo
- Get transaction by id
- Get block by id
- Block viewer
- htm
- Transaction viewer
- When requesting stream, querystring for number of prefetched blocks
- Show transaction

22
hooks/prettier.sh Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
# Collect staged files (add/copy/modify/rename)
mapfile -t STAGED < <(git diff --cached --name-only --diff-filter=ACMR || true)
[ "${#STAGED[@]}" -eq 0 ] && exit 0
echo "🔍 [Prettier] Checking staged files..."
if ! npm run -s format:check -- "${STAGED[@]}"; then
echo
echo "🔧 [Prettier] Fixing staged files..."
npm run -s format -- "${STAGED[@]}"
echo
echo "⚠️ [Prettier] Readd the fixed files to proceed with the commit."
exit 1
fi
echo "✅ [Prettier] All staged files are properly formatted."

28
package-lock.json generated Normal file
View File

@ -0,0 +1,28 @@
{
"name": "nomos-block-explorer",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"prettier": "^3.6.2"
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
}
}
}

9
package.json Normal file
View File

@ -0,0 +1,9 @@
{
"devDependencies": {
"prettier": "^3.6.2"
},
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check ."
}
}

View File

@ -1,11 +1,13 @@
from fastapi import FastAPI
from core.app import NBE
from frontend.statics import mount_statics
from lifespan import lifespan
from router import create_router
def create_app() -> FastAPI:
app = NBE(lifespan=lifespan)
app = mount_statics(app)
app.include_router(create_router())
return app

View File

@ -1 +1,5 @@
from .mount import create_frontend_router
from src import DIR_REPO
STATIC_DIR = DIR_REPO.joinpath("static")
from .router import create_frontend_router

View File

@ -1,21 +0,0 @@
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from starlette.responses import FileResponse, Response
from src import DIR_REPO
STATIC_DIR = DIR_REPO.joinpath("static")
INDEX_FILE = STATIC_DIR.joinpath("index.html")
def index() -> Response:
return FileResponse(INDEX_FILE)
def create_frontend_router() -> APIRouter:
router = APIRouter()
router.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
router.get("/", include_in_schema=False)(index)
return router

16
src/frontend/router.py Normal file
View File

@ -0,0 +1,16 @@
from fastapi import APIRouter
from starlette.responses import FileResponse
from . import STATIC_DIR
INDEX_FILE = STATIC_DIR.joinpath("index.html")
def spa() -> FileResponse:
return FileResponse(INDEX_FILE)
def create_frontend_router() -> APIRouter:
router = APIRouter()
router.get("/", include_in_schema=False)(spa)
return router

13
src/frontend/statics.py Normal file
View File

@ -0,0 +1,13 @@
from starlette.staticfiles import StaticFiles
from core.app import NBE
from frontend import STATIC_DIR
def mount_statics(app: NBE) -> NBE:
"""
This needs to be mounted onto an app.
If mounted directly onto a nested router (`include_router`), it will be ignored.
"""
app.router.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
return app

60
static/app.js Normal file
View File

@ -0,0 +1,60 @@
import { h, render, Fragment } from 'preact';
import Router from './components/Router.js?dev=1';
import HealthPill from './components/HealthPill.js?dev=1';
import HomePage from './pages/Home.js?dev=1';
import BlockDetailPage from './pages/BlockDetail.js?dev=1';
import TransactionDetailPage from './pages/TransactionDetail.js?dev=1';
const ROOT = document.getElementById('app');
function LoadingScreen() {
return h('main', { class: 'wrap' }, h('p', null, 'Loading...'));
}
function AppShell(props) {
return h(
Fragment,
null,
h('header', null, h('h1', null, 'Nomos Block Explorer'), h(HealthPill, null)),
props.children,
);
}
const ROUTES = [
{
name: 'home',
re: /^\/$/,
view: () => h(AppShell, null, h(HomePage, null)),
},
{
name: 'blockDetail',
re: /^\/block\/([^/]+)$/,
view: ({ params }) => h(AppShell, null, h(BlockDetailPage, { params })),
},
{
name: 'transactionDetail',
re: /^\/transaction\/([^/]+)$/,
view: ({ params }) => h(AppShell, null, h(TransactionDetailPage, { params })),
},
];
function AppRouter() {
const wired = ROUTES.map((r) => ({
re: r.re,
view: (match) => r.view({ params: match }),
}));
return h(Router, { routes: wired });
}
try {
if (ROOT) {
render(h(LoadingScreen, null), ROOT);
render(h(AppRouter, null), ROOT);
} else {
console.error('Mount element #app not found.');
}
} catch (err) {
console.error('UI Error', err);
}

View File

@ -0,0 +1,188 @@
import { h } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { BLOCKS_ENDPOINT, TABLE_SIZE } from '../lib/api.js';
import { streamNdjson, ensureFixedRowCount, shortenHex, formatTimestamp } from '../lib/utils.js';
const COLUMN_COUNT = 5;
export default function BlocksTable() {
const tbodyRef = useRef(null);
const counterRef = useRef(null);
const controllerRef = useRef(null);
const seenKeysRef = useRef(new Set());
useEffect(() => {
const tbody = tbodyRef.current;
const counter = counterRef.current;
ensureFixedRowCount(tbody, COLUMN_COUNT, TABLE_SIZE);
controllerRef.current?.abort();
controllerRef.current = new AbortController();
function updateCounter() {
let realRows = 0;
for (const row of tbody.rows) {
if (!row.classList.contains('ph')) realRows++;
}
counter.textContent = String(realRows);
}
function removePlaceholders() {
for (let i = tbody.rows.length - 1; i >= 0; i--) {
if (tbody.rows[i].classList.contains('ph')) tbody.deleteRow(i);
}
}
function trimToTableSize() {
// count real rows
let realRows = 0;
for (const row of tbody.rows) {
if (!row.classList.contains('ph')) realRows++;
}
// drop rows beyond limit, and forget their keys
while (realRows > TABLE_SIZE) {
const last = tbody.rows[tbody.rows.length - 1];
const key = last?.dataset?.key;
if (key) seenKeysRef.current.delete(key);
tbody.deleteRow(-1);
realRows--;
}
}
function makeLink(href, text, title) {
const anchor = document.createElement('a');
anchor.className = 'linkish mono';
anchor.href = href;
if (title) anchor.title = title;
anchor.textContent = text;
return anchor;
}
function appendRow(block, key) {
const row = document.createElement('tr');
row.dataset.key = key;
const slotCell = document.createElement('td');
const slotSpan = document.createElement('span');
slotSpan.className = 'mono';
slotSpan.textContent = String(block.slot);
slotCell.appendChild(slotSpan);
const rootCell = document.createElement('td');
rootCell.appendChild(makeLink(`/block/${block.root}`, shortenHex(block.root), block.root));
const parentCell = document.createElement('td');
parentCell.appendChild(makeLink(`/block/${block.parent}`, shortenHex(block.parent), block.parent));
const countCell = document.createElement('td');
const countSpan = document.createElement('span');
countSpan.className = 'mono';
countSpan.textContent = String(block.transactionCount);
countCell.appendChild(countSpan);
const timeCell = document.createElement('td');
const timeSpan = document.createElement('span');
timeSpan.className = 'mono';
timeSpan.title = block.time ?? '';
timeSpan.textContent = formatTimestamp(block.time);
timeCell.appendChild(timeSpan);
row.append(slotCell, rootCell, parentCell, countCell, timeCell);
tbody.insertBefore(row, tbody.firstChild);
// housekeeping
removePlaceholders();
trimToTableSize();
ensureFixedRowCount(tbody, COLUMN_COUNT, TABLE_SIZE);
updateCounter();
}
function normalizeBlock(raw) {
const header = raw.header ?? raw;
const createdAt = raw.created_at ?? raw.header?.created_at ?? null;
return {
id: Number(raw.id ?? 0),
slot: Number(header?.slot ?? raw.slot ?? 0),
root: header?.block_root ?? raw.block_root ?? '',
parent: header?.parent_block ?? raw.parent_block ?? '',
transactionCount: Array.isArray(raw.transactions)
? raw.transactions.length
: typeof raw.transaction_count === 'number'
? raw.transaction_count
: 0,
time: createdAt,
};
}
streamNdjson(
BLOCKS_ENDPOINT,
(raw) => {
const block = normalizeBlock(raw);
const key = `${block.slot}:${block.id}`;
if (seenKeysRef.current.has(key)) {
// still keep placeholders consistent and counter fresh
removePlaceholders();
trimToTableSize();
ensureFixedRowCount(tbody, COLUMN_COUNT, TABLE_SIZE);
updateCounter();
return;
}
seenKeysRef.current.add(key);
appendRow(block, key);
},
{
signal: controllerRef.current.signal,
onError: (err) => {
if (!controllerRef.current.signal.aborted) {
console.error('Blocks stream error:', err);
}
},
},
);
return () => controllerRef.current?.abort();
}, []);
return h(
'div',
{ class: 'card' },
h(
'div',
{ class: 'card-header' },
h('div', null, h('strong', null, 'Blocks '), h('span', { class: 'pill', ref: counterRef }, '0')),
h('div', { style: 'color:var(--muted); font-size:12px;' }, BLOCKS_ENDPOINT),
),
h(
'div',
{ class: 'table-wrapper' },
h(
'table',
{ class: 'table--blocks' },
h(
'colgroup',
null,
h('col', { style: 'width:90px' }),
h('col', { style: 'width:260px' }),
h('col', { style: 'width:260px' }),
h('col', { style: 'width:120px' }),
h('col', { style: 'width:180px' }),
),
h(
'thead',
null,
h(
'tr',
null,
h('th', null, 'Slot'),
h('th', null, 'Block Root'),
h('th', null, 'Parent'),
h('th', null, 'Transactions'),
h('th', null, 'Time'),
),
),
h('tbody', { ref: tbodyRef }),
),
),
);
}

View File

@ -0,0 +1,57 @@
import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { HEALTH_ENDPOINT } from '../lib/api.js';
import { streamNdjson } from '../lib/utils.js';
const STATUS = {
CONNECTING: 'connecting',
ONLINE: 'online',
OFFLINE: 'offline',
};
export default function HealthPill() {
const [status, setStatus] = useState(STATUS.CONNECTING);
const pillRef = useRef(null);
const controllerRef = useRef(null);
// Flash animation whenever status changes
useEffect(() => {
const el = pillRef.current;
if (!el) return;
el.classList.add('flash');
const id = setTimeout(() => el.classList.remove('flash'), 750);
return () => clearTimeout(id);
}, [status]);
useEffect(() => {
controllerRef.current?.abort();
controllerRef.current = new AbortController();
streamNdjson(
HEALTH_ENDPOINT,
(item) => {
if (typeof item?.healthy === 'boolean') {
setStatus(item.healthy ? STATUS.ONLINE : STATUS.OFFLINE);
}
},
{
signal: controllerRef.current.signal,
onStart: () => setStatus(STATUS.CONNECTING),
onError: (err) => {
if (!controllerRef.current.signal.aborted) {
console.error('Health stream error:', err);
setStatus(STATUS.OFFLINE);
}
},
},
);
return () => controllerRef.current?.abort();
}, []);
const className = 'pill ' + (status === STATUS.ONLINE ? 'online' : status === STATUS.OFFLINE ? 'offline' : '');
const label = status === STATUS.ONLINE ? 'Online' : status === STATUS.OFFLINE ? 'Offline' : 'Connecting…';
return h('span', { ref: pillRef, class: className }, label);
}

View File

@ -0,0 +1,64 @@
import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
export default function AppRouter({ routes }) {
const [match, setMatch] = useState(() => resolveRoute(location.pathname, routes));
useEffect(() => {
const handlePopState = () => setMatch(resolveRoute(location.pathname, routes));
const handleLinkClick = (event) => {
// Only intercept unmodified left-clicks
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = event.target.closest?.('a[href]');
if (!anchor) return;
// Respect explicit navigation hints
if (anchor.target && anchor.target !== '_self') return;
if (anchor.hasAttribute('download')) return;
if (anchor.getAttribute('rel')?.includes('external')) return;
if (anchor.dataset.external === 'true' || anchor.dataset.noRouter === 'true') return;
const href = anchor.getAttribute('href');
if (!href) return;
// Allow in-page, mailto, and other schemes to pass through
if (href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return;
// Different origin → let the browser handle it
if (anchor.origin !== location.origin) return;
// Likely a static asset (e.g., ".css", ".png") → let it pass
if (/\.[a-z0-9]+($|\?)/i.test(href)) return;
event.preventDefault();
history.pushState({}, '', href);
setMatch(resolveRoute(location.pathname, routes));
};
window.addEventListener('popstate', handlePopState);
document.addEventListener('click', handleLinkClick);
return () => {
window.removeEventListener('popstate', handlePopState);
document.removeEventListener('click', handleLinkClick);
};
}, [routes]);
const View = match?.view ?? NotFound;
return h(View, { params: match?.params ?? [] });
}
function resolveRoute(pathname, routes) {
for (const route of routes) {
const result = pathname.match(route.pattern);
if (result) return { view: route.view, params: result.slice(1) };
}
return null;
}
function NotFound() {
return h('main', { class: 'wrap' }, h('h1', null, 'Not found'));
}

View File

@ -0,0 +1,143 @@
import { h } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { TRANSACTIONS_ENDPOINT, TABLE_SIZE } from '../lib/api.js?dev=1';
import { streamNdjson, ensureFixedRowCount, shortenHex, formatTimestamp } from '../lib/utils.js?dev=1';
export default function TransactionsTable() {
const tableBodyRef = useRef(null);
const totalCountPillRef = useRef(null);
const abortControllerRef = useRef(null);
const totalStreamedCountRef = useRef(0);
useEffect(() => {
const tableBody = tableBodyRef.current;
const totalCountPill = totalCountPillRef.current;
const PLACEHOLDER_CLASS = 'ph';
ensureFixedRowCount(tableBody, 4, TABLE_SIZE);
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
const createSpan = (className, textContent, title) => {
const element = document.createElement('span');
if (className) element.className = className;
if (title) element.title = title;
element.textContent = textContent;
return element;
};
const createLink = (href, textContent, title) => {
const element = document.createElement('a');
element.className = 'linkish mono';
element.href = href;
if (title) element.title = title;
element.textContent = textContent;
return element;
};
const countNonPlaceholderRows = () =>
[...tableBody.rows].filter((row) => !row.classList.contains(PLACEHOLDER_CLASS)).length;
const appendTransactionRow = (transaction) => {
// Trim one placeholder from the end to keep height stable
const lastRow = tableBody.rows[tableBody.rows.length - 1];
if (lastRow?.classList.contains(PLACEHOLDER_CLASS)) tableBody.deleteRow(-1);
const row = document.createElement('tr');
const cellHash = document.createElement('td');
cellHash.appendChild(
createLink(
`/transaction/${transaction.hash ?? ''}`,
shortenHex(transaction.hash ?? ''),
transaction.hash ?? '',
),
);
const cellSenderRecipient = document.createElement('td');
cellSenderRecipient.appendChild(
createSpan('mono', shortenHex(transaction.sender ?? ''), transaction.sender ?? ''),
);
cellSenderRecipient.appendChild(document.createTextNode(' \u2192 '));
cellSenderRecipient.appendChild(
createSpan('mono', shortenHex(transaction.recipient ?? ''), transaction.recipient ?? ''),
);
const cellAmount = document.createElement('td');
cellAmount.className = 'amount';
const amount = Number(transaction.amount ?? 0);
cellAmount.textContent = Number.isFinite(amount)
? amount.toLocaleString(undefined, { maximumFractionDigits: 8 })
: '—';
const cellTime = document.createElement('td');
cellTime.appendChild(
createSpan('mono', formatTimestamp(transaction.timestamp), transaction.timestamp ?? ''),
);
row.append(cellHash, cellSenderRecipient, cellAmount, cellTime);
tableBody.insertBefore(row, tableBody.firstChild);
// Trim to TABLE_SIZE (counting only non-placeholder rows)
while (countNonPlaceholderRows() > TABLE_SIZE) tableBody.deleteRow(-1);
totalCountPill.textContent = String(++totalStreamedCountRef.current);
};
streamNdjson(TRANSACTIONS_ENDPOINT, (transaction) => appendTransactionRow(transaction), {
signal: abortControllerRef.current.signal,
}).catch((error) => {
if (!abortControllerRef.current.signal.aborted) {
console.error('Transactions stream error:', error);
}
});
return () => abortControllerRef.current?.abort();
}, []);
return h(
'div',
{ class: 'card' },
h(
'div',
{ class: 'card-header' },
h(
'div',
null,
h('strong', null, 'Transactions '),
h('span', { class: 'pill', ref: totalCountPillRef }, '0'),
),
h('div', { style: 'color:var(--muted); font-size:12px;' }, '/api/v1/transactions/stream'),
),
h(
'div',
{ class: 'table-wrapper' },
h(
'table',
{ class: 'table--transactions' },
h(
'colgroup',
null,
h('col', { style: 'width:260px' }),
h('col', null),
h('col', { style: 'width:120px' }),
h('col', { style: 'width:180px' }),
),
h(
'thead',
null,
h(
'tr',
null,
h('th', null, 'Hash'),
h('th', null, 'From → To'),
h('th', null, 'Amount'),
h('th', null, 'Time'),
),
),
h('tbody', { ref: tableBodyRef }),
),
),
);
}

View File

@ -1,318 +1,35 @@
<!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; }
<head>
<title>Nomos Block Explorer</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Lightweight Nomos block Explorer UI" />
.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; }
<!-- Styles -->
<link rel="stylesheet" href="/static/styles.css" />
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; }
<!-- Speed up first connection to ESM CDN -->
<link rel="dns-prefetch" href="https://esm.sh" />
<link rel="preconnect" href="https://esm.sh" crossorigin />
.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 {} }
<!-- Import map must appear BEFORE any modulepreload that relies on it -->
<script type="importmap">
{
"imports": {
"preact": "https://esm.sh/preact@10.22.0",
"preact/hooks": "https://esm.sh/preact@10.22.0/hooks"
}
}
} catch (e) { if (!healthController?.signal.aborted) setState("offline"); }
}
connectHealth();
</script>
// ---- 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 = "&nbsp;"; 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 = "&nbsp;"; 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>
<!-- Safe to preload after the import map -->
<link rel="modulepreload" href="/static/app.js" />
<link rel="modulepreload" href="https://esm.sh/preact@10.22.0" crossorigin />
<link rel="modulepreload" href="https://esm.sh/preact@10.22.0/hooks" crossorigin />
</head>
<body>
<div id="app"></div>
<script type="module" src="/static/app.js"></script>
</body>
</html>

13
static/lib/api.js Normal file
View File

@ -0,0 +1,13 @@
export const API_PREFIX = '/api/v1';
const joinUrl = (...parts) => parts.join('/').replace(/\/{2,}/g, '/');
const encodeId = (id) => encodeURIComponent(String(id));
export const HEALTH_ENDPOINT = joinUrl(API_PREFIX, 'health/stream');
export const BLOCKS_ENDPOINT = joinUrl(API_PREFIX, 'blocks/stream');
export const TRANSACTIONS_ENDPOINT = joinUrl(API_PREFIX, 'transactions/stream');
export const TABLE_SIZE = 10;
export const BLOCK_DETAIL = (id) => joinUrl(API_PREFIX, 'blocks', encodeId(id));
export const TRANSACTION_DETAIL = (id) => joinUrl(API_PREFIX, 'transactions', encodeId(id));

96
static/lib/utils.js Normal file
View File

@ -0,0 +1,96 @@
export async function streamNdjson(url, handleItem, { signal, onError = () => {} } = {}) {
const response = await fetch(url, {
headers: { accept: 'application/x-ndjson' },
signal,
cache: 'no-cache',
});
if (!response.ok || !response.body) {
throw new Error(`Stream failed: ${response.status}`);
}
const responseBodyReader = response.body.getReader();
const textDecoder = new TextDecoder();
let buffer = '';
while (true) {
let chunk;
try {
chunk = await responseBodyReader.read();
} catch (error) {
if (signal?.aborted) return;
onError(error);
break;
}
const { value, done } = chunk;
if (done) break;
buffer += textDecoder.decode(value, { stream: true });
let newlineIndex;
while ((newlineIndex = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line) continue;
try {
handleItem(JSON.parse(line));
} catch (error) {
onError(error);
}
}
}
const trailing = buffer.trim();
if (trailing) {
try {
handleItem(JSON.parse(trailing));
} catch (error) {
onError(error);
}
}
}
export const shortenHex = (hexString, left = 10, right = 8) => {
if (!hexString) return '';
return hexString.length <= left + right + 1 ? hexString : `${hexString.slice(0, left)}${hexString.slice(-right)}`;
};
export function formatTimestamp(timestamp) {
if (timestamp == null) return '';
const date =
typeof timestamp === 'number' ? new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp) : new Date(timestamp);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
export function ensureFixedRowCount(tableBody, columnCount, targetRowCount) {
// remove existing placeholder rows
for (let i = tableBody.rows.length - 1; i >= 0; i--) {
if (tableBody.rows[i].classList.contains('ph')) tableBody.deleteRow(i);
}
// count non-placeholder rows
let realRowCount = 0;
for (const row of tableBody.rows) {
if (!row.classList.contains('ph')) realRowCount++;
}
// append placeholders to reach target count
for (let i = 0; i < targetRowCount - realRowCount; i++) {
const tr = document.createElement('tr');
tr.className = 'ph';
for (let c = 0; c < columnCount; c++) {
const td = document.createElement('td');
td.innerHTML = '&nbsp;';
tr.appendChild(td);
}
tableBody.appendChild(tr);
}
}

182
static/pages/BlockDetail.js Normal file
View File

@ -0,0 +1,182 @@
import { h, Fragment } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { shortenHex, formatTimestamp } from '../lib/utils.js?dev=1';
import { BLOCK_DETAIL } from '../lib/api.js?dev=1';
export default function BlockDetail({ params: routeParams }) {
const blockId = routeParams[0];
const [block, setBlock] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const res = await fetch(BLOCK_DETAIL(blockId), {
signal: controller.signal,
cache: 'no-cache',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setBlock(data);
} catch (err) {
if (!controller.signal.aborted) setError(err.message || 'Request failed');
}
})();
return () => controller.abort();
}, [blockId]);
const header = block?.header ?? {};
const transactions = block?.transactions ?? [];
return h(
'main',
{ class: 'wrap' },
h(
'header',
{ style: 'display:flex; gap:12px; align-items:center; margin:12px 0;' },
h('a', { class: 'linkish', href: '/' }, '← Back'),
h('h1', { style: 'margin:0' }, `Block ${shortenHex(blockId, 12, 12)}`),
),
error && h('p', { style: 'color:#ff8a8a' }, `Error: ${error}`),
!block && !error && h('p', null, 'Loading…'),
block &&
h(
Fragment,
null,
// Header card
h(
'div',
{ class: 'card', style: 'margin-top:12px;' },
h('div', { class: 'card-header' }, h('strong', null, 'Header')),
h(
'div',
{ style: 'padding:12px 14px' },
h('div', null, h('b', null, 'Slot: '), h('span', { class: 'mono' }, header.slot ?? '')),
h(
'div',
null,
h('b', null, 'Root: '),
h(
'span',
{ class: 'mono', title: header.block_root ?? '' },
shortenHex(header.block_root ?? ''),
),
),
h(
'div',
null,
h('b', null, 'Parent: '),
h(
'a',
{
class: 'linkish mono',
href: `/block/${header.parent_block ?? ''}`,
title: header.parent_block ?? '',
},
shortenHex(header.parent_block ?? ''),
),
),
h(
'div',
null,
h('b', null, 'Created: '),
h('span', { class: 'mono' }, formatTimestamp(block.created_at)),
),
),
),
// Transactions card
h(
'div',
{ class: 'card', style: 'margin-top:16px;' },
h(
'div',
{ class: 'card-header' },
h('strong', null, 'Transactions '),
h('span', { class: 'pill' }, String(transactions.length)),
),
h(
'div',
{ class: 'table-wrapper' },
h(
'table',
{ class: 'table--transactions' },
h(
'colgroup',
null,
h('col', { style: 'width:260px' }),
h('col', null),
h('col', { style: 'width:120px' }),
h('col', { style: 'width:180px' }),
),
h(
'thead',
null,
h(
'tr',
null,
h('th', null, 'Hash'),
h('th', null, 'From → To'),
h('th', null, 'Amount'),
h('th', null, 'Time'),
),
),
h(
'tbody',
null,
...transactions.map((tx) =>
h(
'tr',
null,
h(
'td',
null,
h(
'a',
{
class: 'linkish mono',
href: `/transaction/${tx.hash}`,
title: tx.hash,
},
shortenHex(tx.hash),
),
),
h(
'td',
null,
h(
'span',
{ class: 'mono', title: tx.sender ?? '' },
shortenHex(tx.sender ?? ''),
),
' \u2192 ',
h(
'span',
{ class: 'mono', title: tx.recipient ?? '' },
shortenHex(tx.recipient ?? ''),
),
),
h(
'td',
{ class: 'amount' },
Number(tx.amount ?? 0).toLocaleString(undefined, {
maximumFractionDigits: 8,
}),
),
h('td', { class: 'mono' }, formatTimestamp(tx.timestamp)),
),
),
),
),
),
),
),
);
}

11
static/pages/Home.js Normal file
View File

@ -0,0 +1,11 @@
import { h } from 'preact';
import BlocksTable from '../components/BlocksTable.js';
import TransactionsTable from '../components/TransactionsTable.js';
export default function HomeView() {
return h(
'main',
{ class: 'wrap' },
h('section', { class: 'two-columns twocol' }, h(BlocksTable, {}), h(TransactionsTable, {})),
);
}

View File

@ -0,0 +1,118 @@
import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { shortenHex, formatTimestamp } from '../lib/utils.js?dev=1';
import { TRANSACTION_DETAIL } from '../lib/api.js?dev=1';
export default function TransactionDetail({ params: routeParams }) {
const transactionId = routeParams[0];
const [transaction, setTransaction] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const response = await fetch(TRANSACTION_DETAIL(transactionId), {
signal: controller.signal,
cache: 'no-cache',
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
setTransaction(data);
} catch (err) {
if (!controller.signal.aborted) {
setError(err.message || 'Request failed');
}
}
})();
return () => controller.abort();
}, [transactionId]);
return h(
'main',
{ class: 'wrap' },
h(
'header',
{ style: 'display:flex; gap:12px; align-items:center; margin:12px 0;' },
h('a', { class: 'linkish', href: '/' }, '← Back'),
h('h1', { style: 'margin:0' }, `Transaction ${shortenHex(transactionId, 12, 12)}`),
),
error && h('p', { style: 'color:#ff8a8a' }, `Error: ${error}`),
!transaction && !error && h('p', null, 'Loading…'),
transaction &&
h(
'div',
{ class: 'card', style: 'margin-top:12px;' },
h('div', { class: 'card-header' }, h('strong', null, 'Overview')),
h(
'div',
{ style: 'padding:12px 14px' },
h(
'div',
null,
h('b', null, 'Hash: '),
h('span', { class: 'mono', title: transaction.hash }, shortenHex(transaction.hash)),
),
h(
'div',
null,
h('b', null, 'From: '),
h(
'span',
{ class: 'mono', title: transaction.sender ?? '' },
shortenHex(transaction.sender ?? ''),
),
),
h(
'div',
null,
h('b', null, 'To: '),
h(
'span',
{ class: 'mono', title: transaction.recipient ?? '' },
shortenHex(transaction.recipient ?? ''),
),
),
h(
'div',
null,
h('b', null, 'Amount: '),
h(
'span',
{ class: 'amount' },
Number(transaction.amount ?? 0).toLocaleString(undefined, {
maximumFractionDigits: 8,
}),
),
),
h(
'div',
null,
h('b', null, 'Time: '),
h('span', { class: 'mono' }, formatTimestamp(transaction.timestamp)),
),
transaction.block_root &&
h(
'div',
null,
h('b', null, 'Block: '),
h(
'a',
{
class: 'linkish mono',
href: `/block/${transaction.block_root}`,
title: transaction.block_root,
},
shortenHex(transaction.block_root),
),
),
),
),
);
}

153
static/styles.css Normal file
View File

@ -0,0 +1,153 @@
: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, 0.15);
color: var(--accent);
}
.pill.offline {
background: rgba(255, 184, 107, 0.15);
color: var(--warn);
}
.flash {
animation: flash 700ms ease-out;
}
@keyframes flash {
from {
box-shadow: 0 0 0 0 rgba(63, 185, 80, 0.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--transactions {
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);
}
tr.ph td {
opacity: 0.35;
}