Files
FireExplorer/templates/index.html
Nathan Woodburn d3b00a81ba
All checks were successful
Build Docker / BuildImage (push) Successful in 36s
Check Code Quality / RuffCheck (push) Successful in 49s
feat: Add block header rendering
2025-11-20 17:05:12 +11:00

618 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fire Explorer - Handshake Blockchain Explorer</title>
<link rel="icon" href="/assets/img/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/index.css">
</head>
<body>
<header>
<div class="container">
<h1><img src="/assets/img/favicon.png" alt="Fire Icon" style="height: 1em; vertical-align: middle;"> Fire Explorer</h1>
<p class="subtitle">Handshake Blockchain Explorer</p>
</div>
</header>
<main class="container">
<!-- Status Cards -->
<section class="status-section">
<div class="card status-card">
<h3>Node Status</h3>
<div id="node-status" class="status-content">Loading...</div>
</div>
<div class="card status-card">
<h3>Chain Info</h3>
<div id="chain-status" class="status-content">Loading...</div>
</div>
<div class="card status-card">
<h3>Mempool</h3>
<div id="mempool-status" class="status-content">Loading...</div>
<div id="mempool-txs" class="mempool-txs-container"></div>
</div>
</section>
<!-- Search Section -->
<section class="search-section">
<div class="card">
<h2>Search Blockchain</h2>
<div class="search-tabs">
<button class="tab-btn active" data-tab="block">Block</button>
<button class="tab-btn" data-tab="tx">Transaction</button>
<button class="tab-btn" data-tab="address">Address</button>
<button class="tab-btn" data-tab="name">Name</button>
</div>
<!-- Block Search -->
<div class="tab-content active" id="block-tab">
<div class="search-box">
<input type="text" id="block-input" placeholder="Enter block height or hash" onkeypress="if(event.key === 'Enter') searchBlock()">
<button onclick="searchBlock()">Search Block</button>
<button onclick="searchHeader()">Get Header</button>
</div>
<div id="block-result" class="result-box"></div>
</div>
<!-- Transaction Search -->
<div class="tab-content" id="tx-tab">
<div class="search-box">
<input type="text" id="tx-input" placeholder="Enter transaction ID" onkeypress="if(event.key === 'Enter') searchTx()">
<button onclick="searchTx()">Search Transaction</button>
</div>
<div id="tx-result" class="result-box"></div>
</div>
<!-- Address Search -->
<div class="tab-content" id="address-tab">
<div class="search-box">
<input type="text" id="address-input" placeholder="Enter Handshake address" onkeypress="if(event.key === 'Enter') searchAddressTx()">
<button onclick="searchAddressTx()">Get Transactions</button>
<button onclick="searchAddressCoins()">Get Coins</button>
</div>
<div id="address-result" class="result-box"></div>
</div>
<!-- Name Search -->
<div class="tab-content" id="name-tab">
<div class="search-box">
<input type="text" id="name-input" placeholder="Enter Handshake name" onkeypress="if(event.key === 'Enter') searchName()">
<button onclick="searchName()">Name Info</button>
<button onclick="searchNameResource()">Get Resource</button>
<button onclick="searchNameSummary()">Get Summary</button>
</div>
<div class="search-box">
<input type="text" id="namehash-input" placeholder="Enter name hash" onkeypress="if(event.key === 'Enter') searchNameHash()">
<button onclick="searchNameHash()">Search by Hash</button>
</div>
<div id="name-result" class="result-box"></div>
</div>
</div>
</section>
<!-- Coin Lookup -->
<section class="additional-section">
<div class="card">
<h2>Coin Lookup</h2>
<div class="search-box">
<input type="text" id="coin-hash" placeholder="Coin hash" onkeypress="if(event.key === 'Enter') searchCoin()">
<input type="text" id="coin-index" placeholder="Index" onkeypress="if(event.key === 'Enter') searchCoin()">
<button onclick="searchCoin()">Get Coin Info</button>
</div>
<div id="coin-result" class="result-box"></div>
</div>
</section>
</main>
<footer>
<div class="container">
<p>Fire Explorer - Handshake Blockchain Explorer | Powered by <a href="https://hns.au" target="_blank">HNSAU</a> & <a href="https://hsd.hns.au" target="_blank">Fire HSD</a></p>
<p class="timestamp">Last updated: {{ datetime }}</p>
</div>
</footer>
<script>
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
// Update active tab button
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Update active tab content
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
document.getElementById(tab + '-tab').classList.add('active');
});
});
// URL routing and history management
function updateURL(type, value) {
const url = `/${type}/${value}`;
window.history.pushState({type, value}, '', url);
}
function handleRoute() {
const path = window.location.pathname;
const parts = path.split('/').filter(p => p);
if (parts.length === 2) {
const [type, value] = parts;
switch(type) {
case 'block':
document.getElementById('block-input').value = value;
document.querySelector('[data-tab="block"]').click();
searchBlock();
break;
case 'header':
document.getElementById('block-input').value = value;
document.querySelector('[data-tab="block"]').click();
searchHeader();
break;
case 'tx':
document.getElementById('tx-input').value = value;
document.querySelector('[data-tab="tx"]').click();
searchTx();
break;
case 'address':
document.getElementById('address-input').value = value;
document.querySelector('[data-tab="address"]').click();
searchAddressTx();
break;
case 'name':
document.getElementById('name-input').value = value;
document.querySelector('[data-tab="name"]').click();
searchName();
break;
}
}
}
// Handle browser back/forward
window.addEventListener('popstate', (e) => {
if (e.state) {
handleRoute();
}
});
// API call helper
async function apiCall(endpoint) {
try {
const response = await fetch(`https://hsd.hns.au/api/v1/${endpoint}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
return { error: error.message };
}
}
// Format chain data nicely
function formatChainData(chain) {
return `
<div class="info-grid">
<div class="info-item"><strong>Height:</strong> ${chain.height.toLocaleString()}</div>
<div class="info-item"><strong>Progress:</strong> ${(chain.progress * 100).toFixed(2)}%</div>
<div class="info-item"><strong>TX Count:</strong> ${chain.state.tx.toLocaleString()}</div>
<div class="info-item"><strong>Coins:</strong> ${chain.state.coin.toLocaleString()}</div>
<div class="info-item"><strong>Value:</strong> ${(chain.state.value / 1e6).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})} HNS</div>
<div class="info-item"><strong>Burned:</strong> ${(chain.state.burned / 1e6).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})} HNS</div>
<div class="info-item full-width"><strong>Tip:</strong> <span class="mono">${chain.tip}</span></div>
<div class="info-item full-width"><strong>Tree Root:</strong> <span class="mono">${chain.treeRoot}</span></div>
</div>
`;
}
// Format mempool data nicely
function formatMempoolData(mempool) {
// Check if mempool is an array of transaction IDs
if (Array.isArray(mempool)) {
// Hide the loading status
document.getElementById('mempool-status').style.display = 'none';
// Display transaction count and list
const mempoolTxsContainer = document.getElementById('mempool-txs');
mempoolTxsContainer.innerHTML = `
<div class="mempool-header">
<strong>Transactions: ${mempool.length.toLocaleString()}</strong>
</div>
<div class="tx-list">
${mempool.map(txId => `
<div class="tx-item">
<span class="tx-hash mono">${txId}</span>
<button class="tx-view-btn" onclick="window.location.href='/tx/${txId}'">View</button>
</div>
`).join('')}
</div>
`;
return '';
}
// Fallback for object format
return `
<div class="info-grid">
<div class="info-item"><strong>Transactions:</strong> ${mempool.tx || 0}</div>
<div class="info-item"><strong>Size:</strong> ${mempool.size ? (mempool.size / 1024).toFixed(2) + ' KB' : '0 KB'}</div>
<div class="info-item"><strong>Orphans:</strong> ${mempool.orphans || 0}</div>
<div class="info-item"><strong>Claims:</strong> ${mempool.claims || 0}</div>
<div class="info-item"><strong>Airdrops:</strong> ${mempool.airdrops || 0}</div>
</div>
`;
}
// Format block data nicely
function formatBlockData(block) {
if (!block || block.error) {
return `<div class="error">Error: ${block.error || 'Invalid block data'}</div>`;
}
const formatValue = (value) => (value / 1e6).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' HNS';
const formatTime = (timestamp) => new Date(timestamp * 1000).toLocaleString();
let html = `
<div class="tx-details">
<div class="tx-section">
<h4>Block Info</h4>
<div class="info-grid">
<div class="info-item full-width"><strong>Hash:</strong> <span class="mono">${block.hash}</span></div>
<div class="info-item"><strong>Height:</strong> ${block.height.toLocaleString()}</div>
<div class="info-item"><strong>Confirmations:</strong> ${block.depth.toLocaleString()}</div>
<div class="info-item"><strong>Time:</strong> ${formatTime(block.time)}</div>
<div class="info-item"><strong>Transactions:</strong> ${block.txs.length.toLocaleString()}</div>
<div class="info-item full-width"><strong>Previous Block:</strong> <span class="mono">${block.prevBlock}</span></div>
<div class="info-item full-width"><strong>Merkle Root:</strong> <span class="mono">${block.merkleRoot}</span></div>
<div class="info-item full-width"><strong>Witness Root:</strong> <span class="mono">${block.witnessRoot}</span></div>
<div class="info-item full-width"><strong>Tree Root:</strong> <span class="mono">${block.treeRoot}</span></div>
<div class="info-item"><strong>Version:</strong> ${block.version}</div>
<div class="info-item"><strong>Bits:</strong> ${block.bits}</div>
<div class="info-item"><strong>Nonce:</strong> ${block.nonce.toLocaleString()}</div>
</div>
</div>
<div class="tx-section">
<h4>Transactions (${block.txs.length})</h4>
<div class="tx-io-list">
${block.txs.map((tx, i) => {
const totalOutput = tx.outputs.reduce((sum, out) => sum + out.value, 0);
return `
<div class="tx-io-item" style="cursor: pointer;" onclick="window.location.href='/tx/${tx.hash}'">
<div class="tx-io-header">
<span class="tx-io-index">#${i}</span>
<span class="tx-io-value">${formatValue(totalOutput)}</span>
</div>
<div class="tx-io-hash mono">${tx.hash}</div>
<div style="display: flex; justify-content: space-between; margin-top: 0.5rem; font-size: 0.85rem; color: #b0b0b0;">
<span>${tx.inputs.length} input${tx.inputs.length !== 1 ? 's' : ''}</span>
<span>${tx.outputs.length} output${tx.outputs.length !== 1 ? 's' : ''}</span>
<span>Fee: ${formatValue(tx.fee)}</span>
</div>
</div>
`;
}).join('')}
</div>
</div>
<div class="tx-section">
<button class="secondary-btn" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">Show Raw JSON</button>
<pre style="display: none;">${JSON.stringify(block, null, 2)}</pre>
</div>
</div>
`;
return html;
}
// Format block header data nicely
function formatHeaderData(header) {
if (!header || header.error) {
return `<div class="error">Error: ${header.error || 'Invalid header data'}</div>`;
}
const formatTime = (timestamp) => new Date(timestamp * 1000).toLocaleString();
let html = `
<div class="tx-details">
<div class="tx-section">
<h4>Block Header Info</h4>
<div class="info-grid">
<div class="info-item full-width"><strong>Hash:</strong> <span class="mono">${header.hash}</span></div>
<div class="info-item"><strong>Height:</strong> ${header.height.toLocaleString()}</div>
<div class="info-item"><strong>Version:</strong> ${header.version}</div>
<div class="info-item"><strong>Time:</strong> ${formatTime(header.time)}</div>
<div class="info-item"><strong>Bits:</strong> ${header.bits}</div>
<div class="info-item"><strong>Nonce:</strong> ${header.nonce.toLocaleString()}</div>
<div class="info-item full-width"><strong>Previous Block:</strong> <span class="mono">${header.prevBlock}</span></div>
<div class="info-item full-width"><strong>Merkle Root:</strong> <span class="mono">${header.merkleRoot}</span></div>
<div class="info-item full-width"><strong>Witness Root:</strong> <span class="mono">${header.witnessRoot}</span></div>
<div class="info-item full-width"><strong>Tree Root:</strong> <span class="mono">${header.treeRoot}</span></div>
<div class="info-item full-width"><strong>Reserved Root:</strong> <span class="mono">${header.reservedRoot}</span></div>
<div class="info-item full-width"><strong>Extra Nonce:</strong> <span class="mono">${header.extraNonce}</span></div>
<div class="info-item full-width"><strong>Mask:</strong> <span class="mono">${header.mask}</span></div>
<div class="info-item full-width"><strong>Chainwork:</strong> <span class="mono">${header.chainwork}</span></div>
</div>
</div>
<div class="tx-section">
<button class="secondary-btn" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">Show Raw JSON</button>
<pre style="display: none;">${JSON.stringify(header, null, 2)}</pre>
</div>
</div>
`;
return html;
}
// Format transaction data nicely
function formatTransactionData(tx) {
if (!tx || tx.error) {
return `<div class="error">Error: ${tx.error || 'Invalid transaction data'}</div>`;
}
const formatValue = (value) => (value / 1e6).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' HNS';
const formatRate = (value) => (value / 1e3).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' doo/vB';
let html = `
<div class="tx-details">
<div class="tx-section">
<h4>Transaction Info</h4>
<div class="info-grid">
<div class="info-item full-width"><strong>Hash:</strong> <span class="mono">${tx.hash}</span></div>
<div class="info-item full-width"><strong>Block:</strong> <span class="mono tx-block-hash">${tx.block || 'Unconfirmed'}</span></div>
<div class="info-item"><strong>Height:</strong> ${tx.height >= 0 ? tx.height.toLocaleString() : 'Pending'}</div>
<div class="info-item"><strong>Confirmations:</strong> ${tx.confirmations.toLocaleString()}</div>
<div class="info-item"><strong>Fee:</strong> ${formatValue(tx.fee)}</div>
<div class="info-item"><strong>Rate:</strong> ${formatRate(tx.rate)}</div>
</div>
</div>
<div class="tx-section">
<h4>Inputs (${tx.inputs.length})</h4>
<div class="tx-io-list">
${tx.inputs.map((input, i) => `
<div class="tx-io-item">
<div class="tx-io-header">
<span class="tx-io-index">#${i}</span>
<span class="tx-io-value">${formatValue(input.coin.value)}</span>
</div>
<div class="tx-io-address">${input.coin.address}</div>
${input.coin.covenant.action !== 'NONE' ? `<div class="tx-covenant">Covenant: ${input.coin.covenant.action}</div>` : ''}
</div>
`).join('')}
</div>
</div>
<div class="tx-section">
<h4>Outputs (${tx.outputs.length})</h4>
<div class="tx-io-list">
${tx.outputs.map((output, i) => `
<div class="tx-io-item">
<div class="tx-io-header">
<span class="tx-io-index">#${i}</span>
<span class="tx-io-value">${formatValue(output.value)}</span>
</div>
<div class="tx-io-address">${output.address}</div>
${output.covenant.action !== 'NONE' ? `<div class="tx-covenant">Covenant: ${output.covenant.action}</div>` : ''}
</div>
`).join('')}
</div>
</div>
<div class="tx-section">
<button class="secondary-btn" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">Show Raw JSON</button>
<pre style="display: none;">${JSON.stringify(tx, null, 2)}</pre>
</div>
</div>
`;
return html;
}
// View transaction details
async function viewTransaction(txId) {
const data = await apiCall(`tx/${txId}`);
// Create a modal or use the search result area
const modal = document.createElement('div');
modal.className = 'tx-modal';
modal.onclick = (e) => {
if (e.target === modal) {
modal.remove();
}
};
modal.innerHTML = `
<div class="tx-modal-content">
<div class="tx-modal-header">
<h3>Transaction Details</h3>
<button class="tx-modal-close" onclick="this.parentElement.parentElement.parentElement.remove()">×</button>
</div>
<div class="tx-modal-body">
${data.error ? `<div class="error">Error: ${data.error}</div>` : formatTransactionData(data)}
</div>
</div>
`;
document.body.appendChild(modal);
}
// Display helper
function displayResult(elementId, data, key = null) {
const element = document.getElementById(elementId);
if (data.error) {
element.innerHTML = `<div class="error">Error: ${data.error}</div>`;
} else {
if (key && data[key] !== undefined) {
// If the value is a simple string, display it without JSON formatting
if (typeof data[key] === 'string') {
element.innerHTML = `<pre>${data[key]}</pre>`;
} else {
element.innerHTML = `<pre>${JSON.stringify(data[key], null, 2)}</pre>`;
}
return;
}
element.innerHTML = `<pre>${JSON.stringify(data, null, 2)}</pre>`;
}
}
// Load status on page load
async function loadStatus() {
const nodeStatus = await apiCall('status');
displayResult('node-status', nodeStatus, 'status');
const chainStatus = await apiCall('chain');
if (chainStatus.chain) {
document.getElementById('chain-status').innerHTML = formatChainData(chainStatus.chain);
} else {
displayResult('chain-status', chainStatus);
}
const mempoolStatus = await apiCall('mempool');
if (mempoolStatus && !mempoolStatus.error) {
document.getElementById('mempool-status').innerHTML = formatMempoolData(mempoolStatus);
} else {
displayResult('mempool-status', mempoolStatus);
}
}
// Search functions
async function searchBlock() {
const blockId = document.getElementById('block-input').value.trim().replace(/,/g, '');
if (!blockId) {
alert('Please enter a block height or hash');
return;
}
updateURL('block', blockId);
const data = await apiCall(`block/${blockId}`);
// Use formatted display instead of raw JSON
const resultElement = document.getElementById('block-result');
if (data.error) {
resultElement.innerHTML = `<div class="error">Error: ${data.error}</div>`;
} else {
resultElement.innerHTML = formatBlockData(data);
}
}
async function searchHeader() {
const blockId = document.getElementById('block-input').value.trim().replace(/,/g, '');
if (!blockId) {
alert('Please enter a block height or hash');
return;
}
updateURL('header', blockId);
const data = await apiCall(`header/${blockId}`);
// Use formatted display instead of raw JSON
const resultElement = document.getElementById('block-result');
if (data.error) {
resultElement.innerHTML = `<div class="error">Error: ${data.error}</div>`;
} else {
resultElement.innerHTML = formatHeaderData(data);
}
}
async function searchTx() {
const txId = document.getElementById('tx-input').value.trim().replace(/,/g, '');
if (!txId) {
alert('Please enter a transaction ID');
return;
}
updateURL('tx', txId);
const data = await apiCall(`tx/${txId}`);
// Use formatted display instead of raw JSON
const resultElement = document.getElementById('tx-result');
if (data.error) {
resultElement.innerHTML = `<div class="error">Error: ${data.error}</div>`;
} else {
resultElement.innerHTML = formatTransactionData(data);
}
}
async function searchAddressTx() {
const address = document.getElementById('address-input').value.trim().replace(/,/g, '');
if (!address) {
alert('Please enter an address');
return;
}
updateURL('address', address);
const data = await apiCall(`tx/address/${address}`);
displayResult('address-result', data);
}
async function searchAddressCoins() {
const address = document.getElementById('address-input').value.trim().replace(/,/g, '');
if (!address) {
alert('Please enter an address');
return;
}
const data = await apiCall(`coin/address/${address}`);
displayResult('address-result', data);
}
async function searchName() {
const name = document.getElementById('name-input').value.trim().replace(/,/g, '');
if (!name) {
alert('Please enter a name');
return;
}
updateURL('name', name);
const data = await apiCall(`name/${name}`);
displayResult('name-result', data);
}
async function searchNameResource() {
const name = document.getElementById('name-input').value.trim().replace(/,/g, '');
if (!name) {
alert('Please enter a name');
return;
}
const data = await apiCall(`nameresource/${name}`);
displayResult('name-result', data);
}
async function searchNameSummary() {
const name = document.getElementById('name-input').value.trim().replace(/,/g, '');
if (!name) {
alert('Please enter a name');
return;
}
const data = await apiCall(`namesummary/${name}`);
displayResult('name-result', data);
}
async function searchNameHash() {
const nameHash = document.getElementById('namehash-input').value.trim().replace(/,/g, '');
if (!nameHash) {
alert('Please enter a name hash');
return;
}
const data = await apiCall(`namehash/${nameHash}`);
displayResult('name-result', data);
}
async function searchCoin() {
const coinHash = document.getElementById('coin-hash').value.trim().replace(/,/g, '');
const coinIndex = document.getElementById('coin-index').value.trim().replace(/,/g, '');
if (!coinHash || !coinIndex) {
alert('Please enter both coin hash and index');
return;
}
const data = await apiCall(`coin/${coinHash}/${coinIndex}`);
displayResult('coin-result', data);
}
// Load status when page loads
window.addEventListener('load', () => {
loadStatus();
handleRoute();
});
// Refresh status every 30 seconds
setInterval(loadStatus, 30000);
</script>
</body>
</html>