From bbd57c42175c2de03e6245f43ba050a7d98aad00 Mon Sep 17 00:00:00 2001 From: Alejandro Cabeza Romero Date: Fri, 17 Oct 2025 14:46:44 +0200 Subject: [PATCH] Migrate to spa. --- .gitignore | 1 + .pre-commit-config.yaml | 9 + .prettierignore | 11 + .prettierrc | 14 + README.md | 7 + hooks/prettier.sh | 22 ++ package-lock.json | 28 ++ package.json | 9 + src/app.py | 2 + src/frontend/__init__.py | 6 +- src/frontend/mount.py | 21 -- src/frontend/router.py | 16 ++ src/frontend/statics.py | 13 + static/app.js | 60 +++++ static/components/BlocksTable.js | 188 ++++++++++++++ static/components/HealthPill.js | 57 +++++ static/components/Router.js | 64 +++++ static/components/TransactionsTable.js | 143 +++++++++++ static/index.html | 337 ++----------------------- static/lib/api.js | 13 + static/lib/utils.js | 96 +++++++ static/pages/BlockDetail.js | 182 +++++++++++++ static/pages/Home.js | 11 + static/pages/TransactionDetail.js | 118 +++++++++ static/styles.css | 153 +++++++++++ 25 files changed, 1249 insertions(+), 332 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100755 hooks/prettier.sh create mode 100644 package-lock.json create mode 100644 package.json delete mode 100644 src/frontend/mount.py create mode 100644 src/frontend/router.py create mode 100644 src/frontend/statics.py create mode 100644 static/app.js create mode 100644 static/components/BlocksTable.js create mode 100644 static/components/HealthPill.js create mode 100644 static/components/Router.js create mode 100644 static/components/TransactionsTable.js create mode 100644 static/lib/api.js create mode 100644 static/lib/utils.js create mode 100644 static/pages/BlockDetail.js create mode 100644 static/pages/Home.js create mode 100644 static/pages/TransactionDetail.js create mode 100644 static/styles.css 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… -
- -
-
- -
-
-
Blocks 0
-
/api/v1/blocks/stream
-
-
- - - - - - - - - - - - - - - - - - -
SlotBlock RootParentTxsTime
-
-
- - -
-
-
Transactions 0
-
/api/v1/transactions/stream
-
-
- - - - - - - - - - - - - - - - -
HashFrom → ToAmountTime
-
-
-
-
- - - // ---- 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); - }); - })(); - - + + + + + + +
+ + diff --git a/static/lib/api.js b/static/lib/api.js new file mode 100644 index 0000000..a6464b8 --- /dev/null +++ b/static/lib/api.js @@ -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)); diff --git a/static/lib/utils.js b/static/lib/utils.js new file mode 100644 index 0000000..145b080 --- /dev/null +++ b/static/lib/utils.js @@ -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 = ' '; + tr.appendChild(td); + } + tableBody.appendChild(tr); + } +} diff --git a/static/pages/BlockDetail.js b/static/pages/BlockDetail.js new file mode 100644 index 0000000..3766fff --- /dev/null +++ b/static/pages/BlockDetail.js @@ -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)), + ), + ), + ), + ), + ), + ), + ), + ); +} diff --git a/static/pages/Home.js b/static/pages/Home.js new file mode 100644 index 0000000..777011e --- /dev/null +++ b/static/pages/Home.js @@ -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, {})), + ); +} diff --git a/static/pages/TransactionDetail.js b/static/pages/TransactionDetail.js new file mode 100644 index 0000000..9bd2f22 --- /dev/null +++ b/static/pages/TransactionDetail.js @@ -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), + ), + ), + ), + ), + ); +} diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..84c2c5e --- /dev/null +++ b/static/styles.css @@ -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; +}