diff --git a/.gitignore b/.gitignore
index 8502b42..ee6ddc9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ sqlite.db
*.ignore*
.env
uv.lock
+node_modules
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 730979f..f6ddb1d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -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)$'
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..f848e8b
--- /dev/null
+++ b/.prettierignore
@@ -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/**
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..a44ca06
--- /dev/null
+++ b/.prettierrc
@@ -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"
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 626f19b..6ea5af9 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/hooks/prettier.sh b/hooks/prettier.sh
new file mode 100755
index 0000000..e59afee
--- /dev/null
+++ b/hooks/prettier.sh
@@ -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."
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..dc01035
--- /dev/null
+++ b/package-lock.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..87a93e6
--- /dev/null
+++ b/package.json
@@ -0,0 +1,9 @@
+{
+ "devDependencies": {
+ "prettier": "^3.6.2"
+ },
+ "scripts": {
+ "format": "prettier --write .",
+ "format:check": "prettier --check ."
+ }
+}
diff --git a/src/app.py b/src/app.py
index 91e2ec4..15107e5 100644
--- a/src/app.py
+++ b/src/app.py
@@ -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
diff --git a/src/frontend/__init__.py b/src/frontend/__init__.py
index f10a8a1..b3ecee2 100644
--- a/src/frontend/__init__.py
+++ b/src/frontend/__init__.py
@@ -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
diff --git a/src/frontend/mount.py b/src/frontend/mount.py
deleted file mode 100644
index 19689be..0000000
--- a/src/frontend/mount.py
+++ /dev/null
@@ -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
diff --git a/src/frontend/router.py b/src/frontend/router.py
new file mode 100644
index 0000000..da1efc1
--- /dev/null
+++ b/src/frontend/router.py
@@ -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
diff --git a/src/frontend/statics.py b/src/frontend/statics.py
new file mode 100644
index 0000000..6971bc3
--- /dev/null
+++ b/src/frontend/statics.py
@@ -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
diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..a30fda4
--- /dev/null
+++ b/static/app.js
@@ -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);
+}
diff --git a/static/components/BlocksTable.js b/static/components/BlocksTable.js
new file mode 100644
index 0000000..f0306a2
--- /dev/null
+++ b/static/components/BlocksTable.js
@@ -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 }),
+ ),
+ ),
+ );
+}
diff --git a/static/components/HealthPill.js b/static/components/HealthPill.js
new file mode 100644
index 0000000..8d94d5a
--- /dev/null
+++ b/static/components/HealthPill.js
@@ -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);
+}
diff --git a/static/components/Router.js b/static/components/Router.js
new file mode 100644
index 0000000..cc4faa8
--- /dev/null
+++ b/static/components/Router.js
@@ -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'));
+}
diff --git a/static/components/TransactionsTable.js b/static/components/TransactionsTable.js
new file mode 100644
index 0000000..f038f37
--- /dev/null
+++ b/static/components/TransactionsTable.js
@@ -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 }),
+ ),
+ ),
+ );
+}
diff --git a/static/index.html b/static/index.html
index f618dad..26f6a0d 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1,318 +1,35 @@
-
-
- Nomos Block Explorer
-
-
-
-
-
- Nomos Block Explorer
- Connectingā¦
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | Slot |
- Block Root |
- Parent |
- Txs |
- Time |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | Hash |
- From ā To |
- Amount |
- Time |
-
-
-
-
-
-
-
-
-
-
- // ---- 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(`${b.slot}`));
- tr.appendChild(td(`${shortHex(b.root)}`));
- tr.appendChild(td(`${shortHex(b.parent)}`));
- tr.appendChild(td(`${b.txCount}`));
- tr.appendChild(td(`${fmtTime(b.time)}`));
- 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(`${shortHex(t.hash ?? "")}`));
- tr.appendChild(td(`${shortHex(t.sender ?? "")} ā ${shortHex(t.recipient ?? "")}`));
- tr.appendChild(td(`${(t.amount ?? 0).toLocaleString(undefined, { maximumFractionDigits: 8 })}`));
- tr.appendChild(td(`${fmtTime(t.timestamp)}`));
- 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);
- });
- })();
-
-
+
+
+
+
+
+
+
+
+