Files
munin-bitcoin/web/index.html
T
Unheard0840 d1e86a6c3d Update web/index.html
Refine wallet dashboard and transaction view
2026-08-29 12:50:58 +00:00

2268 lines
45 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>Munin Bitcoin</title>
<style>
:root {
color-scheme: dark;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
--background: #111827;
--surface: #1f2937;
--surface-hover: #263241;
--border: #374151;
--text: #f9fafb;
--secondary: #d1d5db;
--muted: #9ca3af;
--accent: #93c5fd;
--success: #22c55e;
--danger: #ef4444;
}
* {
box-sizing: border-box;
}
html {
background: var(--background);
}
body {
margin: 0;
min-height: 100vh;
background: var(--background);
color: var(--text);
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
main {
width: min(960px, calc(100% - 32px));
margin: 0 auto;
padding: 32px 0 64px;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 6px;
font-size: 30px;
line-height: 1.2;
}
h2 {
margin-bottom: 0;
font-size: 20px;
}
h3 {
margin-bottom: 0;
font-size: 15px;
}
.subtitle {
margin-bottom: 28px;
color: var(--muted);
}
.card {
margin-bottom: 16px;
padding: 22px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
}
.hidden {
display: none !important;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.topbar .subtitle {
margin-bottom: 0;
}
.topbar-actions {
display: flex;
gap: 8px;
}
.button {
padding: 9px 13px;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--text);
}
.button:hover {
background: var(--surface-hover);
}
.button-primary {
border-color: transparent;
background: var(--accent);
color: #111827;
font-weight: 650;
}
.button-primary:hover {
background: var(--accent);
opacity: 0.9;
}
.status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.status-card {
padding: 15px 16px;
border: 1px solid var(--border);
border-radius: 11px;
background: var(--surface);
}
.status-label {
margin-bottom: 7px;
color: var(--muted);
font-size: 12px;
}
.status-value {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.status-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #6b7280;
}
.status-dot.ok {
background: var(--success);
}
.status-dot.error {
background: var(--danger);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.muted {
color: var(--muted);
}
.error {
color: #fca5a5;
}
.empty {
padding: 28px 8px;
color: var(--muted);
text-align: center;
}
/* Wallets */
.wallet {
overflow: hidden;
margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
.wallet:last-child {
margin-bottom: 0;
}
.wallet-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
align-items: center;
gap: 24px;
width: 100%;
padding: 18px;
border: 0;
background: transparent;
color: var(--text);
text-align: left;
}
.wallet-header:hover {
background: var(--surface-hover);
}
.wallet-name {
min-width: 0;
}
.wallet-title {
overflow: hidden;
margin-bottom: 4px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.wallet-address {
overflow: hidden;
color: var(--muted);
font-family:
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.wallet-stat {
min-width: 115px;
}
.wallet-stat-label {
margin-bottom: 3px;
color: var(--muted);
font-size: 11px;
}
.wallet-stat-value {
white-space: nowrap;
}
.wallet-balance {
font-weight: 650;
}
.wallet-chevron {
width: 20px;
color: var(--muted);
text-align: center;
font-size: 18px;
}
.wallet-content {
padding: 0 18px 18px;
border-top: 1px solid var(--border);
}
.wallet-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 0;
border-bottom: 1px solid var(--border);
}
.wallet-meta-left {
display: flex;
flex-wrap: wrap;
gap: 12px;
color: var(--muted);
font-size: 12px;
}
/* Transactions */
.transactions-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 0 10px;
}
.transactions-list {
border-top: 1px solid var(--border);
}
.transaction {
border-bottom: 1px solid var(--border);
}
.transaction:last-child {
border-bottom: 0;
}
.transaction-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 16px;
width: 100%;
padding: 14px 0;
border: 0;
background: transparent;
color: var(--text);
text-align: left;
}
.transaction-row:hover {
background: rgba(255, 255, 255, 0.02);
}
.transaction-main {
min-width: 0;
}
.transaction-date {
margin-bottom: 4px;
font-size: 14px;
}
.transaction-id {
overflow: hidden;
color: var(--muted);
font-family:
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
monospace;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.transaction-status {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--secondary);
font-size: 12px;
white-space: nowrap;
}
.transaction-status.confirmed {
color: #86efac;
}
.transaction-status.pending {
color: #fcd34d;
}
.transaction-chevron {
width: 18px;
color: var(--muted);
text-align: center;
}
.transaction-details {
padding: 0 0 16px;
}
.transaction-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 9px;
background: rgba(0, 0, 0, 0.12);
}
.detail-item {
min-width: 0;
}
.detail-label {
margin-bottom: 3px;
color: var(--muted);
font-size: 11px;
}
.detail-value {
overflow-wrap: anywhere;
font-size: 13px;
}
.mono {
font-family:
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
monospace;
}
.mempool-link {
display: inline-block;
margin-top: 12px;
color: var(--accent);
font-size: 13px;
text-decoration: none;
}
.mempool-link:hover {
text-decoration: underline;
}
/* UTXOs */
.utxo-section {
margin-top: 14px;
border-top: 1px solid var(--border);
}
.utxo-list {
border-top: 1px solid var(--border);
}
.utxo {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
padding: 11px 0;
border-bottom: 1px solid var(--border);
}
.utxo:last-child {
border-bottom: 0;
}
.utxo-outpoint {
overflow: hidden;
margin-bottom: 3px;
color: var(--muted);
font-family:
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
monospace;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.utxo-height {
color: var(--muted);
font-size: 12px;
}
.utxo-value {
align-self: center;
font-family:
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
monospace;
font-size: 13px;
white-space: nowrap;
}
/* Settings */
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 15px 0;
border-bottom: 1px solid var(--border);
}
.settings-row:first-child {
padding-top: 0;
}
.settings-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.settings-title {
margin-bottom: 3px;
font-weight: 600;
}
.settings-description {
color: var(--muted);
font-size: 13px;
}
.unit-selector {
display: flex;
padding: 3px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--background);
}
.unit-button {
min-width: 64px;
padding: 7px 11px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--muted);
}
.unit-button.active {
background: var(--surface);
color: var(--text);
font-weight: 600;
}
/* Login */
.login {
width: min(420px, 100%);
margin: 80px auto;
}
.login label {
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.login input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
outline: 0;
background: var(--background);
color: var(--text);
}
.login input:focus {
border-color: var(--accent);
}
.login button {
width: 100%;
margin-top: 14px;
}
.footer {
padding-top: 12px;
color: var(--muted);
font-size: 12px;
text-align: center;
}
@media (max-width: 720px) {
main {
width: min(100% - 20px, 960px);
padding-top: 20px;
}
.topbar {
align-items: flex-start;
}
.status-grid {
grid-template-columns: 1fr;
}
.wallet-header {
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
}
.wallet-stat:nth-of-type(2) {
display: none;
}
.transaction-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.transaction-status {
display: none;
}
.transaction-chevron {
display: none;
}
.transaction-detail-grid {
grid-template-columns: 1fr;
}
.settings-row {
align-items: flex-start;
flex-direction: column;
gap: 12px;
}
}
</style>
</head>
<body>
<main>
<!-- Login -->
<section id="login" class="card login">
<h1>Munin Bitcoin</h1>
<p class="subtitle">
Sign in to access your Bitcoin wallets.
</p>
<form id="login-form">
<label for="password">
Admin password
</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
>
<button
class="button button-primary"
type="submit"
>
Sign in
</button>
</form>
<p id="login-error" class="error"></p>
</section>
<!-- Dashboard -->
<section id="dashboard" class="hidden">
<header class="topbar">
<div>
<h1>Munin Bitcoin</h1>
<p class="subtitle">
Bitcoin watch-only monitoring
</p>
</div>
<div class="topbar-actions">
<button
id="settings-button"
class="button"
type="button"
>
Settings
</button>
</div>
</header>
<!-- Service status -->
<section class="status-grid">
<div class="status-card">
<div class="status-label">
Munin service
</div>
<div class="status-value">
<span
id="service-dot"
class="status-dot"
></span>
<span id="service-status">
Checking...
</span>
</div>
</div>
<div class="status-card">
<div class="status-label">
Fulcrum
</div>
<div class="status-value">
<span
id="fulcrum-dot"
class="status-dot"
></span>
<span id="fulcrum-status">
Checking...
</span>
</div>
</div>
</section>
<!-- Wallets -->
<section class="card">
<div class="section-header">
<h2>Wallets</h2>
<span
id="wallet-count"
class="muted"
></span>
</div>
<div id="wallet-list">
<div class="empty">
Loading wallets...
</div>
</div>
<p id="wallet-error" class="error"></p>
</section>
<!-- Settings -->
<section
id="settings-panel"
class="card hidden"
>
<div class="section-header">
<h2>Settings</h2>
<button
id="close-settings"
class="button"
type="button"
>
Close
</button>
</div>
<div class="settings-row">
<div>
<div class="settings-title">
Balance unit
</div>
<div class="settings-description">
Choose how Bitcoin balances are displayed.
</div>
</div>
<div class="unit-selector">
<button
id="unit-btc"
class="unit-button"
type="button"
data-unit="BTC"
>
BTC
</button>
<button
id="unit-sats"
class="unit-button"
type="button"
data-unit="sats"
>
sats
</button>
</div>
</div>
<div class="settings-row">
<div>
<div class="settings-title">
Fulcrum endpoint
</div>
<div
id="settings-fulcrum-url"
class="settings-description"
>
Checking...
</div>
</div>
</div>
<div class="settings-row">
<div>
<div class="settings-title">
Blockchain height
</div>
<div
id="settings-blockchain-height"
class="settings-description"
>
Checking...
</div>
</div>
</div>
</section>
<footer class="footer">
<span id="sync-age">
Not synced
</span>
</footer>
</section>
</main>
<script>
const BALANCE_UNIT_KEY =
'munin-bitcoin-balance-unit';
let balanceUnit = loadBalanceUnit();
let wallets = [];
const expandedWallets = new Set();
const expandedUtxos = new Set();
const expandedTransactions = new Set();
const transactionCache = new Map();
let lastSuccessfulSync = null;
function loadBalanceUnit() {
const value =
localStorage.getItem(BALANCE_UNIT_KEY);
return value === 'sats' ? 'sats' : 'BTC';
}
function setBalanceUnit(unit) {
if (unit !== 'BTC' && unit !== 'sats') {
return;
}
balanceUnit = unit;
localStorage.setItem(
BALANCE_UNIT_KEY,
unit,
);
updateUnitButtons();
renderWallets();
}
function updateUnitButtons() {
document
.querySelectorAll('.unit-button')
.forEach((button) => {
button.classList.toggle(
'active',
button.dataset.unit === balanceUnit,
);
});
}
function showLogin() {
document
.getElementById('login')
.classList.remove('hidden');
document
.getElementById('dashboard')
.classList.add('hidden');
document
.getElementById('password')
.focus();
}
function showDashboard() {
document
.getElementById('login')
.classList.add('hidden');
document
.getElementById('dashboard')
.classList.remove('hidden');
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function formatSats(value) {
return Math.round(
Number(value) || 0,
).toLocaleString('en-US');
}
function formatBtc(value) {
return (
Number(value || 0) /
100000000
).toFixed(8);
}
function formatAmount(sats) {
const value =
Math.round(Number(sats) || 0);
if (balanceUnit === 'sats') {
return `${formatSats(value)} sats`;
}
return `${formatBtc(value)} BTC`;
}
function formatDate(timestamp) {
const value =
Number(timestamp);
if (!Number.isFinite(value)) {
return 'Unknown';
}
const date =
new Date(value * 1000);
if (Number.isNaN(date.getTime())) {
return 'Unknown';
}
return date.toLocaleString(
undefined,
{
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
},
);
}
function formatSyncAge(date) {
if (!date) {
return 'Not synced';
}
const seconds =
Math.max(
0,
Math.floor(
(Date.now() - date.getTime()) /
1000,
),
);
if (seconds < 60) {
return 'Synced just now';
}
const minutes =
Math.floor(seconds / 60);
if (minutes < 60) {
return `Synced ${minutes} min ago`;
}
const hours =
Math.floor(minutes / 60);
if (hours < 24) {
return `Synced ${hours} h ago`;
}
const days =
Math.floor(hours / 24);
return `Synced ${days} d ago`;
}
function shortenAddress(address) {
if (
typeof address !== 'string' ||
address.length < 20
) {
return address || '';
}
return (
address.slice(0, 10) +
'…' +
address.slice(-8)
);
}
function getWalletName(wallet) {
if (
typeof wallet.label === 'string' &&
wallet.label.trim()
) {
return wallet.label.trim();
}
return 'Wallet';
}
function getWalletBalance(wallet) {
return (
Number(wallet.confirmedBalance || 0) +
Number(wallet.unconfirmedBalance || 0)
);
}
function getLatestHistory(wallet) {
if (
!Array.isArray(wallet.history) ||
wallet.history.length === 0
) {
return null;
}
return wallet.history.reduce(
(latest, item) => {
if (!latest) {
return item;
}
const latestHeight =
Number(latest.height);
const itemHeight =
Number(item.height);
if (
!Number.isFinite(latestHeight)
) {
return item;
}
if (
Number.isFinite(itemHeight) &&
itemHeight > latestHeight
) {
return item;
}
return latest;
},
null,
);
}
function getWalletTransactions(wallet) {
if (!Array.isArray(wallet.history)) {
return [];
}
const transactions =
new Map();
for (const item of wallet.history) {
if (
!item ||
typeof item.tx_hash !== 'string'
) {
continue;
}
if (
!transactions.has(item.tx_hash)
) {
transactions.set(
item.tx_hash,
{
txid: item.tx_hash,
height:
item.height ?? null,
},
);
continue;
}
const existing =
transactions.get(item.tx_hash);
if (
existing.height === null &&
item.height !== null &&
item.height !== undefined
) {
existing.height = item.height;
}
}
return Array.from(
transactions.values(),
).sort((a, b) => {
const aHeight =
a.height === null
? Number.MAX_SAFE_INTEGER
: Number(a.height);
const bHeight =
b.height === null
? Number.MAX_SAFE_INTEGER
: Number(b.height);
return bHeight - aHeight;
});
}
function getTransactionCacheKey(
txid,
) {
return txid;
}
function getTransactionFromCache(txid) {
return transactionCache.get(
getTransactionCacheKey(txid),
);
}
function getTransactionHistoryItem(
wallet,
txid,
) {
if (!Array.isArray(wallet.history)) {
return null;
}
return (
wallet.history.find(
(item) =>
item &&
item.tx_hash === txid,
) || null
);
}
function getTransactionStatus(
wallet,
transaction,
) {
const history =
getTransactionHistoryItem(
wallet,
transaction.txid,
);
if (
history &&
Number(history.height) < 0
) {
return {
label: 'Pending',
className: 'pending',
};
}
if (
transaction.height === null ||
transaction.height === undefined
) {
return {
label: 'Pending',
className: 'pending',
};
}
return {
label: 'Confirmed',
className: 'confirmed',
};
}
function getTransactionDate(
transaction,
) {
const detail =
getTransactionFromCache(
transaction.txid,
);
if (!detail) {
return null;
}
return (
detail.blocktime ||
detail.time ||
null
);
}
function renderWallets() {
const container =
document.getElementById(
'wallet-list',
);
const count =
document.getElementById(
'wallet-count',
);
count.textContent =
wallets.length === 1
? '1 wallet'
: `${wallets.length} wallets`;
if (!wallets.length) {
container.innerHTML =
'<div class="empty">No wallets are being watched.</div>';
return;
}
container.innerHTML =
wallets
.map(
(wallet) =>
renderWallet(wallet),
)
.join('');
}
function renderWallet(wallet) {
const address =
wallet.address;
const expanded =
expandedWallets.has(address);
const balance =
getWalletBalance(wallet);
const latest =
getLatestHistory(wallet);
const transactionCount =
getWalletTransactions(wallet)
.length;
const lastActivity =
getTransactionDateForLatest(
wallet,
latest,
);
const lastActivityText =
lastActivity
? formatDate(lastActivity)
: latest
? `Block ${latest.height}`
: 'No activity';
const syncText =
lastSuccessfulSync
? formatSyncAge(
lastSuccessfulSync,
)
: 'Not synced';
return `
<article class="wallet">
<button
class="wallet-header"
type="button"
onclick="toggleWallet('${encodeURIComponent(address)}')"
>
<div class="wallet-name">
<div class="wallet-title">
${escapeHtml(
getWalletName(wallet),
)}
</div>
<div class="wallet-address">
${escapeHtml(
shortenAddress(address),
)}
</div>
</div>
<div class="wallet-stat">
<div class="wallet-stat-label">
Balance
</div>
<div class="wallet-stat-value wallet-balance">
${escapeHtml(
formatAmount(balance),
)}
</div>
</div>
<div class="wallet-stat">
<div class="wallet-stat-label">
Last activity
</div>
<div class="wallet-stat-value">
${escapeHtml(
lastActivityText,
)}
</div>
</div>
<div class="wallet-chevron">
${expanded ? '⌄' : ''}
</div>
</button>
${
expanded
? renderWalletContent(
wallet,
syncText,
transactionCount,
)
: ''
}
${
wallet.error
? `
<div style="padding: 0 18px 16px;">
<span class="error">
${escapeHtml(
wallet.error,
)}
</span>
</div>
`
: ''
}
</article>
`;
}
function getTransactionDateForLatest(
wallet,
latest,
) {
if (!latest) {
return null;
}
return getTransactionDate({
txid: latest.tx_hash,
});
}
function renderWalletContent(
wallet,
syncText,
transactionCount,
) {
const utxos =
Array.isArray(wallet.utxos)
? wallet.utxos
: [];
const transactions =
getWalletTransactions(wallet);
const utxosExpanded =
expandedUtxos.has(
wallet.address,
);
return `
<div class="wallet-content">
<div class="wallet-meta">
<div class="wallet-meta-left">
<span>
${escapeHtml(syncText)}
</span>
<span>
${transactionCount}
${
transactionCount === 1
? 'transaction'
: 'transactions'
}
</span>
</div>
<button
class="button"
type="button"
onclick="event.stopPropagation(); toggleUtxos('${encodeURIComponent(wallet.address)}')"
>
${
utxosExpanded
? 'Hide'
: 'Show'
}
UTXOs (${utxos.length})
</button>
</div>
<div class="transactions-header">
<h3>Transactions</h3>
<span class="muted">
${transactions.length}
</span>
</div>
${
transactions.length
? renderTransactions(
wallet,
transactions,
)
: `
<div class="empty">
No transactions found for this wallet.
</div>
`
}
${
utxosExpanded
? renderUtxos(
utxos,
)
: ''
}
</div>
`;
}
function renderTransactions(
wallet,
transactions,
) {
return `
<div class="transactions-list">
${transactions
.map(
(transaction) =>
renderTransaction(
wallet,
transaction,
),
)
.join('')}
</div>
`;
}
function renderTransaction(
wallet,
transaction,
) {
const key =
`${wallet.address}:${transaction.txid}`;
const expanded =
expandedTransactions.has(key);
const status =
getTransactionStatus(
wallet,
transaction,
);
const timestamp =
getTransactionDate(
transaction,
);
const dateText =
timestamp
? formatDate(timestamp)
: transaction.height === null ||
transaction.height === undefined
? 'Pending'
: `Block ${transaction.height}`;
return `
<div class="transaction">
<button
class="transaction-row"
type="button"
onclick="toggleTransaction('${encodeURIComponent(wallet.address)}', '${encodeURIComponent(transaction.txid)}')"
>
<div class="transaction-main">
<div class="transaction-date">
${escapeHtml(dateText)}
</div>
<div class="transaction-id">
${escapeHtml(
transaction.txid,
)}
</div>
</div>
<span
class="transaction-status ${status.className}"
>
${escapeHtml(
status.label,
)}
</span>
<span class="transaction-chevron">
${expanded ? '⌃' : ''}
</span>
</button>
${
expanded
? renderTransactionDetails(
transaction,
)
: ''
}
</div>
`;
}
function renderTransactionDetails(
transaction,
) {
const detail =
getTransactionFromCache(
transaction.txid,
);
if (!detail) {
return `
<div class="transaction-details">
<p class="muted">
Loading transaction...
</p>
</div>
`;
}
const confirmations =
Number(
detail.confirmations || 0,
);
const block =
detail.blockhash
? detail.blockhash
: 'Pending';
const time =
detail.blocktime ||
detail.time ||
null;
return `
<div class="transaction-details">
<div class="transaction-detail-grid">
<div class="detail-item">
<div class="detail-label">
Transaction ID
</div>
<div class="detail-value mono">
${escapeHtml(
detail.txid ||
transaction.txid,
)}
</div>
</div>
<div class="detail-item">
<div class="detail-label">
Confirmations
</div>
<div class="detail-value">
${escapeHtml(
confirmations.toLocaleString(
'en-US',
),
)}
</div>
</div>
<div class="detail-item">
<div class="detail-label">
Block
</div>
<div class="detail-value mono">
${escapeHtml(block)}
</div>
</div>
<div class="detail-item">
<div class="detail-label">
Time
</div>
<div class="detail-value">
${escapeHtml(
formatDate(time),
)}
</div>
</div>
<div class="detail-item">
<div class="detail-label">
Size
</div>
<div class="detail-value">
${escapeHtml(
String(
detail.vsize ??
detail.size ??
'Unknown',
),
)} vB
</div>
</div>
<div class="detail-item">
<div class="detail-label">
Outputs
</div>
<div class="detail-value">
${escapeHtml(
String(
Array.isArray(detail.vout)
? detail.vout.length
: 0,
),
)}
</div>
</div>
</div>
<a
class="mempool-link"
href="https://mempool.space/tx/${encodeURIComponent(detail.txid || transaction.txid)}"
target="_blank"
rel="noopener noreferrer"
>
View transaction on mempool.space ↗
</a>
</div>
`;
}
function renderUtxos(
utxos,
) {
return `
<div class="utxo-section">
<div class="section-header">
<h3>UTXOs</h3>
<span class="muted">
${utxos.length}
</span>
</div>
<div class="utxo-list">
${
utxos.length
? utxos
.map(
(utxo) => {
const sats =
Math.round(
Number(
utxo.value || 0,
) *
100000000,
);
const outpoint =
`${utxo.tx_hash}:${utxo.tx_pos}`;
const height =
utxo.height;
return `
<div class="utxo">
<div>
<div class="utxo-outpoint">
${escapeHtml(
outpoint,
)}
</div>
<div class="utxo-height">
${
height !==
undefined &&
height !== null &&
Number(height) > 0
? `Block ${escapeHtml(height)}`
: 'Unconfirmed'
}
</div>
</div>
<div class="utxo-value">
${escapeHtml(
formatAmount(
sats,
),
)}
</div>
</div>
`;
},
)
.join('')
: `
<div class="empty">
No UTXOs.
</div>
`
}
</div>
</div>
`;
}
async function toggleWallet(
encodedAddress,
) {
const address =
decodeURIComponent(
encodedAddress,
);
if (
expandedWallets.has(address)
) {
expandedWallets.delete(
address,
);
renderWallets();
return;
}
expandedWallets.add(address);
renderWallets();
const wallet =
wallets.find(
(item) =>
item.address === address,
);
if (!wallet) {
return;
}
await loadWalletTransactionDetails(
wallet,
);
}
function toggleUtxos(
encodedAddress,
) {
const address =
decodeURIComponent(
encodedAddress,
);
if (
expandedUtxos.has(address)
) {
expandedUtxos.delete(address);
} else {
expandedUtxos.add(address);
}
renderWallets();
}
async function toggleTransaction(
encodedAddress,
encodedTxid,
) {
const address =
decodeURIComponent(
encodedAddress,
);
const txid =
decodeURIComponent(
encodedTxid,
);
const key =
`${address}:${txid}`;
if (
expandedTransactions.has(key)
) {
expandedTransactions.delete(
key,
);
renderWallets();
return;
}
expandedTransactions.add(key);
renderWallets();
await loadTransaction(txid);
}
async function loadWalletTransactionDetails(
wallet,
) {
const transactions =
getWalletTransactions(wallet);
/*
* Only the latest transaction is loaded
* immediately. The remaining transactions
* are fetched when the user opens them.
*
* This keeps a wallet with many transactions
* from creating dozens of Fulcrum requests
* just by opening it.
*/
if (!transactions.length) {
return;
}
const latest =
transactions[0];
if (
!getTransactionFromCache(
latest.txid,
)
) {
await loadTransaction(
latest.txid,
);
}
}
async function loadTransaction(
txid,
) {
if (
getTransactionFromCache(txid)
) {
renderWallets();
return;
}
try {
const response =
await fetch(
`/api/transactions/${encodeURIComponent(txid)}`,
{
credentials:
'same-origin',
},
);
if (
response.status === 401
) {
showLogin();
return;
}
if (!response.ok) {
throw new Error(
`HTTP ${response.status}`,
);
}
const transaction =
await response.json();
transactionCache.set(
txid,
transaction,
);
renderWallets();
} catch (error) {
console.error(
'Failed to load transaction:',
error,
);
}
}
async function loadStatus() {
try {
const response =
await fetch(
'/api/status',
{
credentials:
'same-origin',
},
);
if (
response.status === 401
) {
showLogin();
return;
}
if (!response.ok) {
throw new Error(
`HTTP ${response.status}`,
);
}
const status =
await response.json();
const serviceRunning =
status.service ===
'running';
const fulcrum =
status.fulcrum || {};
const fulcrumConnected =
Boolean(
fulcrum.connected,
);
document.getElementById(
'service-status',
).textContent =
serviceRunning
? 'Running'
: 'Not running';
document.getElementById(
'service-dot',
).className =
`status-dot ${
serviceRunning
? 'ok'
: 'error'
}`;
document.getElementById(
'fulcrum-status',
).textContent =
fulcrumConnected
? 'Connected'
: fulcrum.configured
? 'Not connected'
: 'Not configured';
document.getElementById(
'fulcrum-dot',
).className =
`status-dot ${
fulcrumConnected
? 'ok'
: 'error'
}`;
document.getElementById(
'settings-fulcrum-url',
).textContent =
fulcrum.url ||
'Not configured';
document.getElementById(
'settings-blockchain-height',
).textContent =
fulcrum.blockchainHeight ??
'Unavailable';
} catch (error) {
console.error(
'Failed to load status:',
error,
);
document.getElementById(
'service-status',
).textContent =
'Unavailable';
document.getElementById(
'fulcrum-status',
).textContent =
'Unavailable';
document.getElementById(
'service-dot',
).className =
'status-dot error';
document.getElementById(
'fulcrum-dot',
).className =
'status-dot error';
}
}
async function loadWallets() {
const response =
await fetch(
'/api/addresses/data',
{
credentials:
'same-origin',
},
);
if (
response.status === 401
) {
showLogin();
return;
}
if (!response.ok) {
throw new Error(
`HTTP ${response.status}`,
);
}
const data =
await response.json();
wallets =
Array.isArray(
data.addresses,
)
? data.addresses
: [];
lastSuccessfulSync =
new Date();
renderWallets();
updateSyncAge();
}
function updateSyncAge() {
const element =
document.getElementById(
'sync-age',
);
element.textContent =
formatSyncAge(
lastSuccessfulSync,
);
}
async function loadAll() {
try {
await loadStatus();
await loadWallets();
} catch (error) {
console.error(
'Failed to load Munin Bitcoin:',
error,
);
document.getElementById(
'wallet-list',
).innerHTML =
`
<div class="empty">
Unable to retrieve wallet data.
</div>
`;
}
}
document
.getElementById(
'login-form',
)
.addEventListener(
'submit',
async (event) => {
event.preventDefault();
const input =
document.getElementById(
'password',
);
const error =
document.getElementById(
'login-error',
);
error.textContent = '';
try {
const response =
await fetch(
'/api/login',
{
method: 'POST',
headers: {
'Content-Type':
'application/json',
},
credentials:
'same-origin',
body:
JSON.stringify({
password:
input.value,
}),
},
);
if (!response.ok) {
throw new Error(
'Invalid password',
);
}
input.value = '';
showDashboard();
await loadAll();
} catch {
error.textContent =
'Invalid password.';
input.select();
}
},
);
document
.getElementById(
'settings-button',
)
.addEventListener(
'click',
() => {
document
.getElementById(
'settings-panel',
)
.classList.toggle(
'hidden',
);
},
);
document
.getElementById(
'close-settings',
)
.addEventListener(
'click',
() => {
document
.getElementById(
'settings-panel',
)
.classList.add(
'hidden',
);
},
);
document
.querySelectorAll(
'.unit-button',
)
.forEach((button) => {
button.addEventListener(
'click',
() => {
setBalanceUnit(
button.dataset.unit,
);
},
);
});
updateUnitButtons();
loadStatus();
loadWallets();
setInterval(
loadAll,
60000,
);
setInterval(
updateSyncAge,
10000,
);
</script>
</body>
</html>