mirror of
https://github.com/logos-blockchain/logos-blockchain-block-explorer-template.git
synced 2026-01-02 05:03:07 +00:00
Migrate to spa.
This commit is contained in:
parent
09efa68903
commit
bbd57c4217
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,3 +5,4 @@ sqlite.db
|
||||
*.ignore*
|
||||
.env
|
||||
uv.lock
|
||||
node_modules
|
||||
|
||||
@ -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
11
.prettierignore
Normal 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
14
.prettierrc
Normal 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"
|
||||
}
|
||||
@ -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
22
hooks/prettier.sh
Executable 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
28
package-lock.json
generated
Normal 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
9
package.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"prettier": "^3.6.2"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
16
src/frontend/router.py
Normal 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
13
src/frontend/statics.py
Normal 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
60
static/app.js
Normal 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);
|
||||
}
|
||||
188
static/components/BlocksTable.js
Normal file
188
static/components/BlocksTable.js
Normal 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 }),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
57
static/components/HealthPill.js
Normal file
57
static/components/HealthPill.js
Normal 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);
|
||||
}
|
||||
64
static/components/Router.js
Normal file
64
static/components/Router.js
Normal 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'));
|
||||
}
|
||||
143
static/components/TransactionsTable.js
Normal file
143
static/components/TransactionsTable.js
Normal 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 }),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -1,318 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<head>
|
||||
<title>Nomos Block Explorer</title>
|
||||
<meta charset="utf-8" />
|
||||
<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; }
|
||||
<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 {} }
|
||||
}
|
||||
} catch (e) { if (!healthController?.signal.aborted) setState("offline"); }
|
||||
}
|
||||
connectHealth();
|
||||
|
||||
// ---- 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 {}
|
||||
<!-- 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"
|
||||
}
|
||||
}
|
||||
const last = buf.trim(); if (last) { try { onItem(JSON.parse(last)); } catch {} }
|
||||
}
|
||||
</script>
|
||||
|
||||
// ---- 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(`<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
13
static/lib/api.js
Normal 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
96
static/lib/utils.js
Normal 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 = ' ';
|
||||
tr.appendChild(td);
|
||||
}
|
||||
tableBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
182
static/pages/BlockDetail.js
Normal file
182
static/pages/BlockDetail.js
Normal 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
11
static/pages/Home.js
Normal 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, {})),
|
||||
);
|
||||
}
|
||||
118
static/pages/TransactionDetail.js
Normal file
118
static/pages/TransactionDetail.js
Normal 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
153
static/styles.css
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user