chore: initial commit for v0.0.1
DChain single-node blockchain + React Native messenger client. Core: - PBFT consensus with multi-sig validator admission + equivocation slashing - BadgerDB + schema migration scaffold (CurrentSchemaVersion=0) - libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1) - Native Go contracts (username_registry) alongside WASM (wazero) - WebSocket gateway with topic-based fanout + Ed25519-nonce auth - Relay mailbox with NaCl envelope encryption (X25519 + Ed25519) - Prometheus /metrics, per-IP rate limit, body-size cap Deployment: - Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus - 3-node dev compose (docker-compose.yml) with mocked internet topology - 3-validator prod compose (deploy/prod/) for federation - Auto-update from Gitea via /api/update-check + systemd timer - Build-time version injection (ldflags → node --version) - UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER) Client (client-app/): - Expo / React Native / NativeWind - E2E NaCl encryption, typing indicator, contact requests - Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch Documentation: - README.md, CHANGELOG.md, CONTEXT.md - deploy/single/README.md with 6 operator scenarios - deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design - docs/contracts/*.md per contract
This commit is contained in:
224
node/explorer/address.html
Normal file
224
node/explorer/address.html
Normal file
@@ -0,0 +1,224 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Wallet | DChain Explorer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/explorer/style.css">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script defer src="/assets/explorer/common.js"></script>
|
||||
<script defer src="/assets/explorer/address.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- top nav -->
|
||||
<header class="topnav">
|
||||
<div class="topnav-inner">
|
||||
<a class="topnav-brand" href="/">
|
||||
<span class="brand-gem">◆</span>
|
||||
<span class="brand-name">DChain</span>
|
||||
</a>
|
||||
<nav class="topnav-links">
|
||||
<a href="/contract">Contracts</a>
|
||||
<a href="/tokens">Tokens</a>
|
||||
<a href="/validators">Validators</a>
|
||||
<a href="/relays">Relay Nodes</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- search bar (collapsed under nav) -->
|
||||
<div class="addr-searchbar">
|
||||
<div class="addr-searchbar-inner">
|
||||
<div class="hero-search" style="max-width:640px">
|
||||
<i data-lucide="search" class="hero-search-icon"></i>
|
||||
<input id="addressInput" type="text" placeholder="Enter address (DC…) or public key…">
|
||||
<button id="addressBtn" class="btn-hero">Load</button>
|
||||
</div>
|
||||
<div id="status" class="hero-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- empty state (shown when no address loaded, toggled by JS) -->
|
||||
<div id="emptyState" class="page-body" style="display:none">
|
||||
<div class="addr-empty-state">
|
||||
<div class="addr-empty-icon"><i data-lucide="wallet"></i></div>
|
||||
<div class="addr-empty-title">Search for a wallet</div>
|
||||
<div class="addr-empty-sub">Enter a DC address (e.g. <span class="mono" style="color:var(--accent)">DC1abc…</span>) or a 64-character hex public key above to view balance and transaction history.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="page-body" id="mainContent" style="display:none">
|
||||
|
||||
<!-- ── Error banner (hidden unless load failed) ──────────────────────── -->
|
||||
<div id="errorBanner" class="addr-error-banner" style="display:none">
|
||||
<div class="addr-error-icon"><i data-lucide="alert-circle"></i></div>
|
||||
<div class="addr-error-body">
|
||||
<div class="addr-error-title">Could not load wallet</div>
|
||||
<div class="addr-error-msg" id="errorMsg">Unknown error</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Profile card (hidden on error) ───────────────────────────────── -->
|
||||
<div id="profileSection">
|
||||
<div class="addr-profile-card">
|
||||
|
||||
<!-- left: avatar + name + badges -->
|
||||
<div class="addr-profile-left">
|
||||
<div class="addr-avatar" id="addrAvatar">◆</div>
|
||||
<div class="addr-profile-info">
|
||||
<div class="addr-name" id="addrNickname">Unknown Wallet</div>
|
||||
<div class="addr-badges" id="addrBadges"><!-- filled by JS --></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- right: balance -->
|
||||
<div class="addr-profile-right">
|
||||
<div class="addr-bal-label">Balance</div>
|
||||
<div class="addr-bal-val" id="walletBalance">—</div>
|
||||
<div class="addr-bal-sub" id="walletBalanceSub">—</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Identity details grid ─────────────────────────────────────────── -->
|
||||
<div class="panel addr-detail-panel">
|
||||
<div class="addr-kv-list">
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="at-sign"></i> Address</div>
|
||||
<div class="addr-kv-val">
|
||||
<span class="mono" id="walletAddress">—</span>
|
||||
<button class="copy-btn" data-copy-id="walletAddress" title="Copy"><i data-lucide="copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="key-round"></i> Public Key</div>
|
||||
<div class="addr-kv-val">
|
||||
<span class="mono addr-pubkey-text" id="walletPubKey">—</span>
|
||||
<button class="copy-btn" data-copy-id="walletPubKey" title="Copy"><i data-lucide="copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row" id="x25519Row" style="display:none">
|
||||
<div class="addr-kv-key"><i data-lucide="message-square-lock"></i> Messaging Key (X25519)</div>
|
||||
<div class="addr-kv-val">
|
||||
<span class="mono addr-pubkey-text" id="walletX25519">—</span>
|
||||
<button class="copy-btn" data-copy-id="walletX25519" title="Copy"><i data-lucide="copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row" id="nodeLinkRow" style="display:none">
|
||||
<div class="addr-kv-key"><i data-lucide="server"></i> Node Page</div>
|
||||
<div class="addr-kv-val"><a id="nodePageLink" href="#">View node stats →</a></div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row" id="walletBindingRow" style="display:none">
|
||||
<div class="addr-kv-key"><i data-lucide="wallet"></i> Bound Wallet</div>
|
||||
<div class="addr-kv-val"><a id="walletBindingLink" href="#">—</a></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Node stats (shown only for validators / relay nodes) ─────────── -->
|
||||
<div class="panel addr-node-panel" id="nodeStatsPanel" style="display:none">
|
||||
<h2><i data-lucide="server"></i> Node Stats</h2>
|
||||
<div class="addr-node-grid">
|
||||
<div class="addr-node-cell">
|
||||
<div class="addr-node-label">Blocks Produced</div>
|
||||
<div class="addr-node-val" id="nodeBlocks">—</div>
|
||||
</div>
|
||||
<div class="addr-node-cell">
|
||||
<div class="addr-node-label">Relay Proofs</div>
|
||||
<div class="addr-node-val" id="nodeRelayProofs">—</div>
|
||||
</div>
|
||||
<div class="addr-node-cell">
|
||||
<div class="addr-node-label">Reputation</div>
|
||||
<div class="addr-node-val" id="nodeRepScore">—</div>
|
||||
<div class="addr-node-sub" id="nodeRepRank">—</div>
|
||||
</div>
|
||||
<div class="addr-node-cell">
|
||||
<div class="addr-node-label">Heartbeats</div>
|
||||
<div class="addr-node-val" id="nodeHeartbeats">—</div>
|
||||
</div>
|
||||
<div class="addr-node-cell">
|
||||
<div class="addr-node-label">Slashes</div>
|
||||
<div class="addr-node-val" id="nodeSlashes">—</div>
|
||||
</div>
|
||||
<div class="addr-node-cell">
|
||||
<div class="addr-node-label">Recent Rewards</div>
|
||||
<div class="addr-node-val" id="nodeRecentRewards">—</div>
|
||||
<div class="addr-node-sub" id="nodeRecentWindow">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Token Balances ───────────────────────────────────────────────── -->
|
||||
<div class="panel" id="tokenBalPanel" style="display:none">
|
||||
<div class="addr-hist-head">
|
||||
<h2><i data-lucide="coins"></i> Token Balances</h2>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Name</th>
|
||||
<th style="text-align:right">Balance</th>
|
||||
<th style="width:90px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tokenBalBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── NFT Collection ────────────────────────────────────────────────── -->
|
||||
<div class="panel" id="nftPanel" style="display:none">
|
||||
<div class="addr-hist-head">
|
||||
<h2><i data-lucide="image"></i> NFTs</h2>
|
||||
<span class="addr-hist-count" id="nftOwnedCount"></span>
|
||||
</div>
|
||||
<div id="addrNFTGrid" class="nft-grid" style="padding:1rem"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Transaction History ───────────────────────────────────────────── -->
|
||||
<div class="panel">
|
||||
<div class="addr-hist-head">
|
||||
<h2><i data-lucide="history"></i> History</h2>
|
||||
<span class="addr-hist-count" id="walletTxCount"></span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="walletTxTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Type</th>
|
||||
<th>From → To</th>
|
||||
<th>Memo / Block</th>
|
||||
<th style="text-align:right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="walletTxBody">
|
||||
<tr><td colspan="5" class="tbl-empty">No transactions yet.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="addr-load-more">
|
||||
<button id="loadMoreBtn" class="btn" style="display:none">
|
||||
<i data-lucide="chevron-down"></i> Load more
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /#profileSection -->
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
414
node/explorer/address.js
Normal file
414
node/explorer/address.js
Normal file
@@ -0,0 +1,414 @@
|
||||
(function() {
|
||||
var C = window.ExplorerCommon;
|
||||
|
||||
var state = {
|
||||
currentAddress: '',
|
||||
currentPubKey: '',
|
||||
nextOffset: 0,
|
||||
hasMore: false,
|
||||
limit: 50
|
||||
};
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
function linkAddress(value, label) {
|
||||
return '<a href="/address?address=' + encodeURIComponent(value) + '">' +
|
||||
C.esc(label || C.shortAddress(value)) + '</a>';
|
||||
}
|
||||
|
||||
function direction(tx, pubKey) {
|
||||
if (tx.type === 'BLOCK_REWARD' || tx.type === 'HEARTBEAT') return { cls: 'recv', arrow: '↓' };
|
||||
if (tx.type === 'RELAY_PROOF') return { cls: 'recv', arrow: '↓' };
|
||||
if (tx.from === pubKey && (!tx.to || tx.to !== pubKey)) return { cls: 'sent', arrow: '↑' };
|
||||
if (tx.to === pubKey && tx.from !== pubKey) return { cls: 'recv', arrow: '↓' };
|
||||
return { cls: 'neutral', arrow: '·' };
|
||||
}
|
||||
|
||||
/* ── Badge builder ───────────────────────────────────────────────────────── */
|
||||
|
||||
function badge(text, variant) {
|
||||
return '<span class="addr-badge addr-badge-' + variant + '">' + C.esc(text) + '</span>';
|
||||
}
|
||||
|
||||
/* ── Profile render ──────────────────────────────────────────────────────── */
|
||||
|
||||
function renderProfile(addrData, identData, nodeData) {
|
||||
var pubKey = addrData.pub_key || '';
|
||||
var address = addrData.address || '';
|
||||
|
||||
state.currentPubKey = pubKey;
|
||||
|
||||
// Avatar initials (first letter of nickname or first char of address)
|
||||
var name = (identData && identData.nickname) || '';
|
||||
var avatarChar = name ? name[0].toUpperCase() : (address ? address[2] || '◆' : '◆');
|
||||
document.getElementById('addrAvatar').textContent = avatarChar;
|
||||
|
||||
// Name
|
||||
document.getElementById('addrNickname').textContent = name || 'Unknown Wallet';
|
||||
|
||||
// Badges
|
||||
var badges = '';
|
||||
if (identData && identData.registered) badges += badge('Registered', 'ok');
|
||||
if (nodeData && nodeData.blocks_produced > 0) badges += badge('Validator', 'accent');
|
||||
if (nodeData && nodeData.relay_proofs > 0) badges += badge('Relay Node', 'relay');
|
||||
document.getElementById('addrBadges').innerHTML = badges || badge('Unregistered', 'muted');
|
||||
|
||||
// Balance
|
||||
document.getElementById('walletBalance').textContent = addrData.balance || C.toToken(addrData.balance_ut);
|
||||
document.getElementById('walletBalanceSub').textContent = addrData.balance_ut != null
|
||||
? addrData.balance_ut + ' µT' : '';
|
||||
|
||||
// Address field
|
||||
document.getElementById('walletAddress').textContent = address;
|
||||
document.getElementById('walletAddress').title = address;
|
||||
|
||||
// Public key field
|
||||
document.getElementById('walletPubKey').textContent = pubKey;
|
||||
document.getElementById('walletPubKey').title = pubKey;
|
||||
|
||||
// X25519 key
|
||||
if (identData && identData.x25519_pub) {
|
||||
document.getElementById('walletX25519').textContent = identData.x25519_pub;
|
||||
document.getElementById('walletX25519').title = identData.x25519_pub;
|
||||
document.getElementById('x25519Row').style.display = '';
|
||||
} else {
|
||||
document.getElementById('x25519Row').style.display = 'none';
|
||||
}
|
||||
|
||||
// Node page link (if address is a node itself)
|
||||
if (nodeData && (nodeData.blocks_produced > 0 || nodeData.relay_proofs > 0 || nodeData.heartbeats > 0)) {
|
||||
var nodeLink = document.getElementById('nodePageLink');
|
||||
nodeLink.href = '/node?node=' + encodeURIComponent(pubKey);
|
||||
document.getElementById('nodeLinkRow').style.display = '';
|
||||
} else {
|
||||
document.getElementById('nodeLinkRow').style.display = 'none';
|
||||
}
|
||||
|
||||
// Bound wallet (only for pure node addresses)
|
||||
if (nodeData && nodeData.wallet_binding_address && nodeData.wallet_binding_address !== address) {
|
||||
var wbl = document.getElementById('walletBindingLink');
|
||||
wbl.href = '/address?address=' + encodeURIComponent(nodeData.wallet_binding_address);
|
||||
wbl.textContent = nodeData.wallet_binding_address;
|
||||
document.getElementById('walletBindingRow').style.display = '';
|
||||
} else {
|
||||
document.getElementById('walletBindingRow').style.display = 'none';
|
||||
}
|
||||
|
||||
// Node stats panel
|
||||
if (nodeData && (nodeData.blocks_produced > 0 || nodeData.relay_proofs > 0 || nodeData.heartbeats > 0)) {
|
||||
document.getElementById('nodeBlocks').textContent = nodeData.blocks_produced || 0;
|
||||
document.getElementById('nodeRelayProofs').textContent = nodeData.relay_proofs || 0;
|
||||
document.getElementById('nodeRepScore').textContent = nodeData.reputation_score || 0;
|
||||
document.getElementById('nodeRepRank').textContent = nodeData.reputation_rank || '—';
|
||||
document.getElementById('nodeHeartbeats').textContent = nodeData.heartbeats || 0;
|
||||
document.getElementById('nodeSlashes').textContent = nodeData.slash_count || 0;
|
||||
document.getElementById('nodeRecentRewards').textContent = nodeData.recent_rewards || '—';
|
||||
var window_ = nodeData.recent_window_blocks || 0;
|
||||
var produced = nodeData.recent_blocks_produced || 0;
|
||||
document.getElementById('nodeRecentWindow').textContent =
|
||||
'last ' + window_ + ' blocks, produced ' + produced;
|
||||
document.getElementById('nodeStatsPanel').style.display = '';
|
||||
} else {
|
||||
document.getElementById('nodeStatsPanel').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Transaction row builder ─────────────────────────────────────────────── */
|
||||
|
||||
function txTypeIcon(type) {
|
||||
var icons = {
|
||||
TRANSFER: 'arrow-left-right',
|
||||
REGISTER_KEY: 'user-check',
|
||||
RELAY_PROOF: 'zap',
|
||||
HEARTBEAT: 'activity',
|
||||
BLOCK_REWARD: 'layers-3',
|
||||
BIND_WALLET: 'link',
|
||||
REGISTER_RELAY: 'radio',
|
||||
SLASH: 'alert-triangle',
|
||||
ADD_VALIDATOR: 'shield-plus',
|
||||
REMOVE_VALIDATOR: 'shield-minus',
|
||||
};
|
||||
return icons[type] || 'circle';
|
||||
}
|
||||
|
||||
function appendTxRows(txs) {
|
||||
var body = document.getElementById('walletTxBody');
|
||||
for (var i = 0; i < txs.length; i++) {
|
||||
var tx = txs[i];
|
||||
var dir = direction(tx, state.currentPubKey);
|
||||
|
||||
var fromAddr = tx.from_addr || (tx.from ? C.shortAddress(tx.from) : '—');
|
||||
var toAddr = tx.to_addr || (tx.to ? C.shortAddress(tx.to) : '—');
|
||||
var fromCell = tx.from ? linkAddress(tx.from, tx.from_addr || C.shortAddress(tx.from)) : '<span class="text-muted">—</span>';
|
||||
var toCell = tx.to ? linkAddress(tx.to, tx.to_addr || C.shortAddress(tx.to)) : '<span class="text-muted">—</span>';
|
||||
|
||||
var amount = Number(tx.amount_ut || 0);
|
||||
var amountStr = amount > 0 ? C.toToken(amount) : '—';
|
||||
var amountCls = dir.cls === 'recv' ? 'pos' : (dir.cls === 'sent' ? 'neg' : 'neutral');
|
||||
var amountSign = dir.cls === 'recv' ? '+' : (dir.cls === 'sent' ? '−' : '');
|
||||
|
||||
var memo = tx.memo ? C.esc(tx.memo) : '';
|
||||
var blockRef = tx.block_index != null
|
||||
? '<span class="tx-block-ref">block #' + tx.block_index + '</span>'
|
||||
: '';
|
||||
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'tx-row';
|
||||
tr.dataset.txid = tx.id || '';
|
||||
tr.innerHTML =
|
||||
'<td title="' + C.esc(C.fmtTime(tx.time)) + '">' +
|
||||
'<div class="tx-time">' + C.esc(C.timeAgo(tx.time)) + '</div>' +
|
||||
'</td>' +
|
||||
'<td>' +
|
||||
'<div class="tx-type-cell">' +
|
||||
'<span class="tx-type-icon tx-icon-' + C.esc(dir.cls) + '">' +
|
||||
'<i data-lucide="' + txTypeIcon(tx.type) + '"></i>' +
|
||||
'</span>' +
|
||||
'<div>' +
|
||||
'<div class="tx-type-name">' + C.esc(C.txLabel(tx.type)) + '</div>' +
|
||||
'<div class="tx-type-raw">' + C.esc(tx.type || '') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</td>' +
|
||||
'<td>' +
|
||||
'<div class="tx-route">' +
|
||||
'<span class="tx-route-item">' + fromCell + '</span>' +
|
||||
'<i data-lucide="arrow-right" class="tx-route-arrow"></i>' +
|
||||
'<span class="tx-route-item">' + toCell + '</span>' +
|
||||
'</div>' +
|
||||
'</td>' +
|
||||
'<td>' +
|
||||
(memo ? '<div class="tx-memo-block">' + memo + '</div>' : '') +
|
||||
blockRef +
|
||||
'</td>' +
|
||||
'<td style="text-align:right">' +
|
||||
'<div class="tx-amount ' + amountCls + '">' + amountSign + C.esc(amountStr) + '</div>' +
|
||||
(tx.fee_ut ? '<div class="tx-fee">fee ' + C.esc(C.toToken(tx.fee_ut)) + '</div>' : '') +
|
||||
'</td>';
|
||||
body.appendChild(tr);
|
||||
}
|
||||
C.refreshIcons();
|
||||
}
|
||||
|
||||
/* ── Load wallet ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function showEmpty() {
|
||||
var es = document.getElementById('emptyState');
|
||||
var mc = document.getElementById('mainContent');
|
||||
if (es) es.style.display = '';
|
||||
if (mc) mc.style.display = 'none';
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
var es = document.getElementById('emptyState');
|
||||
var mc = document.getElementById('mainContent');
|
||||
var eb = document.getElementById('errorBanner');
|
||||
var em = document.getElementById('errorMsg');
|
||||
var ps = document.getElementById('profileSection');
|
||||
if (es) es.style.display = 'none';
|
||||
if (eb) { eb.style.display = ''; if (em) em.textContent = msg; }
|
||||
if (ps) ps.style.display = 'none';
|
||||
if (mc) mc.style.display = '';
|
||||
C.refreshIcons();
|
||||
}
|
||||
|
||||
async function loadWallet(address) {
|
||||
if (!address) return;
|
||||
state.currentAddress = address;
|
||||
state.nextOffset = 0;
|
||||
state.hasMore = false;
|
||||
|
||||
// Hide empty state, hide main, show loading
|
||||
var es = document.getElementById('emptyState');
|
||||
if (es) es.style.display = 'none';
|
||||
document.getElementById('mainContent').style.display = 'none';
|
||||
document.getElementById('errorBanner').style.display = 'none';
|
||||
document.getElementById('profileSection').style.display = '';
|
||||
document.getElementById('walletTxBody').innerHTML = '';
|
||||
document.getElementById('walletTxCount').textContent = '';
|
||||
C.setStatus('Loading…', 'warn');
|
||||
|
||||
try {
|
||||
// Parallel fetch: address data + identity + node info
|
||||
var results = await Promise.all([
|
||||
C.fetchJSON('/api/address/' + encodeURIComponent(address) +
|
||||
'?limit=' + state.limit + '&offset=0'),
|
||||
C.fetchJSON('/api/identity/' + encodeURIComponent(address)).catch(function() { return null; }),
|
||||
C.fetchJSON('/api/node/' + encodeURIComponent(address)).catch(function() { return null; }),
|
||||
C.fetchJSON('/api/tokens').catch(function() { return null; }),
|
||||
C.fetchJSON('/api/nfts/owner/' + encodeURIComponent(address)).catch(function() { return null; }),
|
||||
]);
|
||||
var addrData = results[0];
|
||||
var identData = results[1];
|
||||
var nodeData = results[2];
|
||||
var tokensAll = results[3];
|
||||
var nftsOwned = results[4];
|
||||
|
||||
renderProfile(addrData, identData, nodeData);
|
||||
|
||||
// ── Token balances ──────────────────────────────────────────────────
|
||||
var pubKey = addrData.pub_key || address;
|
||||
var allTokens = (tokensAll && Array.isArray(tokensAll.tokens)) ? tokensAll.tokens : [];
|
||||
if (allTokens.length) {
|
||||
var balFetches = allTokens.map(function(t) {
|
||||
return C.fetchJSON('/api/tokens/' + t.token_id + '/balance/' + encodeURIComponent(pubKey))
|
||||
.then(function(b) { return { token: t, balance: b && b.balance != null ? b.balance : 0 }; })
|
||||
.catch(function() { return { token: t, balance: 0 }; });
|
||||
});
|
||||
var balResults = await Promise.all(balFetches);
|
||||
var nonZero = balResults.filter(function(r) { return r.balance > 0; });
|
||||
if (nonZero.length) {
|
||||
var rows = '';
|
||||
nonZero.forEach(function(r) {
|
||||
var t = r.token;
|
||||
var d = t.decimals || 0;
|
||||
var sup = r.balance;
|
||||
var whole = d > 0 ? Math.floor(sup / Math.pow(10, d)) : sup;
|
||||
var frac = d > 0 ? sup % Math.pow(10, d) : 0;
|
||||
var balFmt = whole.toLocaleString() +
|
||||
(frac > 0 ? '.' + String(frac).padStart(d, '0').replace(/0+$/, '') : '');
|
||||
rows += '<tr>' +
|
||||
'<td><a href="/token?id=' + encodeURIComponent(t.token_id) + '" class="token-sym">' + C.esc(t.symbol) + '</a></td>' +
|
||||
'<td>' + C.esc(t.name) + '</td>' +
|
||||
'<td style="text-align:right" class="mono">' + C.esc(balFmt) + '</td>' +
|
||||
'<td><a href="/token?id=' + encodeURIComponent(t.token_id) + '" class="pill-link" style="font-size:12px;padding:4px 10px">Details</a></td>' +
|
||||
'</tr>';
|
||||
});
|
||||
document.getElementById('tokenBalBody').innerHTML = rows;
|
||||
document.getElementById('tokenBalPanel').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── NFTs owned ──────────────────────────────────────────────────────
|
||||
var ownedNFTs = (nftsOwned && Array.isArray(nftsOwned.nfts)) ? nftsOwned.nfts : [];
|
||||
if (ownedNFTs.length) {
|
||||
document.getElementById('nftOwnedCount').textContent = ownedNFTs.length + ' NFT' + (ownedNFTs.length !== 1 ? 's' : '');
|
||||
var cards = '';
|
||||
ownedNFTs.forEach(function(n) {
|
||||
var imgHtml = (n.uri && /\.(png|jpg|jpeg|gif|svg|webp)/i.test(n.uri))
|
||||
? '<img class="nft-card-img" src="' + C.esc(n.uri) + '" alt="" loading="lazy">'
|
||||
: '<div class="nft-card-img nft-card-placeholder"><i data-lucide="image"></i></div>';
|
||||
cards += '<a class="nft-card" href="/token?nft=' + encodeURIComponent(n.nft_id) + '">' +
|
||||
imgHtml +
|
||||
'<div class="nft-card-body">' +
|
||||
'<div class="nft-card-name">' + C.esc(n.name) + '</div>' +
|
||||
'<div class="nft-card-id mono">' + C.short(n.nft_id, 16) + '</div>' +
|
||||
'</div>' +
|
||||
'</a>';
|
||||
});
|
||||
document.getElementById('addrNFTGrid').innerHTML = cards;
|
||||
document.getElementById('nftPanel').style.display = '';
|
||||
}
|
||||
|
||||
var txs = addrData.transactions || [];
|
||||
if (!txs.length) {
|
||||
document.getElementById('walletTxBody').innerHTML =
|
||||
'<tr><td colspan="5" class="tbl-empty">No transactions for this wallet.</td></tr>';
|
||||
} else {
|
||||
appendTxRows(txs);
|
||||
}
|
||||
|
||||
state.nextOffset = addrData.next_offset || txs.length;
|
||||
state.hasMore = !!addrData.has_more;
|
||||
setLoadMoreVisible(state.hasMore);
|
||||
|
||||
var countText = txs.length + (state.hasMore ? '+' : '') + ' transaction' +
|
||||
(txs.length !== 1 ? 's' : '');
|
||||
document.getElementById('walletTxCount').textContent = countText;
|
||||
|
||||
// Update page title and URL
|
||||
var displayName = (identData && identData.nickname) || addrData.address || address;
|
||||
document.title = displayName + ' | DChain Explorer';
|
||||
window.history.replaceState({}, '',
|
||||
'/address?address=' + encodeURIComponent(addrData.pub_key || address));
|
||||
|
||||
document.getElementById('mainContent').style.display = '';
|
||||
C.setStatus('', '');
|
||||
C.refreshIcons();
|
||||
|
||||
} catch (e) {
|
||||
showError(e.message || 'Unknown error');
|
||||
C.setStatus('', '');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!state.currentAddress || !state.hasMore) return;
|
||||
C.setStatus('Loading more…', 'warn');
|
||||
setLoadMoreVisible(false);
|
||||
try {
|
||||
var data = await C.fetchJSON('/api/address/' + encodeURIComponent(state.currentAddress) +
|
||||
'?limit=' + state.limit + '&offset=' + state.nextOffset);
|
||||
var txs = data.transactions || [];
|
||||
appendTxRows(txs);
|
||||
state.nextOffset = data.next_offset || (state.nextOffset + txs.length);
|
||||
state.hasMore = !!data.has_more;
|
||||
|
||||
var prev = parseInt((document.getElementById('walletTxCount').textContent || '0'), 10) || 0;
|
||||
var total = prev + txs.length;
|
||||
document.getElementById('walletTxCount').textContent =
|
||||
total + (state.hasMore ? '+' : '') + ' transactions';
|
||||
setLoadMoreVisible(state.hasMore);
|
||||
C.setStatus('', '');
|
||||
} catch (e) {
|
||||
C.setStatus('Load failed: ' + e.message, 'err');
|
||||
setLoadMoreVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
function setLoadMoreVisible(v) {
|
||||
var btn = document.getElementById('loadMoreBtn');
|
||||
if (btn) btn.style.display = v ? '' : 'none';
|
||||
}
|
||||
|
||||
/* ── Copy buttons ────────────────────────────────────────────────────────── */
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var t = e.target;
|
||||
if (!t) return;
|
||||
|
||||
// Copy button
|
||||
var btn = t.closest ? t.closest('.copy-btn') : null;
|
||||
if (btn) {
|
||||
var src = document.getElementById(btn.dataset.copyId);
|
||||
if (src) {
|
||||
navigator.clipboard.writeText(src.textContent || src.title || '').catch(function() {});
|
||||
btn.classList.add('copy-btn-done');
|
||||
setTimeout(function() { btn.classList.remove('copy-btn-done'); }, 1200);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// TX row click → tx detail page
|
||||
var link = t.closest ? t.closest('a') : null;
|
||||
if (link) return;
|
||||
var row = t.closest ? t.closest('tr.tx-row') : null;
|
||||
if (row && row.dataset && row.dataset.txid) {
|
||||
window.location.href = '/tx?id=' + encodeURIComponent(row.dataset.txid);
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Event wiring ────────────────────────────────────────────────────────── */
|
||||
|
||||
document.getElementById('addressBtn').addEventListener('click', function() {
|
||||
var val = (document.getElementById('addressInput').value || '').trim();
|
||||
if (val) loadWallet(val);
|
||||
});
|
||||
|
||||
document.getElementById('addressInput').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') document.getElementById('addressBtn').click();
|
||||
});
|
||||
|
||||
document.getElementById('loadMoreBtn').addEventListener('click', loadMore);
|
||||
|
||||
/* ── Auto-load from URL param ────────────────────────────────────────────── */
|
||||
|
||||
var initial = C.q('address');
|
||||
if (initial) {
|
||||
document.getElementById('addressInput').value = initial;
|
||||
loadWallet(initial);
|
||||
} else {
|
||||
showEmpty();
|
||||
}
|
||||
|
||||
})();
|
||||
484
node/explorer/app.js
Normal file
484
node/explorer/app.js
Normal file
@@ -0,0 +1,484 @@
|
||||
(function() {
|
||||
var C = window.ExplorerCommon;
|
||||
|
||||
/* ── State ──────────────────────────────────────────────────────────────── */
|
||||
var state = {
|
||||
txSeries: [], // tx counts per recent block (oldest → newest)
|
||||
intervalSeries: [], // block intervals in seconds
|
||||
supplyHistory: [], // cumulative tx count (proxy sparkline for supply)
|
||||
tpsSeries: [], // same as txSeries, used for strip micro-bar
|
||||
};
|
||||
|
||||
/* ── Address / Block links ──────────────────────────────────────────────── */
|
||||
function linkAddress(value, label) {
|
||||
return '<a href="/address?address=' + encodeURIComponent(value) + '">' +
|
||||
C.esc(label || value) + '</a>';
|
||||
}
|
||||
|
||||
function linkBlock(index) {
|
||||
return '<a href="#" class="block-link" data-index="' + C.esc(index) + '">#' +
|
||||
C.esc(index) + '</a>';
|
||||
}
|
||||
|
||||
/* ── Micro chart helpers (strip sparklines) ─────────────────────────────── */
|
||||
|
||||
function resizeMicro(canvas) {
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var w = canvas.offsetWidth || 80;
|
||||
var h = canvas.offsetHeight || 36;
|
||||
canvas.width = Math.max(1, Math.floor(w * dpr));
|
||||
canvas.height = Math.max(1, Math.floor(h * dpr));
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
return { ctx: ctx, w: w, h: h };
|
||||
}
|
||||
|
||||
/** Tiny bar chart for the TPS strip cell */
|
||||
function drawMicroBars(canvas, values, color) {
|
||||
if (!canvas) return;
|
||||
var r = resizeMicro(canvas);
|
||||
var ctx = r.ctx; var w = r.w; var h = r.h;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!values || !values.length) return;
|
||||
var n = Math.min(values.length, 24);
|
||||
var vs = values.slice(-n);
|
||||
var max = Math.max.apply(null, vs.concat([1]));
|
||||
var gap = 2;
|
||||
var bw = Math.max(2, (w - gap * (n + 1)) / n);
|
||||
ctx.fillStyle = color;
|
||||
for (var i = 0; i < vs.length; i++) {
|
||||
var bh = Math.max(2, (vs[i] / max) * (h - 4));
|
||||
var x = gap + i * (bw + gap);
|
||||
var y = h - bh;
|
||||
ctx.fillRect(x, y, bw, bh);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tiny sparkline with gradient fill for the Supply strip cell */
|
||||
function drawSparkline(canvas, values, color) {
|
||||
if (!canvas) return;
|
||||
var r = resizeMicro(canvas);
|
||||
var ctx = r.ctx; var w = r.w; var h = r.h;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!values || values.length < 2) {
|
||||
// draw a flat line when no data
|
||||
ctx.strokeStyle = color + '55';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(2, h / 2);
|
||||
ctx.lineTo(w - 2, h / 2);
|
||||
ctx.stroke();
|
||||
return;
|
||||
}
|
||||
var n = Math.min(values.length, 24);
|
||||
var vs = values.slice(-n);
|
||||
var mn = Math.min.apply(null, vs);
|
||||
var mx = Math.max.apply(null, vs);
|
||||
if (mn === mx) { mn = mn - 1; mx = mx + 1; }
|
||||
var span = mx - mn;
|
||||
var pad = 3;
|
||||
|
||||
var pts = [];
|
||||
for (var i = 0; i < vs.length; i++) {
|
||||
var x = pad + (i / (vs.length - 1)) * (w - pad * 2);
|
||||
var y = (h - pad) - ((vs[i] - mn) / span) * (h - pad * 2);
|
||||
pts.push([x, y]);
|
||||
}
|
||||
|
||||
// gradient fill
|
||||
var grad = ctx.createLinearGradient(0, 0, 0, h);
|
||||
grad.addColorStop(0, color + '40');
|
||||
grad.addColorStop(1, color + '05');
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0][0], pts[0][1]);
|
||||
for (var j = 1; j < pts.length; j++) ctx.lineTo(pts[j][0], pts[j][1]);
|
||||
ctx.lineTo(pts[pts.length - 1][0], h);
|
||||
ctx.lineTo(pts[0][0], h);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
// line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0][0], pts[0][1]);
|
||||
for (var k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/* ── Full-size chart helpers (main chart panels) ────────────────────────── */
|
||||
|
||||
function resizeCanvas(canvas) {
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var w = canvas.clientWidth;
|
||||
var h = canvas.clientHeight;
|
||||
canvas.width = Math.max(1, Math.floor(w * dpr));
|
||||
canvas.height = Math.max(1, Math.floor(h * dpr));
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function chartLayout(width, height) {
|
||||
return {
|
||||
left: 44,
|
||||
right: 10,
|
||||
top: 10,
|
||||
bottom: 26,
|
||||
plotW: Math.max(10, width - 54),
|
||||
plotH: Math.max(10, height - 36)
|
||||
};
|
||||
}
|
||||
|
||||
function fmtTick(v) {
|
||||
if (Math.abs(v) >= 1000) return String(Math.round(v));
|
||||
if (Math.abs(v) >= 10) return (Math.round(v * 10) / 10).toFixed(1);
|
||||
return (Math.round(v * 100) / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function drawAxes(ctx, w, h, b, minY, maxY) {
|
||||
ctx.strokeStyle = '#334155';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(b.left, b.top); ctx.lineTo(b.left, h - b.bottom); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(b.left, h - b.bottom); ctx.lineTo(w - b.right, h - b.bottom); ctx.stroke();
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '11px "Inter", sans-serif';
|
||||
for (var i = 0; i <= 4; i++) {
|
||||
var ratio = i / 4;
|
||||
var y = b.top + b.plotH * ratio;
|
||||
var val = maxY - (maxY - minY) * ratio;
|
||||
ctx.beginPath(); ctx.moveTo(b.left - 4, y); ctx.lineTo(b.left, y); ctx.stroke();
|
||||
ctx.fillText(fmtTick(val), 4, y + 3);
|
||||
}
|
||||
ctx.fillText('oldest', b.left, h - 8);
|
||||
ctx.fillText('newest', w - b.right - 36, h - 8);
|
||||
}
|
||||
|
||||
function drawBars(canvas, values, color) {
|
||||
if (!canvas) return;
|
||||
var ctx = resizeCanvas(canvas);
|
||||
var w = canvas.clientWidth; var h = canvas.clientHeight;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
var b = chartLayout(w, h);
|
||||
var max = Math.max.apply(null, (values || []).concat([1]));
|
||||
drawAxes(ctx, w, h, b, 0, max);
|
||||
if (!values || !values.length) return;
|
||||
var gap = 5;
|
||||
var bw = Math.max(2, (b.plotW - gap * (values.length + 1)) / values.length);
|
||||
ctx.fillStyle = color;
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var bh = max > 0 ? (values[i] / max) * b.plotH : 2;
|
||||
var x = b.left + gap + i * (bw + gap);
|
||||
var y = b.top + b.plotH - bh;
|
||||
ctx.fillRect(x, y, bw, bh);
|
||||
}
|
||||
}
|
||||
|
||||
function drawLine(canvas, values, color) {
|
||||
if (!canvas) return;
|
||||
var ctx = resizeCanvas(canvas);
|
||||
var w = canvas.clientWidth; var h = canvas.clientHeight;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
var b = chartLayout(w, h);
|
||||
if (!values || !values.length) { drawAxes(ctx, w, h, b, 0, 1); return; }
|
||||
var mn = Math.min.apply(null, values);
|
||||
var mx = Math.max.apply(null, values);
|
||||
if (mn === mx) { mn = mn - 1; mx = mx + 1; }
|
||||
drawAxes(ctx, w, h, b, mn, mx);
|
||||
var span = mx - mn;
|
||||
ctx.beginPath();
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var x = values.length === 1 ? b.left + b.plotW / 2
|
||||
: b.left + (i * b.plotW) / (values.length - 1);
|
||||
var ratio = span === 0 ? 0.5 : (values[i] - mn) / span;
|
||||
var y = b.top + b.plotH - ratio * b.plotH;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function renderCharts() {
|
||||
drawBars(document.getElementById('txChart'), state.txSeries, '#2563eb');
|
||||
drawLine(document.getElementById('intervalChart'), state.intervalSeries, '#0f766e');
|
||||
}
|
||||
|
||||
function renderStripCharts() {
|
||||
drawSparkline(document.getElementById('supplyChart'), state.supplyHistory, '#7db5ff');
|
||||
drawMicroBars(document.getElementById('tpsChart'), state.tpsSeries, '#41c98a');
|
||||
}
|
||||
|
||||
/* ── renderStats ────────────────────────────────────────────────────────── */
|
||||
|
||||
function renderStats(net, blocks) {
|
||||
// Supply strip
|
||||
document.getElementById('sSupply').textContent = C.toTokenShort(net.total_supply);
|
||||
|
||||
// Height + last block time
|
||||
document.getElementById('sHeight').textContent = Number(net.total_blocks || 0);
|
||||
if (blocks.length) {
|
||||
document.getElementById('sLastTime').textContent = C.timeAgo(blocks[0].time);
|
||||
} else {
|
||||
document.getElementById('sLastTime').textContent = '—';
|
||||
}
|
||||
|
||||
if (!blocks.length) {
|
||||
document.getElementById('sTps').textContent = '—';
|
||||
document.getElementById('sBlockTime').textContent = '—';
|
||||
document.getElementById('sLastBlock').textContent = '—';
|
||||
document.getElementById('sLastHash').textContent = '—';
|
||||
document.getElementById('sTxs').textContent = net.total_txs || 0;
|
||||
document.getElementById('sTransfers').textContent = 'Transfers: ' + (net.total_transfers || 0);
|
||||
document.getElementById('sRelayProofs').textContent = net.total_relay_proofs || 0;
|
||||
document.getElementById('sRelayCount').textContent = (net.relay_count || 0) + ' relay nodes';
|
||||
document.getElementById('sValidators').textContent = net.validator_count || 0;
|
||||
document.getElementById('sTpsWindow').textContent = 'no data yet';
|
||||
state.txSeries = []; state.intervalSeries = []; state.supplyHistory = []; state.tpsSeries = [];
|
||||
renderCharts();
|
||||
renderStripCharts();
|
||||
return;
|
||||
}
|
||||
|
||||
// Newest block
|
||||
var newest = blocks[0];
|
||||
document.getElementById('sLastBlock').textContent = '#' + newest.index;
|
||||
document.getElementById('sLastHash').textContent = C.short(newest.hash, 24);
|
||||
|
||||
// TPS over window
|
||||
var totalTx = 0;
|
||||
for (var i = 0; i < blocks.length; i++) totalTx += Number(blocks[i].tx_count || 0);
|
||||
var t0 = new Date(blocks[blocks.length - 1].time).getTime();
|
||||
var t1 = new Date(blocks[0].time).getTime();
|
||||
var secs = Math.max(1, (t1 - t0) / 1000);
|
||||
var tps = (totalTx / secs).toFixed(2);
|
||||
document.getElementById('sTps').textContent = tps;
|
||||
document.getElementById('sTpsWindow').textContent = 'window: ~' + Math.round(secs) + ' sec';
|
||||
|
||||
// Avg block interval
|
||||
var asc = blocks.slice().reverse();
|
||||
var intervals = [];
|
||||
for (var j = 1; j < asc.length; j++) {
|
||||
var prev = new Date(asc[j - 1].time).getTime();
|
||||
var cur = new Date(asc[j].time).getTime();
|
||||
intervals.push(Math.max(0, (cur - prev) / 1000));
|
||||
}
|
||||
var avg = 0;
|
||||
if (intervals.length) {
|
||||
var sum = 0;
|
||||
for (var k = 0; k < intervals.length; k++) sum += intervals[k];
|
||||
avg = sum / intervals.length;
|
||||
}
|
||||
document.getElementById('sBlockTime').textContent = avg ? avg.toFixed(2) + 's' : '—';
|
||||
|
||||
// Card stats
|
||||
document.getElementById('sTxs').textContent = net.total_txs || 0;
|
||||
document.getElementById('sTransfers').textContent = 'Transfers: ' + (net.total_transfers || 0);
|
||||
document.getElementById('sRelayProofs').textContent = net.total_relay_proofs || 0;
|
||||
document.getElementById('sRelayCount').textContent = (net.relay_count || 0) + ' relay nodes';
|
||||
document.getElementById('sValidators').textContent = net.validator_count || 0;
|
||||
|
||||
// Series for charts
|
||||
state.txSeries = asc.map(function(b) { return Number(b.tx_count || 0); });
|
||||
state.intervalSeries = intervals;
|
||||
|
||||
// Cumulative tx sparkline (supply proxy — shows chain activity growth)
|
||||
var cum = 0;
|
||||
state.supplyHistory = asc.map(function(b) { cum += Number(b.tx_count || 0); return cum; });
|
||||
state.tpsSeries = state.txSeries;
|
||||
|
||||
renderCharts();
|
||||
renderStripCharts();
|
||||
}
|
||||
|
||||
/* ── renderRecentBlocks ─────────────────────────────────────────────────── */
|
||||
|
||||
function renderRecentBlocks(blocks) {
|
||||
var tbody = document.getElementById('blocksBody');
|
||||
if (!tbody) return;
|
||||
if (!blocks || !blocks.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="tbl-empty">No blocks yet.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = '';
|
||||
var shown = blocks.slice(0, 20);
|
||||
for (var i = 0; i < shown.length; i++) {
|
||||
var b = shown[i];
|
||||
var val = b.validator || b.proposer || '';
|
||||
var valShort = val ? C.shortAddress(val) : '—';
|
||||
var valCell = val
|
||||
? '<a href="/address?address=' + encodeURIComponent(val) + '" title="' + C.esc(val) + '">' + C.esc(valShort) + '</a>'
|
||||
: '—';
|
||||
var fees = b.total_fees_ut != null ? C.toToken(Number(b.total_fees_ut)) : '—';
|
||||
rows +=
|
||||
'<tr>' +
|
||||
'<td>' + linkBlock(b.index) + '</td>' +
|
||||
'<td class="mono">' + C.esc(C.short(b.hash, 20)) + '</td>' +
|
||||
'<td class="mono">' + valCell + '</td>' +
|
||||
'<td>' + C.esc(b.tx_count || 0) + '</td>' +
|
||||
'<td>' + C.esc(fees) + '</td>' +
|
||||
'<td title="' + C.esc(C.fmtTime(b.time)) + '">' + C.esc(C.timeAgo(b.time)) + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
tbody.innerHTML = rows;
|
||||
C.refreshIcons();
|
||||
}
|
||||
|
||||
/* ── Block detail panel ─────────────────────────────────────────────────── */
|
||||
|
||||
function showBlockPanel(data) {
|
||||
var panel = document.getElementById('blockDetailPanel');
|
||||
var raw = document.getElementById('blockRaw');
|
||||
if (!panel || !raw) return;
|
||||
raw.textContent = JSON.stringify(data, null, 2);
|
||||
panel.style.display = '';
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function hideBlockPanel() {
|
||||
var panel = document.getElementById('blockDetailPanel');
|
||||
if (panel) panel.style.display = 'none';
|
||||
}
|
||||
|
||||
async function loadBlock(index) {
|
||||
C.setStatus('Loading block #' + index + '…', 'warn');
|
||||
try {
|
||||
var b = await C.fetchJSON('/api/block/' + encodeURIComponent(index));
|
||||
showBlockPanel(b);
|
||||
C.setStatus('Block #' + index + ' loaded.', 'ok');
|
||||
} catch (e) {
|
||||
C.setStatus('Block load failed: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Main refresh ───────────────────────────────────────────────────────── */
|
||||
|
||||
async function refreshDashboard() {
|
||||
try {
|
||||
var data = await Promise.all([
|
||||
C.fetchJSON('/api/netstats'),
|
||||
C.fetchJSON('/api/blocks?limit=36'),
|
||||
]);
|
||||
renderStats(data[0] || {}, data[1] || []);
|
||||
renderRecentBlocks(data[1] || []);
|
||||
C.setStatus('Updated at ' + new Date().toLocaleTimeString(), 'ok');
|
||||
C.refreshIcons();
|
||||
} catch (e) {
|
||||
C.setStatus('Refresh failed: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Search ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
function handleSearch() {
|
||||
var raw = (document.getElementById('searchInput').value || '').trim();
|
||||
if (!raw) return;
|
||||
|
||||
// Pure integer → block lookup
|
||||
if (/^\d+$/.test(raw)) {
|
||||
loadBlock(raw);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hex tx id (32 bytes = 64 chars) → tx page
|
||||
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
|
||||
C.navAddress(raw);
|
||||
return;
|
||||
}
|
||||
|
||||
// Short hex (16 chars) or partial → try as tx id
|
||||
if (/^[0-9a-fA-F]{16,63}$/.test(raw)) {
|
||||
C.navTx(raw);
|
||||
return;
|
||||
}
|
||||
|
||||
// DC address or any remaining string → address page
|
||||
C.navAddress(raw);
|
||||
}
|
||||
|
||||
/* ── Event wiring ───────────────────────────────────────────────────────── */
|
||||
|
||||
var searchBtn = document.getElementById('searchBtn');
|
||||
if (searchBtn) searchBtn.addEventListener('click', handleSearch);
|
||||
|
||||
var openNodeBtn = document.getElementById('openNodeBtn');
|
||||
if (openNodeBtn) {
|
||||
openNodeBtn.addEventListener('click', function() {
|
||||
var raw = (document.getElementById('searchInput').value || '').trim();
|
||||
if (raw && C.isPubKey(raw)) {
|
||||
window.location.href = '/node?node=' + encodeURIComponent(raw);
|
||||
} else {
|
||||
window.location.href = '/validators';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
});
|
||||
}
|
||||
|
||||
// Block link clicks inside the blocks table
|
||||
document.addEventListener('click', function(e) {
|
||||
var t = e.target;
|
||||
if (!t) return;
|
||||
var link = t.closest ? t.closest('a.block-link') : null;
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
loadBlock(link.dataset.index);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Redraw on resize (both full-size and strip charts)
|
||||
window.addEventListener('resize', function() {
|
||||
renderCharts();
|
||||
renderStripCharts();
|
||||
});
|
||||
|
||||
/* ── Boot ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
// Polling fallback — active when SSE is unavailable or disconnected.
|
||||
var pollTimer = null;
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return;
|
||||
pollTimer = setInterval(refreshDashboard, 10000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||
}
|
||||
|
||||
var bootPromise = refreshDashboard();
|
||||
|
||||
// SSE live feed — stop polling while connected; resume on disconnect.
|
||||
var sseConn = C.connectSSE({
|
||||
connected: function() {
|
||||
stopPolling();
|
||||
C.setStatus('● LIVE', 'ok');
|
||||
},
|
||||
block: function(/*ev*/) {
|
||||
// A new block was committed — refresh everything immediately.
|
||||
refreshDashboard();
|
||||
},
|
||||
error: function() {
|
||||
startPolling();
|
||||
C.setStatus('Offline — polling every 10s', 'warn');
|
||||
}
|
||||
});
|
||||
|
||||
// If SSE is not supported by the browser, fall back to polling immediately.
|
||||
if (!sseConn) startPolling();
|
||||
|
||||
// Handle /?block=N — navigated here from tx page block link
|
||||
var blockParam = C.q('block');
|
||||
if (blockParam) {
|
||||
bootPromise.then(function() { loadBlock(blockParam); });
|
||||
}
|
||||
|
||||
})();
|
||||
197
node/explorer/common.js
Normal file
197
node/explorer/common.js
Normal file
@@ -0,0 +1,197 @@
|
||||
(function() {
|
||||
function esc(v) {
|
||||
return String(v === undefined || v === null ? '' : v)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function short(v, n) {
|
||||
if (!v) return '-';
|
||||
return v.length > n ? v.slice(0, n) + '...' : v;
|
||||
}
|
||||
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '-';
|
||||
return iso.replace('T', ' ').replace('Z', ' UTC');
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '-';
|
||||
var now = Date.now();
|
||||
var ts = new Date(iso).getTime();
|
||||
if (!isFinite(ts)) return '-';
|
||||
var diffSec = Math.max(0, Math.floor((now - ts) / 1000));
|
||||
if (diffSec < 10) return 'a few seconds ago';
|
||||
if (diffSec < 60) return diffSec + ' seconds ago';
|
||||
var diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return diffMin === 1 ? 'a minute ago' : diffMin + ' minutes ago';
|
||||
var diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return diffHour === 1 ? 'an hour ago' : diffHour + ' hours ago';
|
||||
var diffDay = Math.floor(diffHour / 24);
|
||||
if (diffDay === 1) return 'yesterday';
|
||||
if (diffDay < 30) return diffDay + ' days ago';
|
||||
var diffMonth = Math.floor(diffDay / 30);
|
||||
if (diffMonth < 12) return diffMonth === 1 ? 'a month ago' : diffMonth + ' months ago';
|
||||
var diffYear = Math.floor(diffMonth / 12);
|
||||
return diffYear === 1 ? 'a year ago' : diffYear + ' years ago';
|
||||
}
|
||||
|
||||
function shortAddress(addr) {
|
||||
if (!addr) return '-';
|
||||
if (addr.length <= 9) return addr;
|
||||
return addr.slice(0, 3) + '...' + addr.slice(-3);
|
||||
}
|
||||
|
||||
function txLabel(eventType) {
|
||||
var map = {
|
||||
TRANSFER: 'Transfer',
|
||||
REGISTER_KEY: 'Register',
|
||||
CREATE_CHANNEL: 'Create Channel',
|
||||
ADD_MEMBER: 'Add Member',
|
||||
OPEN_PAY_CHAN: 'Open Channel',
|
||||
CLOSE_PAY_CHAN: 'Close Channel',
|
||||
RELAY_PROOF: 'Relay Proof',
|
||||
BIND_WALLET: 'Bind Wallet',
|
||||
SLASH: 'Slash',
|
||||
HEARTBEAT: 'Heartbeat',
|
||||
BLOCK_REWARD: 'Reward'
|
||||
};
|
||||
return map[eventType] || eventType || 'Transaction';
|
||||
}
|
||||
|
||||
function toToken(micro) {
|
||||
if (micro === undefined || micro === null) return '-';
|
||||
var n = Number(micro);
|
||||
if (isNaN(n)) return String(micro);
|
||||
return (n / 1000000).toFixed(6) + ' T';
|
||||
}
|
||||
|
||||
// Compact token display: 21000000 T → "21M T", 1234 T → "1.23k T"
|
||||
function _fmtNum(n) {
|
||||
if (n >= 100) return Math.round(n).toString();
|
||||
if (n >= 10) return n.toFixed(1).replace(/\.0$/, '');
|
||||
return n.toFixed(2).replace(/\.?0+$/, '');
|
||||
}
|
||||
function toTokenShort(micro) {
|
||||
if (micro === undefined || micro === null) return '-';
|
||||
var t = Number(micro) / 1000000;
|
||||
if (isNaN(t)) return String(micro);
|
||||
if (t >= 1e9) return _fmtNum(t / 1e9) + 'B T';
|
||||
if (t >= 1e6) return _fmtNum(t / 1e6) + 'M T';
|
||||
if (t >= 1e3) return _fmtNum(t / 1e3) + 'k T';
|
||||
if (t >= 0.01) return _fmtNum(t) + ' T';
|
||||
var ut = Number(micro);
|
||||
if (ut >= 1000) return _fmtNum(ut / 1000) + 'k µT';
|
||||
return ut + ' µT';
|
||||
}
|
||||
|
||||
function isPubKey(s) {
|
||||
return /^[0-9a-fA-F]{64}$/.test(s || '');
|
||||
}
|
||||
|
||||
function setStatus(text, cls) {
|
||||
var el = document.getElementById('status');
|
||||
if (!el) return;
|
||||
el.className = 'status' + (cls ? ' ' + cls : '');
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
async function fetchJSON(url) {
|
||||
var resp = await fetch(url);
|
||||
var json = await resp.json().catch(function() { return {}; });
|
||||
if (!resp.ok) {
|
||||
throw new Error(json && json.error ? json.error : 'request failed');
|
||||
}
|
||||
if (json && json.error) {
|
||||
throw new Error(json.error);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
function q(name) {
|
||||
return new URLSearchParams(window.location.search).get(name) || '';
|
||||
}
|
||||
|
||||
function navAddress(address) {
|
||||
window.location.href = '/address?address=' + encodeURIComponent(address);
|
||||
}
|
||||
|
||||
function navTx(id) {
|
||||
window.location.href = '/tx?id=' + encodeURIComponent(id);
|
||||
}
|
||||
|
||||
function navNode(node) {
|
||||
window.location.href = '/node?node=' + encodeURIComponent(node);
|
||||
}
|
||||
|
||||
function refreshIcons() {
|
||||
if (window.lucide && typeof window.lucide.createIcons === 'function') {
|
||||
window.lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE live event stream ─────────────────────────────────────────────────
|
||||
//
|
||||
// connectSSE(handlers) opens a connection to GET /api/events and dispatches
|
||||
// events to the supplied handler map, e.g.:
|
||||
//
|
||||
// C.connectSSE({
|
||||
// block: function(data) { ... },
|
||||
// tx: function(data) { ... },
|
||||
// contract_log: function(data) { ... },
|
||||
// connected: function() { /* SSE connection established */ },
|
||||
// error: function() { /* connection lost */ },
|
||||
// });
|
||||
//
|
||||
// Returns the EventSource instance so the caller can close it if needed.
|
||||
function connectSSE(handlers) {
|
||||
if (!window.EventSource) return null; // browser doesn't support SSE
|
||||
|
||||
var es = new EventSource('/api/events');
|
||||
|
||||
es.addEventListener('open', function() {
|
||||
if (handlers.connected) handlers.connected();
|
||||
});
|
||||
|
||||
es.addEventListener('error', function() {
|
||||
if (handlers.error) handlers.error();
|
||||
});
|
||||
|
||||
['block', 'tx', 'contract_log'].forEach(function(type) {
|
||||
if (!handlers[type]) return;
|
||||
es.addEventListener(type, function(e) {
|
||||
try {
|
||||
var data = JSON.parse(e.data);
|
||||
handlers[type](data);
|
||||
} catch (_) {}
|
||||
});
|
||||
});
|
||||
|
||||
return es;
|
||||
}
|
||||
|
||||
window.ExplorerCommon = {
|
||||
esc: esc,
|
||||
short: short,
|
||||
fmtTime: fmtTime,
|
||||
timeAgo: timeAgo,
|
||||
shortAddress: shortAddress,
|
||||
txLabel: txLabel,
|
||||
toToken: toToken,
|
||||
toTokenShort: toTokenShort,
|
||||
isPubKey: isPubKey,
|
||||
setStatus: setStatus,
|
||||
fetchJSON: fetchJSON,
|
||||
q: q,
|
||||
navAddress: navAddress,
|
||||
navTx: navTx,
|
||||
navNode: navNode,
|
||||
refreshIcons: refreshIcons,
|
||||
connectSSE: connectSSE
|
||||
};
|
||||
|
||||
refreshIcons();
|
||||
})();
|
||||
222
node/explorer/contract.html
Normal file
222
node/explorer/contract.html
Normal file
@@ -0,0 +1,222 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Contract | DChain Explorer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/explorer/style.css">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script defer src="/assets/explorer/common.js"></script>
|
||||
<script defer src="/assets/explorer/contract.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topnav">
|
||||
<div class="topnav-inner">
|
||||
<a class="topnav-brand" href="/">
|
||||
<span class="brand-gem">◆</span>
|
||||
<span class="brand-name">DChain</span>
|
||||
</a>
|
||||
<nav class="topnav-links">
|
||||
<a href="/">Explorer</a>
|
||||
<a href="/tokens">Tokens</a>
|
||||
<a href="/contract" style="color:var(--text)">Contracts</a>
|
||||
<a href="/validators">Validators</a>
|
||||
<a href="/relays">Relay Nodes</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- search bar strip -->
|
||||
<div class="addr-searchbar">
|
||||
<div class="addr-searchbar-inner">
|
||||
<div class="hero-search" style="max-width:640px">
|
||||
<i data-lucide="search" class="hero-search-icon"></i>
|
||||
<input id="contractInput" type="text" placeholder="Contract ID (hex)…">
|
||||
<button id="contractBtn" class="btn-hero">Load</button>
|
||||
</div>
|
||||
<div id="status" class="hero-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Contracts list (shown when no contract is selected) ──────────────── -->
|
||||
<main class="page-body" id="contractsList">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span><i data-lucide="code-2" style="width:16px;height:16px;vertical-align:middle;margin-right:6px"></i>Deployed Contracts</span>
|
||||
<span id="contractsCount" class="text-muted" style="font-size:0.85rem"></span>
|
||||
</div>
|
||||
<div id="contractsEmpty" style="padding:2rem 1.25rem;color:var(--muted);font-size:0.9rem;display:none">
|
||||
No contracts deployed yet.
|
||||
</div>
|
||||
<div class="table-wrap" id="contractsTableWrap" style="display:none">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contract ID</th>
|
||||
<th>Deployer</th>
|
||||
<th style="width:7rem">Block</th>
|
||||
<th style="width:6rem">WASM</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="contractsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="contractsLoading" style="padding:2rem 1.25rem;color:var(--muted);font-size:0.9rem">
|
||||
Loading…
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main class="page-body" id="mainContent" style="display:none">
|
||||
|
||||
<!-- ── Banner ──────────────────────────────────────────────────────────── -->
|
||||
<div class="tx-banner tx-banner-ok" id="contractBanner">
|
||||
<div class="tx-banner-left">
|
||||
<div class="tx-banner-icon tx-banner-ok">
|
||||
<i data-lucide="code-2"></i>
|
||||
</div>
|
||||
<div class="tx-banner-body">
|
||||
<div class="tx-banner-title">Smart Contract</div>
|
||||
<div class="tx-banner-desc mono" id="bannerContractId">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tx-banner-time" id="bannerDeployedAt">—</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Main panel ───────────────────────────────────────────────────────── -->
|
||||
<div class="panel tx-overview-panel">
|
||||
|
||||
<!-- tab row -->
|
||||
<div class="tx-tabs">
|
||||
<button class="tx-tab tx-tab-active" id="tabOverview">Overview</button>
|
||||
<button class="tx-tab" id="tabState">State</button>
|
||||
<button class="tx-tab" id="tabLogs">Logs</button>
|
||||
<button class="tx-tab" id="tabRaw">Raw JSON</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Overview ── -->
|
||||
<div id="paneOverview">
|
||||
<div class="addr-kv-list">
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="hash"></i> Contract ID</div>
|
||||
<div class="addr-kv-val">
|
||||
<span class="mono" id="contractId">—</span>
|
||||
<button class="copy-btn" data-copy-id="contractId" title="Copy"><i data-lucide="copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="user"></i> Deployer</div>
|
||||
<div class="addr-kv-val">
|
||||
<span id="contractDeployer">—</span>
|
||||
<button class="copy-btn" data-copy-id="contractDeployerRaw" title="Copy pubkey"><i data-lucide="copy"></i></button>
|
||||
<span id="contractDeployerRaw" style="display:none"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="layers-3"></i> Deployed at block</div>
|
||||
<div class="addr-kv-val"><span id="contractDeployedBlock">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="package"></i> WASM size</div>
|
||||
<div class="addr-kv-val"><span id="contractWasmSize">—</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ABI methods -->
|
||||
<div id="abiSection" style="display:none">
|
||||
<div style="padding: 0 1.25rem 0.5rem; font-size:0.8rem; font-weight:600; text-transform:uppercase; letter-spacing:0.06em; color:var(--muted)">
|
||||
<i data-lucide="braces" style="width:12px;height:12px;vertical-align:middle"></i> ABI Methods
|
||||
</div>
|
||||
<div class="table-wrap" style="margin: 0 0 1rem">
|
||||
<table>
|
||||
<thead><tr><th>Method</th><th>Arguments</th></tr></thead>
|
||||
<tbody id="abiMethodsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── State browser ── -->
|
||||
<div id="paneState" style="display:none">
|
||||
<div style="padding:1rem 1.25rem 0.5rem">
|
||||
<div class="hero-search" style="max-width:480px">
|
||||
<i data-lucide="key" class="hero-search-icon"></i>
|
||||
<input id="stateKeyInput" type="text" placeholder="State key (e.g. counter)…">
|
||||
<button id="stateLoadBtn" class="btn-hero">Query</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="stateResult" style="display:none;padding:0 1.25rem 1rem">
|
||||
<div class="addr-kv-list" style="margin-top:0.75rem">
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="key"></i> Key</div>
|
||||
<div class="addr-kv-val"><span class="mono" id="stateKey">—</span></div>
|
||||
</div>
|
||||
<div class="addr-kv-row" id="stateU64Row" style="display:none">
|
||||
<div class="addr-kv-key"><i data-lucide="hash"></i> uint64</div>
|
||||
<div class="addr-kv-val"><span class="mono" id="stateU64">—</span></div>
|
||||
</div>
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="binary"></i> hex</div>
|
||||
<div class="addr-kv-val">
|
||||
<span class="mono addr-pubkey-text" id="stateHex">—</span>
|
||||
<button class="copy-btn" data-copy-id="stateHex" title="Copy"><i data-lucide="copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="file-text"></i> base64</div>
|
||||
<div class="addr-kv-val">
|
||||
<span class="mono addr-pubkey-text" id="stateB64">—</span>
|
||||
<button class="copy-btn" data-copy-id="stateB64" title="Copy"><i data-lucide="copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="addr-kv-row" id="stateNullRow" style="display:none">
|
||||
<div class="addr-kv-key"><i data-lucide="circle-off"></i> Value</div>
|
||||
<div class="addr-kv-val"><span class="text-muted">null (key not set)</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="stateEmpty" style="padding:1.5rem 1.25rem; color:var(--muted); font-size:0.9rem">
|
||||
Enter a state key and click Query.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Logs ── -->
|
||||
<div id="paneLogs" style="display:none">
|
||||
<div id="logsEmpty" style="padding:1.5rem 1.25rem; color:var(--muted); font-size:0.9rem">
|
||||
No log entries yet. Logs are written by <code>env.log()</code> calls inside the contract.
|
||||
</div>
|
||||
<div class="table-wrap" id="logsTableWrap" style="display:none">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:6rem">Block</th>
|
||||
<th style="width:8rem">Tx</th>
|
||||
<th style="width:3rem">#</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Raw JSON ── -->
|
||||
<div id="paneRaw" style="display:none">
|
||||
<pre id="contractRaw" class="raw tx-raw-pre">No data.</pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
295
node/explorer/contract.js
Normal file
295
node/explorer/contract.js
Normal file
@@ -0,0 +1,295 @@
|
||||
(function() {
|
||||
var C = window.ExplorerCommon;
|
||||
|
||||
var currentContractID = '';
|
||||
|
||||
/* ── Tab switching ───────────────────────────────────────────────────────── */
|
||||
|
||||
function switchTab(active) {
|
||||
var tabs = ['Overview', 'State', 'Logs', 'Raw'];
|
||||
tabs.forEach(function(name) {
|
||||
var btn = document.getElementById('tab' + name);
|
||||
var pane = document.getElementById('pane' + name);
|
||||
if (!btn || !pane) return;
|
||||
var isActive = (name === active);
|
||||
btn.className = 'tx-tab' + (isActive ? ' tx-tab-active' : '');
|
||||
pane.style.display = isActive ? '' : 'none';
|
||||
});
|
||||
// Lazy-load logs when tab is opened
|
||||
if (active === 'Logs' && currentContractID) {
|
||||
loadLogs(currentContractID);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('tabOverview').addEventListener('click', function() { switchTab('Overview'); });
|
||||
document.getElementById('tabState').addEventListener('click', function() { switchTab('State'); });
|
||||
document.getElementById('tabLogs').addEventListener('click', function() { switchTab('Logs'); });
|
||||
document.getElementById('tabRaw').addEventListener('click', function() { switchTab('Raw'); });
|
||||
|
||||
/* ── ABI rendering ───────────────────────────────────────────────────────── */
|
||||
|
||||
function renderABI(abiJson) {
|
||||
var abi = null;
|
||||
try {
|
||||
abi = typeof abiJson === 'string' ? JSON.parse(abiJson) : abiJson;
|
||||
} catch (e) { return; }
|
||||
if (!abi || !Array.isArray(abi.methods) || abi.methods.length === 0) return;
|
||||
|
||||
var tbody = document.getElementById('abiMethodsBody');
|
||||
tbody.innerHTML = '';
|
||||
abi.methods.forEach(function(m) {
|
||||
var args = Array.isArray(m.args) && m.args.length > 0
|
||||
? m.args.map(function(a) { return C.esc(a.name || '') + ': ' + C.esc(a.type || '?'); }).join(', ')
|
||||
: '<span class="text-muted">none</span>';
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td><span class="tx-type-badge">' + C.esc(m.name || '?') + '</span></td>' +
|
||||
'<td class="mono" style="font-size:0.82rem">' + args + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
document.getElementById('abiSection').style.display = '';
|
||||
}
|
||||
|
||||
/* ── Main render ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function renderContract(contract) {
|
||||
document.title = 'Contract ' + C.short(contract.contract_id, 16) + ' | DChain Explorer';
|
||||
currentContractID = contract.contract_id;
|
||||
|
||||
// Banner
|
||||
document.getElementById('bannerContractId').textContent = contract.contract_id || '—';
|
||||
document.getElementById('bannerDeployedAt').textContent =
|
||||
contract.deployed_at != null ? 'Block #' + contract.deployed_at : '—';
|
||||
|
||||
// Overview fields
|
||||
document.getElementById('contractId').textContent = contract.contract_id || '—';
|
||||
|
||||
if (contract.deployer_pub) {
|
||||
document.getElementById('contractDeployer').innerHTML =
|
||||
'<a href="/address?address=' + encodeURIComponent(contract.deployer_pub) + '">' +
|
||||
C.esc(C.shortAddress(contract.deployer_pub)) + '</a>';
|
||||
document.getElementById('contractDeployerRaw').textContent = contract.deployer_pub;
|
||||
} else {
|
||||
document.getElementById('contractDeployer').textContent = '—';
|
||||
}
|
||||
|
||||
document.getElementById('contractDeployedBlock').textContent =
|
||||
contract.deployed_at != null ? '#' + contract.deployed_at : '—';
|
||||
|
||||
document.getElementById('contractWasmSize').textContent =
|
||||
contract.wasm_size != null ? contract.wasm_size.toLocaleString() + ' bytes' : '—';
|
||||
|
||||
// ABI
|
||||
if (contract.abi_json) {
|
||||
renderABI(contract.abi_json);
|
||||
}
|
||||
|
||||
// Raw JSON
|
||||
document.getElementById('contractRaw').textContent = JSON.stringify(contract, null, 2);
|
||||
|
||||
document.getElementById('mainContent').style.display = '';
|
||||
C.refreshIcons();
|
||||
}
|
||||
|
||||
/* ── Contracts list ──────────────────────────────────────────────────────── */
|
||||
|
||||
async function loadContractsList() {
|
||||
try {
|
||||
var data = await C.fetchJSON('/api/contracts');
|
||||
var contracts = data.contracts || [];
|
||||
var loading = document.getElementById('contractsLoading');
|
||||
var empty = document.getElementById('contractsEmpty');
|
||||
var wrap = document.getElementById('contractsTableWrap');
|
||||
var count = document.getElementById('contractsCount');
|
||||
if (loading) loading.style.display = 'none';
|
||||
count.textContent = contracts.length + ' contract' + (contracts.length !== 1 ? 's' : '');
|
||||
if (contracts.length === 0) {
|
||||
empty.style.display = '';
|
||||
return;
|
||||
}
|
||||
var tbody = document.getElementById('contractsBody');
|
||||
tbody.innerHTML = '';
|
||||
contracts.forEach(function(c) {
|
||||
var tr = document.createElement('tr');
|
||||
var idShort = C.short(c.contract_id, 20);
|
||||
var deployer = c.deployer_pub ? C.shortAddress(c.deployer_pub) : '—';
|
||||
var deployerHref = c.deployer_pub
|
||||
? '<a href="/address?address=' + encodeURIComponent(c.deployer_pub) + '" class="mono" style="font-size:0.82rem">' + C.esc(deployer) + '</a>'
|
||||
: '<span class="text-muted">—</span>';
|
||||
tr.innerHTML =
|
||||
'<td><a href="/contract?id=' + encodeURIComponent(c.contract_id) + '" class="mono" style="font-size:0.82rem">' + C.esc(idShort) + '</a></td>' +
|
||||
'<td>' + deployerHref + '</td>' +
|
||||
'<td class="text-muted">' + (c.deployed_at != null ? '#' + c.deployed_at : '—') + '</td>' +
|
||||
'<td class="text-muted" style="font-size:0.8rem">' + (c.wasm_size != null ? c.wasm_size.toLocaleString() + ' B' : '—') + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
wrap.style.display = '';
|
||||
C.refreshIcons();
|
||||
} catch (e) {
|
||||
var loading = document.getElementById('contractsLoading');
|
||||
if (loading) loading.textContent = 'Failed to load contracts: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Load contract ───────────────────────────────────────────────────────── */
|
||||
|
||||
async function loadContract(id) {
|
||||
if (!id) return;
|
||||
C.setStatus('Loading…', 'warn');
|
||||
document.getElementById('contractsList').style.display = 'none';
|
||||
document.getElementById('mainContent').style.display = 'none';
|
||||
document.getElementById('abiSection').style.display = 'none';
|
||||
switchTab('Overview');
|
||||
try {
|
||||
var contract = await C.fetchJSON('/api/contracts/' + encodeURIComponent(id));
|
||||
renderContract(contract);
|
||||
window.history.replaceState({}, '', '/contract?id=' + encodeURIComponent(id));
|
||||
C.setStatus('', '');
|
||||
} catch (e) {
|
||||
C.setStatus('Contract not found: ' + e.message, 'err');
|
||||
document.getElementById('contractsList').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── State browser ───────────────────────────────────────────────────────── */
|
||||
|
||||
async function queryState(key) {
|
||||
if (!currentContractID || !key) return;
|
||||
C.setStatus('Querying state…', 'warn');
|
||||
document.getElementById('stateResult').style.display = 'none';
|
||||
document.getElementById('stateEmpty').style.display = 'none';
|
||||
try {
|
||||
var data = await C.fetchJSON(
|
||||
'/api/contracts/' + encodeURIComponent(currentContractID) +
|
||||
'/state/' + encodeURIComponent(key)
|
||||
);
|
||||
C.setStatus('', '');
|
||||
|
||||
document.getElementById('stateKey').textContent = data.key || key;
|
||||
|
||||
if (data.value_hex === null || data.value_hex === undefined) {
|
||||
// Key not set
|
||||
document.getElementById('stateU64Row').style.display = 'none';
|
||||
document.getElementById('stateHex').textContent = '—';
|
||||
document.getElementById('stateB64').textContent = '—';
|
||||
document.getElementById('stateNullRow').style.display = '';
|
||||
} else {
|
||||
document.getElementById('stateNullRow').style.display = 'none';
|
||||
document.getElementById('stateHex').textContent = data.value_hex || '—';
|
||||
document.getElementById('stateB64').textContent = data.value_b64 || '—';
|
||||
if (data.value_u64 !== null && data.value_u64 !== undefined) {
|
||||
document.getElementById('stateU64').textContent = data.value_u64.toLocaleString();
|
||||
document.getElementById('stateU64Row').style.display = '';
|
||||
} else {
|
||||
document.getElementById('stateU64Row').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('stateResult').style.display = '';
|
||||
C.refreshIcons();
|
||||
} catch (e) {
|
||||
C.setStatus('State query failed: ' + e.message, 'err');
|
||||
document.getElementById('stateEmpty').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('stateLoadBtn').addEventListener('click', function() {
|
||||
var key = (document.getElementById('stateKeyInput').value || '').trim();
|
||||
if (key) queryState(key);
|
||||
});
|
||||
|
||||
document.getElementById('stateKeyInput').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') document.getElementById('stateLoadBtn').click();
|
||||
});
|
||||
|
||||
/* ── Logs ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
var logsLoaded = false;
|
||||
|
||||
function makeLogRow(entry) {
|
||||
var tr = document.createElement('tr');
|
||||
var txShort = entry.tx_id ? C.short(entry.tx_id, 12) : '—';
|
||||
var txLink = entry.tx_id
|
||||
? '<a href="/tx?id=' + encodeURIComponent(entry.tx_id) + '" class="mono" style="font-size:0.8rem">' + C.esc(txShort) + '</a>'
|
||||
: '<span class="text-muted">—</span>';
|
||||
tr.innerHTML =
|
||||
'<td><a href="/?block=' + entry.block_height + '">#' + entry.block_height + '</a></td>' +
|
||||
'<td>' + txLink + '</td>' +
|
||||
'<td class="text-muted" style="font-size:0.8rem">' + entry.seq + '</td>' +
|
||||
'<td class="mono" style="font-size:0.85rem;word-break:break-all">' + C.esc(entry.message) + '</td>';
|
||||
return tr;
|
||||
}
|
||||
|
||||
async function loadLogs(contractID) {
|
||||
if (logsLoaded) return;
|
||||
logsLoaded = true;
|
||||
try {
|
||||
var data = await C.fetchJSON('/api/contracts/' + encodeURIComponent(contractID) + '/logs?limit=100');
|
||||
var logs = data.logs || [];
|
||||
if (logs.length === 0) {
|
||||
document.getElementById('logsEmpty').style.display = '';
|
||||
document.getElementById('logsTableWrap').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
var tbody = document.getElementById('logsBody');
|
||||
tbody.innerHTML = '';
|
||||
logs.forEach(function(entry) { tbody.appendChild(makeLogRow(entry)); });
|
||||
document.getElementById('logsEmpty').style.display = 'none';
|
||||
document.getElementById('logsTableWrap').style.display = '';
|
||||
} catch (e) {
|
||||
document.getElementById('logsEmpty').textContent = 'Failed to load logs: ' + e.message;
|
||||
document.getElementById('logsEmpty').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// SSE — prepend live contract_log entries for the currently viewed contract.
|
||||
C.connectSSE({
|
||||
contract_log: function(entry) {
|
||||
if (!currentContractID || entry.contract_id !== currentContractID) return;
|
||||
var tbody = document.getElementById('logsBody');
|
||||
if (!tbody) return;
|
||||
tbody.insertBefore(makeLogRow(entry), tbody.firstChild);
|
||||
document.getElementById('logsEmpty').style.display = 'none';
|
||||
document.getElementById('logsTableWrap').style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Copy buttons ────────────────────────────────────────────────────────── */
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var t = e.target;
|
||||
if (!t) return;
|
||||
var btn = t.closest ? t.closest('.copy-btn') : null;
|
||||
if (btn) {
|
||||
var src = document.getElementById(btn.dataset.copyId);
|
||||
if (src) {
|
||||
navigator.clipboard.writeText(src.textContent || '').catch(function() {});
|
||||
btn.classList.add('copy-btn-done');
|
||||
setTimeout(function() { btn.classList.remove('copy-btn-done'); }, 1200);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Wiring ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
document.getElementById('contractBtn').addEventListener('click', function() {
|
||||
var val = (document.getElementById('contractInput').value || '').trim();
|
||||
if (val) {
|
||||
logsLoaded = false;
|
||||
loadContract(val);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('contractInput').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') document.getElementById('contractBtn').click();
|
||||
});
|
||||
|
||||
var initial = C.q('id');
|
||||
if (initial) {
|
||||
document.getElementById('contractInput').value = initial;
|
||||
loadContract(initial);
|
||||
} else {
|
||||
loadContractsList();
|
||||
}
|
||||
|
||||
})();
|
||||
166
node/explorer/index.html
Normal file
166
node/explorer/index.html
Normal file
@@ -0,0 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DChain Explorer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/explorer/style.css">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script defer src="/assets/explorer/common.js"></script>
|
||||
<script defer src="/assets/explorer/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- top nav -->
|
||||
<header class="topnav">
|
||||
<div class="topnav-inner">
|
||||
<a class="topnav-brand" href="/">
|
||||
<span class="brand-gem">◆</span>
|
||||
<span class="brand-name">DChain</span>
|
||||
</a>
|
||||
<nav class="topnav-links">
|
||||
<a href="/contract">Contracts</a>
|
||||
<a href="/tokens">Tokens</a>
|
||||
<a href="/validators">Validators</a>
|
||||
<a href="/relays">Relay Nodes</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- hero -->
|
||||
<section class="hero">
|
||||
<div class="hero-inner">
|
||||
<h1 class="hero-title">
|
||||
<span class="hero-gem">◆</span>
|
||||
DChain Explorer
|
||||
</h1>
|
||||
<p class="hero-sub">Ed25519 blockchain · PBFT consensus · NaCl E2E messaging</p>
|
||||
|
||||
<div class="hero-search">
|
||||
<i data-lucide="search" class="hero-search-icon"></i>
|
||||
<input id="searchInput" type="text"
|
||||
placeholder="Search by address, public key, tx id or block number…">
|
||||
<button id="searchBtn" class="btn-hero">Search</button>
|
||||
</div>
|
||||
|
||||
<div class="hero-actions">
|
||||
<a href="/validators" class="pill-link"><i data-lucide="shield-check"></i> Validators</a>
|
||||
<a href="/relays" class="pill-link"><i data-lucide="radio"></i> Relay Nodes</a>
|
||||
<button id="openNodeBtn" class="pill-link pill-btn"><i data-lucide="server"></i> Node Stats</button>
|
||||
</div>
|
||||
|
||||
<div id="status" class="hero-status">Loading…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- stats strip (Tonviewer-style) -->
|
||||
<section class="stats-strip">
|
||||
<div class="strip-inner">
|
||||
|
||||
<div class="strip-cell strip-cell-chart">
|
||||
<div class="strip-label">Total Supply</div>
|
||||
<div class="strip-val" id="sSupply">—</div>
|
||||
<canvas id="supplyChart" class="strip-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="strip-divider"></div>
|
||||
|
||||
<div class="strip-cell">
|
||||
<div class="strip-label">Chain Height</div>
|
||||
<div class="strip-val mono" id="sHeight">—</div>
|
||||
<div class="strip-sub" id="sLastTime">—</div>
|
||||
</div>
|
||||
|
||||
<div class="strip-divider"></div>
|
||||
|
||||
<div class="strip-cell">
|
||||
<div class="strip-label">Avg Block Time</div>
|
||||
<div class="strip-val" id="sBlockTime">—</div>
|
||||
<div class="strip-sub">recent window</div>
|
||||
</div>
|
||||
|
||||
<div class="strip-divider"></div>
|
||||
|
||||
<div class="strip-cell strip-cell-chart">
|
||||
<div class="strip-label">Current TPS</div>
|
||||
<div class="strip-val" id="sTps">—</div>
|
||||
<canvas id="tpsChart" class="strip-chart"></canvas>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- main content -->
|
||||
<main class="page-body">
|
||||
|
||||
<!-- stat cards -->
|
||||
<div class="cards-row">
|
||||
<div class="card">
|
||||
<div class="card-key"><i data-lucide="layers-3"></i> Last Block</div>
|
||||
<div class="card-val mono" id="sLastBlock">—</div>
|
||||
<div class="card-sub mono" id="sLastHash">—</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-key"><i data-lucide="arrow-left-right"></i> Total Transactions</div>
|
||||
<div class="card-val" id="sTxs">—</div>
|
||||
<div class="card-sub" id="sTransfers">—</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-key"><i data-lucide="zap"></i> Relay Proofs</div>
|
||||
<div class="card-val" id="sRelayProofs">—</div>
|
||||
<div class="card-sub" id="sRelayCount">—</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-key"><i data-lucide="shield-check"></i> Validators</div>
|
||||
<div class="card-val" id="sValidators">—</div>
|
||||
<div class="card-sub" id="sTpsWindow">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- charts row -->
|
||||
<div class="charts-row">
|
||||
<div class="panel">
|
||||
<h2><i data-lucide="bar-chart-3"></i> Transactions per Block</h2>
|
||||
<div class="chart-wrap"><canvas id="txChart"></canvas></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2><i data-lucide="line-chart"></i> Block Interval (sec)</h2>
|
||||
<div class="chart-wrap"><canvas id="intervalChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- recent blocks -->
|
||||
<div class="panel">
|
||||
<h2><i data-lucide="blocks"></i> Recent Blocks</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="blocksTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Block</th>
|
||||
<th>Hash</th>
|
||||
<th>Validator</th>
|
||||
<th>Txs</th>
|
||||
<th>Fees</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="blocksBody">
|
||||
<tr><td colspan="6" class="tbl-empty">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- block json viewer (search result) -->
|
||||
<div class="panel" id="blockDetailPanel" style="display:none">
|
||||
<h2><i data-lucide="file-json"></i> Block Details</h2>
|
||||
<pre id="blockRaw" class="raw"></pre>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
146
node/explorer/node.html
Normal file
146
node/explorer/node.html
Normal file
@@ -0,0 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Node | DChain Explorer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/explorer/style.css">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script defer src="/assets/explorer/common.js"></script>
|
||||
<script defer src="/assets/explorer/node.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topnav">
|
||||
<div class="topnav-inner">
|
||||
<a class="topnav-brand" href="/">
|
||||
<span class="brand-gem">◆</span>
|
||||
<span class="brand-name">DChain</span>
|
||||
</a>
|
||||
<nav class="topnav-links">
|
||||
<a href="/contract">Contracts</a>
|
||||
<a href="/tokens">Tokens</a>
|
||||
<a href="/validators">Validators</a>
|
||||
<a href="/relays">Relay Nodes</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- search bar strip -->
|
||||
<div class="addr-searchbar">
|
||||
<div class="addr-searchbar-inner">
|
||||
<div class="hero-search" style="max-width:640px">
|
||||
<i data-lucide="search" class="hero-search-icon"></i>
|
||||
<input id="nodeInput" type="text" placeholder="Node public key or DC address…">
|
||||
<button id="nodeBtn" class="btn-hero">Load</button>
|
||||
</div>
|
||||
<div id="status" class="hero-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="page-body" id="mainContent" style="display:none">
|
||||
|
||||
<!-- ── Node profile card ──────────────────────────────────────────────── -->
|
||||
<div class="addr-profile-card">
|
||||
<div class="addr-profile-left">
|
||||
<div class="addr-avatar" style="background:linear-gradient(135deg,#1a3a3a 0%,#0d2030 100%)">
|
||||
<i data-lucide="server" style="width:22px;height:22px;color:var(--ok)"></i>
|
||||
</div>
|
||||
<div class="addr-profile-info">
|
||||
<div class="addr-name" id="nodeNickname">Node</div>
|
||||
<div class="addr-badges" id="nodeBadges"></div>
|
||||
<div class="mono" style="font-size:12px;color:var(--muted);margin-top:4px" id="nodeAddressShort">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="addr-profile-right">
|
||||
<div class="addr-bal-label">Node Balance</div>
|
||||
<div class="addr-bal-val" id="nodeBalanceVal">—</div>
|
||||
<div class="addr-bal-sub" id="nodeReputationRank">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Identity / binding details ────────────────────────────────────── -->
|
||||
<div class="panel addr-detail-panel">
|
||||
<div class="addr-kv-list">
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="at-sign"></i> Node Address</div>
|
||||
<div class="addr-kv-val">
|
||||
<a class="mono" id="nodeAddress" href="#">—</a>
|
||||
<button class="copy-btn" data-copy-id="nodeAddressRaw" title="Copy"><i data-lucide="copy"></i></button>
|
||||
<span id="nodeAddressRaw" style="display:none"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="key-round"></i> Public Key</div>
|
||||
<div class="addr-kv-val">
|
||||
<span class="mono addr-pubkey-text" id="nodePubKey">—</span>
|
||||
<button class="copy-btn" data-copy-id="nodePubKey" title="Copy"><i data-lucide="copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row" id="bindingRow" style="display:none">
|
||||
<div class="addr-kv-key"><i data-lucide="wallet"></i> Bound Wallet</div>
|
||||
<div class="addr-kv-val">
|
||||
<a id="bindingLink" href="#">—</a>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:12px" id="bindingBalance">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Stats grid ─────────────────────────────────────────────────────── -->
|
||||
<div class="panel addr-node-panel">
|
||||
<h2><i data-lucide="bar-chart-3"></i> Performance</h2>
|
||||
<div class="addr-node-grid" style="grid-template-columns:repeat(4,minmax(0,1fr))">
|
||||
<div class="addr-node-cell">
|
||||
<div class="addr-node-label">Reputation Score</div>
|
||||
<div class="addr-node-val" id="repScore">—</div>
|
||||
<div class="addr-node-sub" id="repRank">—</div>
|
||||
</div>
|
||||
<div class="addr-node-cell">
|
||||
<div class="addr-node-label">Blocks Produced</div>
|
||||
<div class="addr-node-val" id="repBlocks">—</div>
|
||||
<div class="addr-node-sub" id="recentProduced">—</div>
|
||||
</div>
|
||||
<div class="addr-node-cell">
|
||||
<div class="addr-node-label">Relay Proofs</div>
|
||||
<div class="addr-node-val" id="repRelay">—</div>
|
||||
</div>
|
||||
<div class="addr-node-cell">
|
||||
<div class="addr-node-label">Heartbeats</div>
|
||||
<div class="addr-node-val" id="repHeartbeats">—</div>
|
||||
<div class="addr-node-sub" id="repSlash" style="color:var(--err)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Rewards ────────────────────────────────────────────────────────── -->
|
||||
<div class="panel addr-node-panel">
|
||||
<h2><i data-lucide="coins"></i> Rewards</h2>
|
||||
<div class="addr-node-grid" style="grid-template-columns:repeat(3,minmax(0,1fr))">
|
||||
<div class="addr-node-cell">
|
||||
<div class="addr-node-label">Recent Rewards</div>
|
||||
<div class="addr-node-val" id="recentRewards">—</div>
|
||||
<div class="addr-node-sub" id="windowBlocks">—</div>
|
||||
</div>
|
||||
<div class="addr-node-cell">
|
||||
<div class="addr-node-label">Lifetime Base Reward</div>
|
||||
<div class="addr-node-val" id="lifetimeReward">—</div>
|
||||
</div>
|
||||
<div class="addr-node-cell">
|
||||
<div class="addr-node-label">Node Balance</div>
|
||||
<div class="addr-node-val" id="nodeBalance">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
110
node/explorer/node.js
Normal file
110
node/explorer/node.js
Normal file
@@ -0,0 +1,110 @@
|
||||
(function() {
|
||||
var C = window.ExplorerCommon;
|
||||
|
||||
function badge(text, variant) {
|
||||
return '<span class="addr-badge addr-badge-' + variant + '">' + C.esc(text) + '</span>';
|
||||
}
|
||||
|
||||
function renderNode(data) {
|
||||
document.title = 'Node ' + C.short(data.address || data.pub_key || '', 16) + ' | DChain Explorer';
|
||||
|
||||
// Profile card
|
||||
document.getElementById('nodeNickname').textContent = data.address ? C.short(data.address, 24) : 'Node';
|
||||
document.getElementById('nodeAddressShort').textContent = data.pub_key ? C.short(data.pub_key, 32) : '—';
|
||||
document.getElementById('nodeBalanceVal').textContent = data.node_balance || C.toToken(data.node_balance_ut || 0);
|
||||
document.getElementById('nodeReputationRank').textContent = 'Rank: ' + (data.reputation_rank || '—');
|
||||
|
||||
// Badges
|
||||
var badges = '';
|
||||
if (data.blocks_produced > 0) badges += badge('Validator', 'accent');
|
||||
if (data.relay_proofs > 0) badges += badge('Relay Node', 'relay');
|
||||
if (!badges) badges = badge('Observer', 'muted');
|
||||
document.getElementById('nodeBadges').innerHTML = badges;
|
||||
|
||||
// Address field
|
||||
var addrEl = document.getElementById('nodeAddress');
|
||||
addrEl.textContent = data.address || '—';
|
||||
addrEl.href = data.pub_key ? '/address?address=' + encodeURIComponent(data.pub_key) : '#';
|
||||
document.getElementById('nodeAddressRaw').textContent = data.address || '';
|
||||
|
||||
// PubKey field
|
||||
document.getElementById('nodePubKey').textContent = data.pub_key || '—';
|
||||
|
||||
// Wallet binding
|
||||
if (data.wallet_binding_address) {
|
||||
var link = document.getElementById('bindingLink');
|
||||
link.textContent = data.wallet_binding_address;
|
||||
link.href = '/address?address=' + encodeURIComponent(data.wallet_binding_pub_key || data.wallet_binding_address);
|
||||
document.getElementById('bindingBalance').textContent = data.wallet_binding_balance || '—';
|
||||
document.getElementById('bindingRow').style.display = '';
|
||||
} else {
|
||||
document.getElementById('bindingRow').style.display = 'none';
|
||||
}
|
||||
|
||||
// Stats
|
||||
document.getElementById('repScore').textContent = String(data.reputation_score || 0);
|
||||
document.getElementById('repRank').textContent = data.reputation_rank || '—';
|
||||
document.getElementById('repBlocks').textContent = String(data.blocks_produced || 0);
|
||||
var rw = data.recent_window_blocks || 0;
|
||||
var rp = data.recent_blocks_produced || 0;
|
||||
document.getElementById('recentProduced').textContent = 'last ' + rw + ' blocks: ' + rp;
|
||||
document.getElementById('repRelay').textContent = String(data.relay_proofs || 0);
|
||||
document.getElementById('repHeartbeats').textContent = String(data.heartbeats || 0);
|
||||
var slash = data.slash_count || 0;
|
||||
document.getElementById('repSlash').textContent = slash > 0 ? slash + ' slashes' : '';
|
||||
|
||||
// Rewards
|
||||
document.getElementById('recentRewards').textContent = data.recent_rewards || C.toToken(data.recent_rewards_ut || 0);
|
||||
document.getElementById('windowBlocks').textContent = 'window: last ' + rw + ' blocks';
|
||||
document.getElementById('lifetimeReward').textContent = data.lifetime_base_reward || C.toToken(0);
|
||||
document.getElementById('nodeBalance').textContent = data.node_balance || C.toToken(data.node_balance_ut || 0);
|
||||
|
||||
document.getElementById('mainContent').style.display = '';
|
||||
C.refreshIcons();
|
||||
}
|
||||
|
||||
async function loadNode(nodeID) {
|
||||
if (!nodeID) return;
|
||||
C.setStatus('Loading…', 'warn');
|
||||
document.getElementById('mainContent').style.display = 'none';
|
||||
try {
|
||||
var data = await C.fetchJSON('/api/node/' + encodeURIComponent(nodeID) + '?window=300');
|
||||
renderNode(data);
|
||||
window.history.replaceState({}, '', '/node?node=' + encodeURIComponent(nodeID));
|
||||
C.setStatus('', '');
|
||||
} catch (e) {
|
||||
C.setStatus('Load failed: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Copy buttons ────────────────────────────────────────────────────────── */
|
||||
document.addEventListener('click', function(e) {
|
||||
var t = e.target;
|
||||
if (!t) return;
|
||||
var btn = t.closest ? t.closest('.copy-btn') : null;
|
||||
if (btn) {
|
||||
var src = document.getElementById(btn.dataset.copyId);
|
||||
if (src) {
|
||||
navigator.clipboard.writeText(src.textContent || '').catch(function() {});
|
||||
btn.classList.add('copy-btn-done');
|
||||
setTimeout(function() { btn.classList.remove('copy-btn-done'); }, 1200);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Wiring ────────────────────────────────────────────────────────────── */
|
||||
document.getElementById('nodeBtn').addEventListener('click', function() {
|
||||
var val = (document.getElementById('nodeInput').value || '').trim();
|
||||
if (val) loadNode(val);
|
||||
});
|
||||
|
||||
document.getElementById('nodeInput').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') document.getElementById('nodeBtn').click();
|
||||
});
|
||||
|
||||
var initial = C.q('node') || C.q('pk');
|
||||
if (initial) {
|
||||
document.getElementById('nodeInput').value = initial;
|
||||
loadNode(initial);
|
||||
}
|
||||
})();
|
||||
78
node/explorer/relays.html
Normal file
78
node/explorer/relays.html
Normal file
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Relay Nodes | DChain Explorer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/explorer/style.css">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script defer src="/assets/explorer/common.js"></script>
|
||||
<script defer src="/assets/explorer/relays.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topnav">
|
||||
<div class="topnav-inner">
|
||||
<a class="topnav-brand" href="/">
|
||||
<span class="brand-gem">◆</span>
|
||||
<span class="brand-name">DChain</span>
|
||||
</a>
|
||||
<nav class="topnav-links">
|
||||
<a href="/contract">Contracts</a>
|
||||
<a href="/tokens">Tokens</a>
|
||||
<a href="/validators">Validators</a>
|
||||
<a href="/relays" style="color:var(--text)">Relay Nodes</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page-body">
|
||||
|
||||
<!-- page header -->
|
||||
<div class="list-page-header">
|
||||
<div class="list-page-header-left">
|
||||
<div class="list-page-icon list-page-icon-relay"><i data-lucide="radio"></i></div>
|
||||
<div>
|
||||
<h1 class="list-page-title">Relay Nodes</h1>
|
||||
<p class="list-page-sub">
|
||||
Nodes registered on-chain as NaCl E2E relay service providers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-page-header-right">
|
||||
<div class="list-page-stat">
|
||||
<div class="list-page-stat-val" id="relayCount">—</div>
|
||||
<div class="list-page-stat-label">Relays</div>
|
||||
</div>
|
||||
<button id="refreshBtn" class="pill-btn"><i data-lucide="refresh-cw"></i> Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="hero-status" style="margin-bottom:12px"></div>
|
||||
|
||||
<div class="panel" style="padding:0">
|
||||
<div class="table-wrap">
|
||||
<table id="relayTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:36px">#</th>
|
||||
<th>Node Address</th>
|
||||
<th>X25519 Relay Key</th>
|
||||
<th>Fee / msg</th>
|
||||
<th>Multiaddr</th>
|
||||
<th style="width:100px">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="relayBody">
|
||||
<tr><td colspan="6" class="tbl-empty">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
68
node/explorer/relays.js
Normal file
68
node/explorer/relays.js
Normal file
@@ -0,0 +1,68 @@
|
||||
(function() {
|
||||
var C = window.ExplorerCommon;
|
||||
|
||||
function fmtFee(ut) {
|
||||
if (!ut) return '<span class="text-muted">Free</span>';
|
||||
if (ut < 1000) return ut + ' µT';
|
||||
return C.toToken(ut);
|
||||
}
|
||||
|
||||
async function loadRelays() {
|
||||
C.setStatus('Loading…', 'warn');
|
||||
try {
|
||||
var data = await C.fetchJSON('/api/relays');
|
||||
var relays = Array.isArray(data) ? data : [];
|
||||
|
||||
document.getElementById('relayCount').textContent = relays.length;
|
||||
|
||||
var tbody = document.getElementById('relayBody');
|
||||
if (!relays.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="tbl-empty">No relay nodes registered yet.</td></tr>';
|
||||
C.setStatus('No relay nodes found.', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = '';
|
||||
relays.forEach(function(info, i) {
|
||||
var pubKey = info.pub_key || '';
|
||||
var addr = info.address || '—';
|
||||
var x25519 = (info.relay && info.relay.x25519_pub_key) || '—';
|
||||
var feeUT = (info.relay && info.relay.fee_per_msg_ut) || 0;
|
||||
var multiaddr = (info.relay && info.relay.multiaddr) || '';
|
||||
|
||||
rows +=
|
||||
'<tr>' +
|
||||
'<td class="text-muted" style="font-size:12px">' + (i + 1) + '</td>' +
|
||||
'<td>' +
|
||||
(pubKey
|
||||
? '<a class="mono" href="/address?address=' + encodeURIComponent(pubKey) + '">' + C.esc(addr) + '</a>'
|
||||
: C.esc(addr)) +
|
||||
'</td>' +
|
||||
'<td class="mono" style="font-size:11px;color:var(--muted)">' +
|
||||
C.esc(C.short(x25519, 28)) +
|
||||
'</td>' +
|
||||
'<td>' + fmtFee(feeUT) + '</td>' +
|
||||
'<td class="mono" style="font-size:11px;color:var(--muted);max-width:200px;overflow:hidden;text-overflow:ellipsis">' +
|
||||
(multiaddr ? C.esc(multiaddr) : '<span class="text-muted">—</span>') +
|
||||
'</td>' +
|
||||
'<td>' +
|
||||
(pubKey
|
||||
? '<a href="/node?node=' + encodeURIComponent(pubKey) + '" class="pill-link" style="font-size:12px;padding:4px 10px">' +
|
||||
'<i data-lucide="server"></i> Node' +
|
||||
'</a>'
|
||||
: '') +
|
||||
'</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
tbody.innerHTML = rows;
|
||||
|
||||
C.setStatus(relays.length + ' relay node' + (relays.length !== 1 ? 's' : '') + ' registered.', 'ok');
|
||||
C.refreshIcons();
|
||||
} catch (e) {
|
||||
C.setStatus('Load failed: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadRelays);
|
||||
loadRelays();
|
||||
})();
|
||||
1704
node/explorer/style.css
Normal file
1704
node/explorer/style.css
Normal file
File diff suppressed because it is too large
Load Diff
89
node/explorer/token.html
Normal file
89
node/explorer/token.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Token | DChain Explorer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/explorer/style.css">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script defer src="/assets/explorer/common.js"></script>
|
||||
<script defer src="/assets/explorer/token.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topnav">
|
||||
<div class="topnav-inner">
|
||||
<a class="topnav-brand" href="/">
|
||||
<span class="brand-gem">◆</span>
|
||||
<span class="brand-name">DChain</span>
|
||||
</a>
|
||||
<nav class="topnav-links">
|
||||
<a href="/contract">Contracts</a>
|
||||
<a href="/tokens" style="color:var(--text)">Tokens</a>
|
||||
<a href="/validators">Validators</a>
|
||||
<a href="/relays">Relay Nodes</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="addr-searchbar">
|
||||
<div class="addr-searchbar-inner">
|
||||
<div id="status" class="hero-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="page-body" id="mainContent" style="display:none">
|
||||
|
||||
<!-- ── Banner ─────────────────────────────────────────────────────────── -->
|
||||
<div class="tx-banner tx-banner-ok" id="banner">
|
||||
<div class="tx-banner-left">
|
||||
<div class="tx-banner-icon tx-banner-ok" id="bannerIcon">
|
||||
<i data-lucide="coins"></i>
|
||||
</div>
|
||||
<div class="tx-banner-body">
|
||||
<div class="tx-banner-title" id="bannerType">Token</div>
|
||||
<div class="tx-banner-desc" id="bannerName">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tx-banner-time" id="bannerBlock">block —</div>
|
||||
</div>
|
||||
|
||||
<!-- NFT image (shown only for NFTs with image URI) -->
|
||||
<div id="nftImageWrap" style="display:none;text-align:center;margin-bottom:1rem">
|
||||
<img id="nftImage" style="max-width:340px;border-radius:14px;border:1px solid var(--line)" src="" alt="">
|
||||
</div>
|
||||
|
||||
<!-- ── Detail panel ───────────────────────────────────────────────────── -->
|
||||
<div class="panel tx-overview-panel">
|
||||
|
||||
<div class="tx-tabs">
|
||||
<button class="tx-tab tx-tab-active" id="tabOverview">Overview</button>
|
||||
<button class="tx-tab" id="tabRaw" style="display:none">Raw JSON</button>
|
||||
</div>
|
||||
|
||||
<div id="paneOverview">
|
||||
<div class="addr-kv-list" id="kvList">
|
||||
<!-- filled by JS -->
|
||||
</div>
|
||||
|
||||
<!-- Attributes (NFT) -->
|
||||
<div id="attrsSection" style="display:none;padding:0 1.25rem 1rem">
|
||||
<div style="font-size:0.8rem;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);margin-bottom:0.5rem">
|
||||
<i data-lucide="tag" style="width:12px;height:12px;vertical-align:middle"></i> Attributes
|
||||
</div>
|
||||
<div id="attrsGrid" class="attrs-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="paneRaw" style="display:none">
|
||||
<pre id="rawJSON" class="raw tx-raw-pre">No data.</pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
180
node/explorer/token.js
Normal file
180
node/explorer/token.js
Normal file
@@ -0,0 +1,180 @@
|
||||
(function() {
|
||||
var C = window.ExplorerCommon;
|
||||
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var tokenID = params.get('id');
|
||||
var nftID = params.get('nft');
|
||||
|
||||
function kv(icon, label, valHtml) {
|
||||
return '<div class="addr-kv-row">' +
|
||||
'<div class="addr-kv-key"><i data-lucide="' + icon + '"></i> ' + label + '</div>' +
|
||||
'<div class="addr-kv-val">' + valHtml + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function copyBtn(id) {
|
||||
return '<button class="copy-btn" data-copy-id="' + id + '" title="Copy"><i data-lucide="copy"></i></button>';
|
||||
}
|
||||
|
||||
function hiddenSpan(id, val) {
|
||||
return '<span id="' + id + '" style="display:none">' + C.esc(val) + '</span>';
|
||||
}
|
||||
|
||||
/* ── Fungible token ──────────────────────────────────────────────────────── */
|
||||
|
||||
async function loadToken() {
|
||||
C.setStatus('Loading token…', 'warn');
|
||||
try {
|
||||
var data = await C.fetchJSON('/api/tokens/' + tokenID);
|
||||
if (!data || !data.token_id) { C.setStatus('Token not found.', 'err'); return; }
|
||||
|
||||
document.title = data.symbol + ' | DChain Explorer';
|
||||
document.getElementById('bannerType').textContent = 'Fungible Token';
|
||||
document.getElementById('bannerName').textContent = data.name + ' (' + data.symbol + ')';
|
||||
document.getElementById('bannerBlock').textContent = 'block ' + (data.issued_at || 0);
|
||||
document.getElementById('bannerIcon').querySelector('i').setAttribute('data-lucide', 'coins');
|
||||
|
||||
var d = data.decimals || 0;
|
||||
var supplyFmt = formatSupply(data.total_supply || 0, d);
|
||||
|
||||
var rows = '';
|
||||
rows += kv('hash', 'Token ID',
|
||||
'<span class="mono addr-pubkey-text" id="tid">' + C.esc(data.token_id) + '</span>' + copyBtn('tid'));
|
||||
rows += kv('type', 'Symbol',
|
||||
'<span class="token-sym">' + C.esc(data.symbol) + '</span>');
|
||||
rows += kv('tag', 'Name', C.esc(data.name));
|
||||
rows += kv('layers-3', 'Decimals', '<span class="mono">' + d + '</span>');
|
||||
rows += kv('bar-chart-2', 'Total Supply',
|
||||
'<span class="mono">' + C.esc(supplyFmt) + '</span>');
|
||||
rows += kv('user', 'Issuer',
|
||||
'<a href="/address?address=' + encodeURIComponent(data.issuer) + '" class="mono">' +
|
||||
C.short(data.issuer, 20) +
|
||||
'</a>' +
|
||||
hiddenSpan('issuerRaw', data.issuer) + copyBtn('issuerRaw'));
|
||||
rows += kv('blocks', 'Issued at block',
|
||||
'<span class="mono">' + (data.issued_at || 0) + '</span>');
|
||||
|
||||
document.getElementById('kvList').innerHTML = rows;
|
||||
document.getElementById('rawJSON').textContent = JSON.stringify(data, null, 2);
|
||||
document.getElementById('tabRaw').style.display = '';
|
||||
|
||||
document.getElementById('mainContent').style.display = '';
|
||||
C.setStatus('', '');
|
||||
C.refreshIcons();
|
||||
C.wireClipboard();
|
||||
} catch(e) {
|
||||
C.setStatus('Load failed: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── NFT ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
async function loadNFT() {
|
||||
C.setStatus('Loading NFT…', 'warn');
|
||||
try {
|
||||
var data = await C.fetchJSON('/api/nfts/' + nftID);
|
||||
var n = data && data.nft ? data.nft : data;
|
||||
if (!n || !n.nft_id) { C.setStatus('NFT not found.', 'err'); return; }
|
||||
|
||||
document.title = n.name + ' | DChain Explorer';
|
||||
document.getElementById('bannerType').textContent = n.burned ? 'NFT (Burned)' : 'NFT';
|
||||
document.getElementById('bannerName').textContent = n.name;
|
||||
document.getElementById('bannerBlock').textContent = 'minted block ' + (n.minted_at || 0);
|
||||
document.getElementById('bannerIcon').querySelector('i').setAttribute('data-lucide', 'image');
|
||||
if (n.burned) document.getElementById('banner').classList.add('tx-banner-err');
|
||||
|
||||
// Show image if URI looks like an image
|
||||
if (n.uri && /\.(png|jpg|jpeg|gif|svg|webp)/i.test(n.uri)) {
|
||||
document.getElementById('nftImage').src = n.uri;
|
||||
document.getElementById('nftImageWrap').style.display = '';
|
||||
}
|
||||
|
||||
var ownerAddr = data.owner_address || '';
|
||||
var rows = '';
|
||||
rows += kv('hash', 'NFT ID',
|
||||
'<span class="mono addr-pubkey-text" id="nid">' + C.esc(n.nft_id) + '</span>' + copyBtn('nid'));
|
||||
rows += kv('tag', 'Name', C.esc(n.name));
|
||||
if (n.description)
|
||||
rows += kv('file-text', 'Description', C.esc(n.description));
|
||||
if (n.uri)
|
||||
rows += kv('link', 'Metadata URI',
|
||||
'<a href="' + C.esc(n.uri) + '" target="_blank" class="mono" style="word-break:break-all">' + C.esc(n.uri) + '</a>');
|
||||
if (!n.burned && n.owner) {
|
||||
rows += kv('user', 'Owner',
|
||||
'<a href="/address?address=' + encodeURIComponent(n.owner) + '" class="mono">' +
|
||||
C.short(ownerAddr || n.owner, 20) +
|
||||
'</a>' +
|
||||
hiddenSpan('ownerRaw', n.owner) + copyBtn('ownerRaw'));
|
||||
} else if (n.burned) {
|
||||
rows += kv('flame', 'Status', '<span style="color:var(--err)">Burned</span>');
|
||||
}
|
||||
rows += kv('user-check', 'Issuer',
|
||||
'<a href="/address?address=' + encodeURIComponent(n.issuer) + '" class="mono">' +
|
||||
C.short(n.issuer, 20) +
|
||||
'</a>');
|
||||
rows += kv('blocks', 'Minted at block',
|
||||
'<span class="mono">' + (n.minted_at || 0) + '</span>');
|
||||
|
||||
document.getElementById('kvList').innerHTML = rows;
|
||||
|
||||
// Attributes
|
||||
if (n.attributes) {
|
||||
try {
|
||||
var attrs = JSON.parse(n.attributes);
|
||||
var keys = Object.keys(attrs);
|
||||
if (keys.length) {
|
||||
var html = '';
|
||||
keys.forEach(function(k) {
|
||||
html += '<div class="attr-chip"><div class="attr-chip-key">' + C.esc(k) + '</div>' +
|
||||
'<div class="attr-chip-val">' + C.esc(String(attrs[k])) + '</div></div>';
|
||||
});
|
||||
document.getElementById('attrsGrid').innerHTML = html;
|
||||
document.getElementById('attrsSection').style.display = '';
|
||||
}
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
document.getElementById('rawJSON').textContent = JSON.stringify(data, null, 2);
|
||||
document.getElementById('tabRaw').style.display = '';
|
||||
document.getElementById('mainContent').style.display = '';
|
||||
C.setStatus('', '');
|
||||
C.refreshIcons();
|
||||
C.wireClipboard();
|
||||
} catch(e) {
|
||||
C.setStatus('Load failed: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
function formatSupply(supply, decimals) {
|
||||
if (decimals === 0) return supply.toLocaleString();
|
||||
var d = Math.pow(10, decimals);
|
||||
var whole = Math.floor(supply / d);
|
||||
var frac = supply % d;
|
||||
if (frac === 0) return whole.toLocaleString();
|
||||
return whole.toLocaleString() + '.' + String(frac).padStart(decimals, '0').replace(/0+$/, '');
|
||||
}
|
||||
|
||||
/* ── Tabs ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
document.getElementById('tabOverview').addEventListener('click', function() {
|
||||
document.getElementById('paneOverview').style.display = '';
|
||||
document.getElementById('paneRaw').style.display = 'none';
|
||||
document.getElementById('tabOverview').classList.add('tx-tab-active');
|
||||
document.getElementById('tabRaw').classList.remove('tx-tab-active');
|
||||
});
|
||||
document.getElementById('tabRaw').addEventListener('click', function() {
|
||||
document.getElementById('paneOverview').style.display = 'none';
|
||||
document.getElementById('paneRaw').style.display = '';
|
||||
document.getElementById('tabRaw').classList.add('tx-tab-active');
|
||||
document.getElementById('tabOverview').classList.remove('tx-tab-active');
|
||||
});
|
||||
|
||||
/* ── Boot ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
if (nftID) loadNFT();
|
||||
else if (tokenID) loadToken();
|
||||
else C.setStatus('No token or NFT ID provided.', 'err');
|
||||
|
||||
})();
|
||||
100
node/explorer/tokens.html
Normal file
100
node/explorer/tokens.html
Normal file
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Tokens & NFTs | DChain Explorer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/explorer/style.css">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script defer src="/assets/explorer/common.js"></script>
|
||||
<script defer src="/assets/explorer/tokens.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topnav">
|
||||
<div class="topnav-inner">
|
||||
<a class="topnav-brand" href="/">
|
||||
<span class="brand-gem">◆</span>
|
||||
<span class="brand-name">DChain</span>
|
||||
</a>
|
||||
<nav class="topnav-links">
|
||||
<a href="/contract">Contracts</a>
|
||||
<a href="/tokens" style="color:var(--text)">Tokens</a>
|
||||
<a href="/validators">Validators</a>
|
||||
<a href="/relays">Relay Nodes</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page-body">
|
||||
|
||||
<!-- page header -->
|
||||
<div class="list-page-header">
|
||||
<div class="list-page-header-left">
|
||||
<div class="list-page-icon list-page-icon-token"><i data-lucide="coins"></i></div>
|
||||
<div>
|
||||
<h1 class="list-page-title">Tokens & NFTs</h1>
|
||||
<p class="list-page-sub">
|
||||
Fungible tokens issued on-chain and non-fungible token collections.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-page-header-right">
|
||||
<div class="list-page-stat">
|
||||
<div class="list-page-stat-val" id="tokenCount">—</div>
|
||||
<div class="list-page-stat-label">Fungible</div>
|
||||
</div>
|
||||
<div class="list-page-stat">
|
||||
<div class="list-page-stat-val" id="nftCount">—</div>
|
||||
<div class="list-page-stat-label">NFTs</div>
|
||||
</div>
|
||||
<button id="refreshBtn" class="pill-btn"><i data-lucide="refresh-cw"></i> Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="hero-status" style="margin-bottom:12px"></div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tx-tabs" style="margin-bottom:12px;background:var(--surface);border-radius:10px;padding:0 1rem">
|
||||
<button class="tx-tab tx-tab-active" id="tabFungible">Fungible Tokens</button>
|
||||
<button class="tx-tab" id="tabNFT">NFTs</button>
|
||||
</div>
|
||||
|
||||
<!-- Fungible tokens table -->
|
||||
<div id="paneFungible">
|
||||
<div class="panel" style="padding:0">
|
||||
<div class="table-wrap">
|
||||
<table id="tokenTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:36px">#</th>
|
||||
<th>Symbol</th>
|
||||
<th>Name</th>
|
||||
<th style="width:80px">Decimals</th>
|
||||
<th>Total Supply</th>
|
||||
<th>Issuer</th>
|
||||
<th style="width:90px">Block</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tokenBody">
|
||||
<tr><td colspan="7" class="tbl-empty">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NFTs grid -->
|
||||
<div id="paneNFT" style="display:none">
|
||||
<div id="nftEmpty" class="panel" style="display:none;padding:2rem;text-align:center;color:var(--muted)">
|
||||
No NFTs minted yet.
|
||||
</div>
|
||||
<div id="nftGrid" class="nft-grid"></div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
121
node/explorer/tokens.js
Normal file
121
node/explorer/tokens.js
Normal file
@@ -0,0 +1,121 @@
|
||||
(function() {
|
||||
var C = window.ExplorerCommon;
|
||||
|
||||
var state = { tab: 'fungible' };
|
||||
|
||||
function switchTab(name) {
|
||||
state.tab = name;
|
||||
document.getElementById('paneFungible').style.display = name === 'fungible' ? '' : 'none';
|
||||
document.getElementById('paneNFT').style.display = name === 'nft' ? '' : 'none';
|
||||
document.getElementById('tabFungible').className = 'tx-tab' + (name === 'fungible' ? ' tx-tab-active' : '');
|
||||
document.getElementById('tabNFT').className = 'tx-tab' + (name === 'nft' ? ' tx-tab-active' : '');
|
||||
}
|
||||
|
||||
/* ── Fungible tokens ─────────────────────────────────────────────────────── */
|
||||
|
||||
function formatSupply(supply, decimals) {
|
||||
if (decimals === 0) return supply.toLocaleString();
|
||||
var d = Math.pow(10, decimals);
|
||||
var whole = Math.floor(supply / d);
|
||||
var frac = supply % d;
|
||||
if (frac === 0) return whole.toLocaleString();
|
||||
return whole.toLocaleString() + '.' + String(frac).padStart(decimals, '0').replace(/0+$/, '');
|
||||
}
|
||||
|
||||
async function loadTokens() {
|
||||
C.setStatus('Loading…', 'warn');
|
||||
try {
|
||||
var data = await C.fetchJSON('/api/tokens');
|
||||
var tokens = (data && Array.isArray(data.tokens)) ? data.tokens : [];
|
||||
document.getElementById('tokenCount').textContent = tokens.length;
|
||||
|
||||
var tbody = document.getElementById('tokenBody');
|
||||
if (!tokens.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="tbl-empty">No fungible tokens issued yet.</td></tr>';
|
||||
} else {
|
||||
var rows = '';
|
||||
tokens.forEach(function(t, i) {
|
||||
var supply = formatSupply(t.total_supply || 0, t.decimals || 0);
|
||||
rows +=
|
||||
'<tr>' +
|
||||
'<td class="text-muted" style="font-size:12px">' + (i+1) + '</td>' +
|
||||
'<td>' +
|
||||
'<a href="/token?id=' + encodeURIComponent(t.token_id) + '" class="token-sym">' +
|
||||
C.esc(t.symbol) +
|
||||
'</a>' +
|
||||
'</td>' +
|
||||
'<td>' + C.esc(t.name) + '</td>' +
|
||||
'<td class="mono text-muted">' + (t.decimals || 0) + '</td>' +
|
||||
'<td class="mono">' + C.esc(supply) + '</td>' +
|
||||
'<td>' +
|
||||
'<a href="/address?address=' + encodeURIComponent(t.issuer) + '" class="mono" style="font-size:12px">' +
|
||||
C.short(t.issuer, 20) +
|
||||
'</a>' +
|
||||
'</td>' +
|
||||
'<td class="mono text-muted" style="font-size:12px">' + (t.issued_at || 0) + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
tbody.innerHTML = rows;
|
||||
}
|
||||
C.refreshIcons();
|
||||
} catch(e) {
|
||||
C.setStatus('Load failed: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── NFTs ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
async function loadNFTs() {
|
||||
try {
|
||||
var data = await C.fetchJSON('/api/nfts');
|
||||
var nfts = (data && Array.isArray(data.nfts)) ? data.nfts : [];
|
||||
document.getElementById('nftCount').textContent = nfts.length;
|
||||
|
||||
var grid = document.getElementById('nftGrid');
|
||||
var empty = document.getElementById('nftEmpty');
|
||||
if (!nfts.length) {
|
||||
grid.innerHTML = '';
|
||||
empty.style.display = '';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
|
||||
var cards = '';
|
||||
nfts.forEach(function(n) {
|
||||
var burned = n.burned ? ' nft-card-burned' : '';
|
||||
var imgHtml = '';
|
||||
if (n.uri && /\.(png|jpg|jpeg|gif|svg|webp)/i.test(n.uri)) {
|
||||
imgHtml = '<img class="nft-card-img" src="' + C.esc(n.uri) + '" alt="" loading="lazy">';
|
||||
} else {
|
||||
imgHtml = '<div class="nft-card-img nft-card-placeholder"><i data-lucide="image"></i></div>';
|
||||
}
|
||||
cards +=
|
||||
'<a class="nft-card' + burned + '" href="/token?nft=' + encodeURIComponent(n.nft_id) + '">' +
|
||||
imgHtml +
|
||||
'<div class="nft-card-body">' +
|
||||
'<div class="nft-card-name">' + C.esc(n.name) + (n.burned ? ' <span class="badge-burned">BURNED</span>' : '') + '</div>' +
|
||||
'<div class="nft-card-id mono">' + C.short(n.nft_id, 16) + '</div>' +
|
||||
(n.owner ? '<div class="nft-card-owner text-muted">Owner: ' + C.short(n.owner, 16) + '</div>' : '') +
|
||||
'</div>' +
|
||||
'</a>';
|
||||
});
|
||||
grid.innerHTML = cards;
|
||||
C.refreshIcons();
|
||||
} catch(e) {
|
||||
document.getElementById('nftCount').textContent = '?';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Boot ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
document.getElementById('tabFungible').addEventListener('click', function() { switchTab('fungible'); });
|
||||
document.getElementById('tabNFT').addEventListener('click', function() { switchTab('nft'); });
|
||||
document.getElementById('refreshBtn').addEventListener('click', function() { loadTokens(); loadNFTs(); });
|
||||
|
||||
// Check URL hash for tab.
|
||||
if (window.location.hash === '#nft') switchTab('nft');
|
||||
|
||||
Promise.all([loadTokens(), loadNFTs()]).then(function() {
|
||||
C.setStatus('', '');
|
||||
});
|
||||
})();
|
||||
186
node/explorer/tx.html
Normal file
186
node/explorer/tx.html
Normal file
@@ -0,0 +1,186 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Transaction | DChain Explorer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/explorer/style.css">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script defer src="/assets/explorer/common.js"></script>
|
||||
<script defer src="/assets/explorer/tx.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- top nav -->
|
||||
<header class="topnav">
|
||||
<div class="topnav-inner">
|
||||
<a class="topnav-brand" href="/">
|
||||
<span class="brand-gem">◆</span>
|
||||
<span class="brand-name">DChain</span>
|
||||
</a>
|
||||
<nav class="topnav-links">
|
||||
<a href="/contract">Contracts</a>
|
||||
<a href="/tokens">Tokens</a>
|
||||
<a href="/validators">Validators</a>
|
||||
<a href="/relays">Relay Nodes</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- search bar strip -->
|
||||
<div class="addr-searchbar">
|
||||
<div class="addr-searchbar-inner">
|
||||
<div class="hero-search" style="max-width:640px">
|
||||
<i data-lucide="search" class="hero-search-icon"></i>
|
||||
<input id="txInput" type="text" placeholder="Transaction ID…">
|
||||
<button id="txBtn" class="btn-hero">Load</button>
|
||||
</div>
|
||||
<div id="status" class="hero-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="page-body" id="mainContent" style="display:none">
|
||||
|
||||
<!-- ── Status banner ────────────────────────────────────────────────── -->
|
||||
<div class="tx-banner" id="txBanner">
|
||||
<div class="tx-banner-left">
|
||||
<div class="tx-banner-icon" id="txBannerIcon">
|
||||
<i data-lucide="check-circle-2"></i>
|
||||
</div>
|
||||
<div class="tx-banner-body">
|
||||
<div class="tx-banner-title" id="txBannerTitle">Confirmed transaction</div>
|
||||
<div class="tx-banner-desc" id="txBannerDesc">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tx-banner-time" id="txBannerTime">—</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Overview table ────────────────────────────────────────────────── -->
|
||||
<div class="panel tx-overview-panel">
|
||||
|
||||
<!-- tab row -->
|
||||
<div class="tx-tabs">
|
||||
<button class="tx-tab tx-tab-active" id="tabOverview">Overview</button>
|
||||
<button class="tx-tab" id="tabRaw">Raw JSON</button>
|
||||
</div>
|
||||
|
||||
<!-- ── overview content ── -->
|
||||
<div id="paneOverview">
|
||||
|
||||
<!-- action table header -->
|
||||
<div class="tx-action-table">
|
||||
<div class="tx-action-hdr">
|
||||
<span>Action</span>
|
||||
<span>Route</span>
|
||||
<span>Payload / Memo</span>
|
||||
<span style="text-align:right">Value</span>
|
||||
</div>
|
||||
<div class="tx-action-row" id="txActionRow">
|
||||
<!-- filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- route flow diagram -->
|
||||
<div class="tx-flow" id="txFlow">
|
||||
<!-- filled by JS -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── raw JSON content ── -->
|
||||
<div id="paneRaw" style="display:none">
|
||||
<pre id="txRaw" class="raw tx-raw-pre">No data.</pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Technical details ─────────────────────────────────────────────── -->
|
||||
<div class="panel addr-detail-panel" id="txDetailPanel">
|
||||
<div class="addr-kv-list">
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="hash"></i> Transaction ID</div>
|
||||
<div class="addr-kv-val">
|
||||
<span class="mono" id="txId">—</span>
|
||||
<button class="copy-btn" data-copy-id="txId" title="Copy"><i data-lucide="copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="tag"></i> Type</div>
|
||||
<div class="addr-kv-val"><span id="txType">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row" id="txMemoRow" style="display:none">
|
||||
<div class="addr-kv-key"><i data-lucide="message-square"></i> Memo</div>
|
||||
<div class="addr-kv-val"><span id="txMemo">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="arrow-right-from-line"></i> From</div>
|
||||
<div class="addr-kv-val">
|
||||
<span id="txFrom">—</span>
|
||||
<button class="copy-btn" data-copy-id="txFromRaw" title="Copy pubkey"><i data-lucide="copy"></i></button>
|
||||
<span id="txFromRaw" style="display:none"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row" id="txToRow" style="display:none">
|
||||
<div class="addr-kv-key"><i data-lucide="arrow-right-to-line"></i> To</div>
|
||||
<div class="addr-kv-val">
|
||||
<span id="txTo">—</span>
|
||||
<button class="copy-btn" data-copy-id="txToRaw" title="Copy pubkey"><i data-lucide="copy"></i></button>
|
||||
<span id="txToRaw" style="display:none"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="coins"></i> Amount</div>
|
||||
<div class="addr-kv-val"><span class="tx-amount-val" id="txAmount">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="receipt"></i> Fee</div>
|
||||
<div class="addr-kv-val"><span id="txFee">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="layers-3"></i> Block</div>
|
||||
<div class="addr-kv-val">
|
||||
<a id="txBlockLink" href="#">—</a>
|
||||
<span class="tx-block-hash" id="txBlockHash"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row">
|
||||
<div class="addr-kv-key"><i data-lucide="clock"></i> Time</div>
|
||||
<div class="addr-kv-val">
|
||||
<span id="txTime">—</span>
|
||||
<span class="tx-time-ago" id="txTimeAgo"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addr-kv-row" id="txSigRow">
|
||||
<div class="addr-kv-key"><i data-lucide="shield-check"></i> Signature</div>
|
||||
<div class="addr-kv-val">
|
||||
<span class="mono addr-pubkey-text" id="txSig">—</span>
|
||||
<button class="copy-btn" data-copy-id="txSig" title="Copy"><i data-lucide="copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Payload details (shown when payload is a structured object) ───── -->
|
||||
<div class="panel" id="txPayloadPanel" style="display:none">
|
||||
<h2><i data-lucide="braces"></i> Payload</h2>
|
||||
<pre id="txPayloadPre" class="raw tx-raw-pre"></pre>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
334
node/explorer/tx.js
Normal file
334
node/explorer/tx.js
Normal file
@@ -0,0 +1,334 @@
|
||||
(function() {
|
||||
var C = window.ExplorerCommon;
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
function linkAddress(pubkey, addrDisplay) {
|
||||
var label = addrDisplay || C.shortAddress(pubkey);
|
||||
return '<a href="/address?address=' + encodeURIComponent(pubkey) + '">' + C.esc(label) + '</a>';
|
||||
}
|
||||
|
||||
function txTypeIcon(type) {
|
||||
var map = {
|
||||
TRANSFER: 'arrow-left-right',
|
||||
REGISTER_KEY: 'user-check',
|
||||
RELAY_PROOF: 'zap',
|
||||
HEARTBEAT: 'activity',
|
||||
BLOCK_REWARD: 'layers-3',
|
||||
BIND_WALLET: 'link',
|
||||
REGISTER_RELAY: 'radio',
|
||||
SLASH: 'alert-triangle',
|
||||
ADD_VALIDATOR: 'shield-plus',
|
||||
REMOVE_VALIDATOR: 'shield-minus',
|
||||
};
|
||||
return map[type] || 'circle';
|
||||
}
|
||||
|
||||
// Color class for the banner icon based on tx type
|
||||
function txBannerClass(type) {
|
||||
var pos = { BLOCK_REWARD: 1, RELAY_PROOF: 1, HEARTBEAT: 1, REGISTER_KEY: 1, REGISTER_RELAY: 1 };
|
||||
var warn = { SLASH: 1, REMOVE_VALIDATOR: 1 };
|
||||
if (warn[type]) return 'tx-banner-warn';
|
||||
if (pos[type]) return 'tx-banner-ok';
|
||||
return 'tx-banner-ok'; // default — all confirmed txs are "ok"
|
||||
}
|
||||
|
||||
// One-line human description of the tx
|
||||
function txDescription(tx) {
|
||||
var fromAddr = tx.from_addr ? C.shortAddress(tx.from_addr) : (tx.from ? C.shortAddress(tx.from) : '?');
|
||||
var toAddr = tx.to_addr ? C.shortAddress(tx.to_addr) : (tx.to ? C.shortAddress(tx.to) : '');
|
||||
var amt = tx.amount ? tx.amount : C.toToken(tx.amount_ut || 0);
|
||||
|
||||
switch (String(tx.type)) {
|
||||
case 'TRANSFER':
|
||||
return fromAddr + ' sent ' + amt + (toAddr ? ' to ' + toAddr : '');
|
||||
case 'BLOCK_REWARD':
|
||||
return 'Block #' + tx.block_index + ' fee reward' + (toAddr ? ' to ' + toAddr : '');
|
||||
case 'RELAY_PROOF':
|
||||
return fromAddr + ' submitted relay proof, earned ' + amt;
|
||||
case 'HEARTBEAT':
|
||||
return fromAddr + ' submitted heartbeat';
|
||||
case 'REGISTER_KEY': {
|
||||
var nick = '';
|
||||
if (tx.payload && tx.payload.nickname) nick = ' as "' + tx.payload.nickname + '"';
|
||||
return fromAddr + ' registered identity' + nick;
|
||||
}
|
||||
case 'REGISTER_RELAY':
|
||||
return fromAddr + ' registered as relay node';
|
||||
case 'BIND_WALLET':
|
||||
return fromAddr + ' bound wallet' + (toAddr ? ' → ' + toAddr : '');
|
||||
case 'ADD_VALIDATOR':
|
||||
return fromAddr + ' added ' + (toAddr || '?') + ' to validator set';
|
||||
case 'REMOVE_VALIDATOR':
|
||||
return fromAddr + ' removed ' + (toAddr || '?') + ' from validator set';
|
||||
case 'SLASH':
|
||||
return fromAddr + ' slashed ' + (toAddr || '?');
|
||||
default:
|
||||
return C.txLabel(tx.type) + (fromAddr ? ' by ' + fromAddr : '');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Flow diagram builder ─────────────────────────────────────────────── */
|
||||
|
||||
function buildFlowDiagram(tx) {
|
||||
var fromPub = tx.from || '';
|
||||
var fromAddr = tx.from_addr || (fromPub ? C.shortAddress(fromPub) : '');
|
||||
var toPub = tx.to || '';
|
||||
var toAddr = tx.to_addr || (toPub ? C.shortAddress(toPub) : '');
|
||||
var amt = tx.amount ? tx.amount : (tx.amount_ut ? C.toToken(tx.amount_ut) : '');
|
||||
var memo = tx.memo || '';
|
||||
|
||||
// Nodes
|
||||
var fromNode = '';
|
||||
var toNode = '';
|
||||
|
||||
if (fromPub) {
|
||||
fromNode =
|
||||
'<div class="tx-flow-node">' +
|
||||
'<div class="tx-flow-bubble tx-flow-from">' +
|
||||
(fromAddr ? fromAddr[0].toUpperCase() : '?') +
|
||||
'</div>' +
|
||||
'<div class="tx-flow-node-label">' +
|
||||
(fromPub ? '<a href="/address?address=' + encodeURIComponent(fromPub) + '">' + C.esc(fromAddr || C.shortAddress(fromPub)) + '</a>' : '—') +
|
||||
'</div>' +
|
||||
'<div class="tx-flow-node-sub">Sender</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
if (toPub) {
|
||||
toNode =
|
||||
'<div class="tx-flow-node">' +
|
||||
'<div class="tx-flow-bubble tx-flow-to">' +
|
||||
(toAddr ? toAddr[0].toUpperCase() : '?') +
|
||||
'</div>' +
|
||||
'<div class="tx-flow-node-label">' +
|
||||
'<a href="/address?address=' + encodeURIComponent(toPub) + '">' + C.esc(toAddr || C.shortAddress(toPub)) + '</a>' +
|
||||
'</div>' +
|
||||
'<div class="tx-flow-node-sub">Recipient</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// No route for system txs with no To
|
||||
if (!fromPub && !toPub) return '';
|
||||
|
||||
var arrowLabel = amt || C.txLabel(tx.type);
|
||||
var arrowSub = memo || '';
|
||||
|
||||
var arrow =
|
||||
'<div class="tx-flow-arrow">' +
|
||||
'<div class="tx-flow-arrow-label">' + C.esc(arrowLabel) + '</div>' +
|
||||
'<div class="tx-flow-arrow-line">' +
|
||||
'<div class="tx-flow-arrow-track"></div>' +
|
||||
'<i data-lucide="chevron-right" class="tx-flow-arrow-tip"></i>' +
|
||||
'</div>' +
|
||||
(arrowSub ? '<div class="tx-flow-arrow-sub">' + C.esc(arrowSub) + '</div>' : '') +
|
||||
'</div>';
|
||||
|
||||
if (fromNode && toNode) {
|
||||
return fromNode + arrow + toNode;
|
||||
}
|
||||
if (fromNode) return fromNode;
|
||||
return toNode;
|
||||
}
|
||||
|
||||
/* ── Main render ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function renderTx(tx) {
|
||||
// Page title
|
||||
document.title = C.txLabel(tx.type) + ' ' + C.short(tx.id, 12) + ' | DChain Explorer';
|
||||
|
||||
// ── Banner
|
||||
var bannerIcon = document.getElementById('txBannerIcon');
|
||||
bannerIcon.innerHTML = '<i data-lucide="' + txTypeIcon(tx.type) + '"></i>';
|
||||
bannerIcon.className = 'tx-banner-icon ' + txBannerClass(tx.type);
|
||||
document.getElementById('txBannerTitle').textContent = 'Confirmed · ' + C.txLabel(tx.type);
|
||||
document.getElementById('txBannerDesc').textContent = txDescription(tx);
|
||||
document.getElementById('txBannerTime').textContent =
|
||||
C.fmtTime(tx.time) + ' (' + C.timeAgo(tx.time) + ')';
|
||||
|
||||
// ── Overview action table row
|
||||
var fromAddr = tx.from_addr || (tx.from ? C.shortAddress(tx.from) : '—');
|
||||
var toAddr = tx.to_addr || (tx.to ? C.shortAddress(tx.to) : '');
|
||||
var routeHtml = tx.from
|
||||
? ('<span class="tx-route-item">' + (tx.from ? linkAddress(tx.from, fromAddr) : '—') + '</span>' +
|
||||
(tx.to ? '<i data-lucide="arrow-right" class="tx-route-arrow"></i>' +
|
||||
'<span class="tx-route-item">' + linkAddress(tx.to, toAddr) + '</span>' : ''))
|
||||
: '—';
|
||||
var payloadNote = tx.memo || (tx.payload && tx.payload.nickname ? 'nickname: ' + tx.payload.nickname : '') || '—';
|
||||
var amtHtml = tx.amount_ut
|
||||
? '<span class="tx-overview-amt">' + C.esc(tx.amount || C.toToken(tx.amount_ut)) + '</span>'
|
||||
: '<span class="text-muted">—</span>';
|
||||
|
||||
document.getElementById('txActionRow').innerHTML =
|
||||
'<div class="tx-action-cell-action">' +
|
||||
'<i data-lucide="' + txTypeIcon(tx.type) + '" class="tx-action-icon"></i>' +
|
||||
'<span>' + C.esc(C.txLabel(tx.type)) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="tx-action-cell-route tx-route">' + routeHtml + '</div>' +
|
||||
'<div class="tx-action-cell-payload">' + C.esc(payloadNote) + '</div>' +
|
||||
'<div class="tx-action-cell-value">' + amtHtml + '</div>';
|
||||
|
||||
// ── Flow diagram
|
||||
var flowHtml = buildFlowDiagram(tx);
|
||||
var flowEl = document.getElementById('txFlow');
|
||||
if (flowHtml) {
|
||||
flowEl.innerHTML = flowHtml;
|
||||
flowEl.style.display = '';
|
||||
} else {
|
||||
flowEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// ── Details panel
|
||||
// TX ID
|
||||
document.getElementById('txId').textContent = tx.id || '—';
|
||||
|
||||
// Type with badge
|
||||
document.getElementById('txType').innerHTML =
|
||||
'<span class="tx-type-badge">' + C.esc(tx.type || '—') + '</span>' +
|
||||
' <span class="text-muted">— ' + C.esc(C.txLabel(tx.type)) + '</span>';
|
||||
|
||||
// Memo
|
||||
if (tx.memo) {
|
||||
document.getElementById('txMemo').textContent = tx.memo;
|
||||
document.getElementById('txMemoRow').style.display = '';
|
||||
} else {
|
||||
document.getElementById('txMemoRow').style.display = 'none';
|
||||
}
|
||||
|
||||
// From
|
||||
if (tx.from) {
|
||||
document.getElementById('txFrom').innerHTML = linkAddress(tx.from, tx.from_addr || tx.from);
|
||||
document.getElementById('txFromRaw').textContent = tx.from;
|
||||
} else {
|
||||
document.getElementById('txFrom').textContent = '—';
|
||||
}
|
||||
|
||||
// To
|
||||
if (tx.to) {
|
||||
document.getElementById('txTo').innerHTML = linkAddress(tx.to, tx.to_addr || tx.to);
|
||||
document.getElementById('txToRaw').textContent = tx.to;
|
||||
document.getElementById('txToRow').style.display = '';
|
||||
} else {
|
||||
document.getElementById('txToRow').style.display = 'none';
|
||||
}
|
||||
|
||||
// Amount
|
||||
var amountText = tx.amount || C.toToken(tx.amount_ut || 0);
|
||||
document.getElementById('txAmount').textContent = amountText;
|
||||
document.getElementById('txAmount').className = 'tx-amount-val' +
|
||||
(tx.amount_ut > 0 ? ' pos' : '');
|
||||
|
||||
// Fee
|
||||
document.getElementById('txFee').textContent = tx.fee || C.toToken(tx.fee_ut || 0);
|
||||
|
||||
// Block
|
||||
var blockLink = document.getElementById('txBlockLink');
|
||||
if (tx.block_index !== undefined) {
|
||||
blockLink.textContent = '#' + tx.block_index;
|
||||
blockLink.href = '#';
|
||||
blockLink.onclick = function(e) { e.preventDefault(); window.location.href = '/?block=' + tx.block_index; };
|
||||
} else {
|
||||
blockLink.textContent = '—';
|
||||
}
|
||||
document.getElementById('txBlockHash').textContent = tx.block_hash
|
||||
? ' ' + C.short(tx.block_hash, 20)
|
||||
: '';
|
||||
|
||||
// Time
|
||||
document.getElementById('txTime').textContent = C.fmtTime(tx.time);
|
||||
document.getElementById('txTimeAgo').textContent = tx.time ? '(' + C.timeAgo(tx.time) + ')' : '';
|
||||
|
||||
// Signature
|
||||
if (tx.signature_hex) {
|
||||
document.getElementById('txSig').textContent = tx.signature_hex;
|
||||
document.getElementById('txSigRow').style.display = '';
|
||||
} else {
|
||||
document.getElementById('txSigRow').style.display = 'none';
|
||||
}
|
||||
|
||||
// ── Payload panel (only for rich payloads)
|
||||
var shouldShowPayload = tx.payload &&
|
||||
typeof tx.payload === 'object' &&
|
||||
tx.type !== 'TRANSFER'; // memo already shown for transfers
|
||||
if (shouldShowPayload) {
|
||||
document.getElementById('txPayloadPre').textContent = JSON.stringify(tx.payload, null, 2);
|
||||
document.getElementById('txPayloadPanel').style.display = '';
|
||||
} else {
|
||||
document.getElementById('txPayloadPanel').style.display = 'none';
|
||||
}
|
||||
|
||||
// ── Raw JSON
|
||||
document.getElementById('txRaw').textContent = JSON.stringify(tx, null, 2);
|
||||
|
||||
// Show content
|
||||
document.getElementById('mainContent').style.display = '';
|
||||
C.refreshIcons();
|
||||
}
|
||||
|
||||
/* ── Load ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
async function loadTx(id) {
|
||||
if (!id) return;
|
||||
C.setStatus('Loading…', 'warn');
|
||||
document.getElementById('mainContent').style.display = 'none';
|
||||
try {
|
||||
var tx = await C.fetchJSON('/api/tx/' + encodeURIComponent(id));
|
||||
renderTx(tx);
|
||||
window.history.replaceState({}, '', '/tx?id=' + encodeURIComponent(id));
|
||||
C.setStatus('', '');
|
||||
} catch (e) {
|
||||
C.setStatus('Load failed: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tab switching ───────────────────────────────────────────────────────── */
|
||||
|
||||
function switchTab(active) {
|
||||
var tabs = ['Overview', 'Raw'];
|
||||
tabs.forEach(function(name) {
|
||||
var btn = document.getElementById('tab' + name);
|
||||
var pane = document.getElementById('pane' + name);
|
||||
if (!btn || !pane) return;
|
||||
var isActive = (name === active);
|
||||
btn.className = 'tx-tab' + (isActive ? ' tx-tab-active' : '');
|
||||
pane.style.display = isActive ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('tabOverview').addEventListener('click', function() { switchTab('Overview'); });
|
||||
document.getElementById('tabRaw').addEventListener('click', function() { switchTab('Raw'); });
|
||||
|
||||
/* ── Copy buttons ────────────────────────────────────────────────────────── */
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var t = e.target;
|
||||
if (!t) return;
|
||||
var btn = t.closest ? t.closest('.copy-btn') : null;
|
||||
if (btn) {
|
||||
var src = document.getElementById(btn.dataset.copyId);
|
||||
if (src) {
|
||||
navigator.clipboard.writeText(src.textContent || '').catch(function() {});
|
||||
btn.classList.add('copy-btn-done');
|
||||
setTimeout(function() { btn.classList.remove('copy-btn-done'); }, 1200);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Wiring ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
document.getElementById('txBtn').addEventListener('click', function() {
|
||||
var val = (document.getElementById('txInput').value || '').trim();
|
||||
if (val) loadTx(val);
|
||||
});
|
||||
|
||||
document.getElementById('txInput').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') document.getElementById('txBtn').click();
|
||||
});
|
||||
|
||||
var initial = C.q('id');
|
||||
if (initial) {
|
||||
document.getElementById('txInput').value = initial;
|
||||
loadTx(initial);
|
||||
}
|
||||
|
||||
})();
|
||||
81
node/explorer/validators.html
Normal file
81
node/explorer/validators.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Validators | DChain Explorer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/explorer/style.css">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script defer src="/assets/explorer/common.js"></script>
|
||||
<script defer src="/assets/explorer/validators.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topnav">
|
||||
<div class="topnav-inner">
|
||||
<a class="topnav-brand" href="/">
|
||||
<span class="brand-gem">◆</span>
|
||||
<span class="brand-name">DChain</span>
|
||||
</a>
|
||||
<nav class="topnav-links">
|
||||
<a href="/contract">Contracts</a>
|
||||
<a href="/tokens">Tokens</a>
|
||||
<a href="/validators" style="color:var(--text)">Validators</a>
|
||||
<a href="/relays">Relay Nodes</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page-body">
|
||||
|
||||
<!-- page header -->
|
||||
<div class="list-page-header">
|
||||
<div class="list-page-header-left">
|
||||
<div class="list-page-icon list-page-icon-val"><i data-lucide="shield-check"></i></div>
|
||||
<div>
|
||||
<h1 class="list-page-title">Validator Set</h1>
|
||||
<p class="list-page-sub">
|
||||
Active validators participating in PBFT consensus.
|
||||
Quorum requires ⌊2/3 · N⌋ + 1 votes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-page-header-right">
|
||||
<div class="list-page-stat">
|
||||
<div class="list-page-stat-val" id="valCount">—</div>
|
||||
<div class="list-page-stat-label">Validators</div>
|
||||
</div>
|
||||
<div class="list-page-stat">
|
||||
<div class="list-page-stat-val" id="quorumCount">—</div>
|
||||
<div class="list-page-stat-label">Quorum</div>
|
||||
</div>
|
||||
<button id="refreshBtn" class="pill-btn"><i data-lucide="refresh-cw"></i> Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="hero-status" style="margin-bottom:12px"></div>
|
||||
|
||||
<div class="panel" style="padding:0">
|
||||
<div class="table-wrap">
|
||||
<table id="valTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:36px">#</th>
|
||||
<th>Address</th>
|
||||
<th>Public Key</th>
|
||||
<th style="width:140px">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="valBody">
|
||||
<tr><td colspan="4" class="tbl-empty">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
58
node/explorer/validators.js
Normal file
58
node/explorer/validators.js
Normal file
@@ -0,0 +1,58 @@
|
||||
(function() {
|
||||
var C = window.ExplorerCommon;
|
||||
|
||||
async function loadValidators() {
|
||||
C.setStatus('Loading…', 'warn');
|
||||
try {
|
||||
var data = await C.fetchJSON('/api/validators');
|
||||
var validators = (data && Array.isArray(data.validators)) ? data.validators : [];
|
||||
var quorum = validators.length > 0 ? Math.floor(2 * validators.length / 3) + 1 : 0;
|
||||
|
||||
document.getElementById('valCount').textContent = validators.length;
|
||||
document.getElementById('quorumCount').textContent = quorum || '—';
|
||||
|
||||
var tbody = document.getElementById('valBody');
|
||||
if (!validators.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="tbl-empty">No validators found.</td></tr>';
|
||||
C.setStatus('No validators registered.', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = '';
|
||||
validators.forEach(function(v, i) {
|
||||
var addr = v.address || '—';
|
||||
var pubKey = v.pub_key || '—';
|
||||
rows +=
|
||||
'<tr>' +
|
||||
'<td class="text-muted" style="font-size:12px">' + (i + 1) + '</td>' +
|
||||
'<td>' +
|
||||
'<a class="mono" href="/address?address=' + encodeURIComponent(pubKey) + '">' +
|
||||
C.esc(addr) +
|
||||
'</a>' +
|
||||
'</td>' +
|
||||
'<td class="mono" style="color:var(--muted);font-size:12px">' +
|
||||
C.esc(C.short(pubKey, 32)) +
|
||||
'</td>' +
|
||||
'<td>' +
|
||||
'<a href="/node?node=' + encodeURIComponent(pubKey) + '" class="pill-link" style="font-size:12px;padding:4px 10px">' +
|
||||
'<i data-lucide="server"></i> Node' +
|
||||
'</a>' +
|
||||
'</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
tbody.innerHTML = rows;
|
||||
|
||||
C.setStatus(
|
||||
validators.length + ' validator' + (validators.length !== 1 ? 's' : '') +
|
||||
' · quorum: ' + quorum,
|
||||
'ok'
|
||||
);
|
||||
C.refreshIcons();
|
||||
} catch (e) {
|
||||
C.setStatus('Load failed: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadValidators);
|
||||
loadValidators();
|
||||
})();
|
||||
Reference in New Issue
Block a user