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*
|
*.ignore*
|
||||||
.env
|
.env
|
||||||
uv.lock
|
uv.lock
|
||||||
|
node_modules
|
||||||
|
|||||||
@ -16,3 +16,12 @@ repos:
|
|||||||
rev: 6.1.0
|
rev: 6.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- 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 ordering for Blocks and Transactions
|
||||||
- Fix assumption of 1 block per slot
|
- Fix assumption of 1 block per slot
|
||||||
- Split the single file static into components
|
- Split the single file static into components
|
||||||
|
- Get transaction by hash
|
||||||
|
- Get block by hash
|
||||||
|
|
||||||
# Demo
|
# Demo
|
||||||
|
- Get transaction by id
|
||||||
|
- Get block by id
|
||||||
- Block viewer
|
- Block viewer
|
||||||
|
- htm
|
||||||
- Transaction viewer
|
- 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 fastapi import FastAPI
|
||||||
|
|
||||||
from core.app import NBE
|
from core.app import NBE
|
||||||
|
from frontend.statics import mount_statics
|
||||||
from lifespan import lifespan
|
from lifespan import lifespan
|
||||||
from router import create_router
|
from router import create_router
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
app = NBE(lifespan=lifespan)
|
app = NBE(lifespan=lifespan)
|
||||||
|
app = mount_statics(app)
|
||||||
app.include_router(create_router())
|
app.include_router(create_router())
|
||||||
return app
|
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>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<title>Nomos Block Explorer</title>
|
||||||
<title>Nomos Block Explorer</title>
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<style>
|
<meta name="description" content="Lightweight Nomos block Explorer UI" />
|
||||||
: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; }
|
|
||||||
|
|
||||||
.table-wrapper { overflow: auto; -webkit-overflow-scrolling: touch; max-height: 60vh; scrollbar-gutter: stable both-edges; padding-right: 8px; }
|
<!-- Styles -->
|
||||||
table { border-collapse: collapse; table-layout: fixed; width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
.table-wrapper .table--blocks { min-width: 860px; }
|
|
||||||
.table-wrapper .table--txs { 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; }
|
<!-- Speed up first connection to ESM CDN -->
|
||||||
th { color: var(--muted); font-weight: normal; font-size: 13px; position: sticky; top: 0; background: var(--card); z-index: 1; }
|
<link rel="dns-prefetch" href="https://esm.sh" />
|
||||||
tbody td { height: 28px; }
|
<link rel="preconnect" href="https://esm.sh" crossorigin />
|
||||||
tr:nth-child(odd) { background: #121728; }
|
|
||||||
|
|
||||||
.twocol { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 16px; }
|
<!-- Import map must appear BEFORE any modulepreload that relies on it -->
|
||||||
@media (max-width: 960px) { .twocol { grid-template-columns: 1fr; } }
|
<script type="importmap">
|
||||||
|
{
|
||||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
"imports": {
|
||||||
.amount { font-variant-numeric: tabular-nums; }
|
"preact": "https://esm.sh/preact@10.22.0",
|
||||||
.linkish { color: var(--fg); text-decoration: none; border-bottom: 1px dotted #2a3350; }
|
"preact/hooks": "https://esm.sh/preact@10.22.0/hooks"
|
||||||
.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"); }
|
</script>
|
||||||
}
|
|
||||||
connectHealth();
|
|
||||||
|
|
||||||
// ---- NDJSON reader (shared) ----
|
<!-- Safe to preload after the import map -->
|
||||||
async function streamNDJSON(url, onItem, { signal } = {}) {
|
<link rel="modulepreload" href="/static/app.js" />
|
||||||
const res = await fetch(url, { headers: { "accept": "application/x-ndjson" }, signal, cache: "no-cache" });
|
<link rel="modulepreload" href="https://esm.sh/preact@10.22.0" crossorigin />
|
||||||
if (!res.ok || !res.body) throw new Error(`Stream failed: ${res.status}`);
|
<link rel="modulepreload" href="https://esm.sh/preact@10.22.0/hooks" crossorigin />
|
||||||
const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = "";
|
</head>
|
||||||
while (true) {
|
<body>
|
||||||
let chunk;
|
<div id="app"></div>
|
||||||
try { chunk = await reader.read(); }
|
<script type="module" src="/static/app.js"></script>
|
||||||
catch { if (signal?.aborted) return; else break; } // quiet on navigation abort
|
</body>
|
||||||
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(`<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>
|
|
||||||
</html>
|
</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