Files
munin-bitcoin/web/index.html
T
Unheard0840 c576700272 Update web/index.html
Simplify wallet dashboard and add balance unit settings
2026-08-29 12:48:29 +00:00

1967 lines
42 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;
--text-secondary: #d1d5db;
--muted: #9ca3af;
--accent: #93c5fd;
--success: #22c55e;
--danger: #ef4444;
--warning: #f59e0b;
}
* {
box-sizing: border-box;
}
html {
background: var(--background);
}
body {
margin: 0;
min-height: 100vh;
background: var(--background);
color: var(--text);
}
button,
input,
select {
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;
}
.subtitle {
margin-bottom: 28px;
color: var(--muted);
}
.card {
margin-bottom: 16px;
padding: 22px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
}
.hidden {
display: none !important;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.icon-button,
.secondary-button {
padding: 9px 13px;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--text);
}
.icon-button:hover,
.secondary-button:hover {
background: var(--surface-hover);
}
.primary-button {
padding: 10px 15px;
border: 0;
border-radius: 8px;
background: var(--accent);
color: #111827;
font-weight: 650;
}
.primary-button:hover {
opacity: 0.9;
}
.status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.status-card {
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
}
.status-card-label {
margin-bottom: 8px;
color: var(--muted);
font-size: 13px;
}
.status-card-value {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.status-dot {
width: 9px;
height: 9px;
flex: 0 0 auto;
border-radius: 50%;
background: #6b7280;
}
.status-dot.connected {
background: var(--success);
}
.status-dot.disconnected {
background: var(--danger);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.section-header-right {
display: flex;
align-items: center;
gap: 10px;
}
.muted {
color: var(--muted);
}
.error {
color: #fca5a5;
}
.empty {
padding: 28px 8px;
color: var(--muted);
text-align: center;
}
.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: 110px;
}
.wallet-stat-label {
margin-bottom: 3px;
color: var(--muted);
font-size: 12px;
}
.wallet-stat-value {
white-space: nowrap;
}
.wallet-balance {
font-weight: 650;
}
.wallet-chevron {
width: 22px;
color: var(--muted);
text-align: center;
font-size: 18px;
}
.wallet-content {
padding: 0 18px 18px;
border-top: 1px solid var(--border);
}
.wallet-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 0;
}
.wallet-toolbar-left {
display: flex;
align-items: center;
gap: 10px;
}
.small-button {
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 7px;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
}
.small-button:hover {
background: var(--surface-hover);
}
.transactions {
border-top: 1px solid var(--border);
}
.transaction {
border-bottom: 1px solid var(--border);
}
.transaction:last-child {
border-bottom: 0;
}
.transaction-button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 16px;
width: 100%;
padding: 15px 0;
border: 0;
background: transparent;
color: var(--text);
text-align: left;
}
.transaction-button: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-type {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.transaction-type.received {
color: #86efac;
}
.transaction-type.sent {
color: #fca5a5;
}
.transaction-amount {
min-width: 140px;
font-family:
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
monospace;
font-size: 13px;
text-align: right;
white-space: nowrap;
}
.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;
}
.detail-value.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;
}
.utxo-section {
margin-top: 14px;
border-top: 1px solid var(--border);
}
.utxo-list {
padding-top: 8px;
}
.utxo {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
padding: 12px 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-panel {
margin-top: 16px;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 14px 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 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--muted);
}
.unit-button.active {
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.service-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.service-detail {
padding: 12px;
border: 1px solid var(--border);
border-radius: 9px;
}
.service-detail-label {
margin-bottom: 5px;
color: var(--muted);
font-size: 12px;
}
.service-detail-value {
overflow-wrap: anywhere;
font-size: 13px;
}
.login {
width: min(420px, 100%);
margin: 80px auto;
}
.login h1 {
margin-bottom: 8px;
}
.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 {
min-width: 0;
}
.wallet-stat:nth-of-type(2),
.wallet-stat:nth-of-type(3) {
display: none;
}
.transaction-button {
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.transaction-type {
display: none;
}
.transaction-amount {
min-width: 0;
}
.transaction-chevron {
display: none;
}
.transaction-detail-grid {
grid-template-columns: 1fr;
}
.service-details {
grid-template-columns: 1fr;
}
.settings-row {
align-items: flex-start;
flex-direction: column;
gap: 12px;
}
}
</style>
</head>
<body>
<main>
<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="primary-button" type="submit">
Sign in
</button>
</form>
<p id="login-error" class="error"></p>
</section>
<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="icon-button"
type="button"
>
Settings
</button>
</div>
</header>
<section class="status-grid">
<div class="status-card">
<div class="status-card-label">
Munin service
</div>
<div class="status-card-value">
<span
id="service-dot"
class="status-dot"
></span>
<span id="service-status">
Checking...
</span>
</div>
</div>
<div class="status-card">
<div class="status-card-label">
Fulcrum
</div>
<div class="status-card-value">
<span
id="fulcrum-dot"
class="status-dot"
></span>
<span id="fulcrum-status">
Checking...
</span>
</div>
</div>
</section>
<section class="card">
<div class="section-header">
<div>
<h2>Wallets</h2>
</div>
<div class="section-header-right">
<span
id="wallet-count"
class="muted"
></span>
</div>
</div>
<div id="wallet-list">
<div class="empty">
Loading wallets...
</div>
</div>
<p id="wallet-error" class="error"></p>
</section>
<section
id="settings-panel"
class="card settings-panel hidden"
>
<div class="section-header">
<h2>Settings</h2>
<button
id="close-settings"
class="secondary-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="last-refresh">
Not synced
</span>
</footer>
</section>
</main>
<script>
const STORAGE_KEY = 'munin-bitcoin-balance-unit';
let balanceUnit = loadBalanceUnit();
let addresses = [];
let transactionDetails = {};
let expandedWallets = new Set();
let expandedUtxos = new Set();
let expandedTransactions = new Set();
let lastSuccessfulSync = null;
function loadBalanceUnit() {
const value = localStorage.getItem(STORAGE_KEY);
if (value === 'sats') {
return 'sats';
}
return 'BTC';
}
function saveBalanceUnit(unit) {
balanceUnit = unit;
localStorage.setItem(STORAGE_KEY, unit);
updateUnitButtons();
renderWallets(addresses);
}
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');
}
async function login(password) {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({ password }),
});
if (!response.ok) {
throw new Error('Invalid password');
}
showDashboard();
await loadAll();
}
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(value) {
const sats = Math.round(Number(value) || 0);
if (balanceUnit === 'sats') {
return `${formatSats(sats)} sats`;
}
return `${formatBtc(sats)} BTC`;
}
function formatSignedAmount(value) {
const sats = Math.round(Number(value) || 0);
const sign = sats > 0 ? '+' : sats < 0 ? '' : '';
if (balanceUnit === 'sats') {
return `${sign}${formatSats(Math.abs(sats))} sats`;
}
return `${sign}${formatBtc(Math.abs(sats))} BTC`;
}
function formatDate(timestamp) {
if (
timestamp === null ||
timestamp === undefined ||
!Number.isFinite(Number(timestamp))
) {
return 'Unknown';
}
const date = new Date(Number(timestamp) * 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 getLastActivity(address) {
if (!Array.isArray(address.history) || !address.history.length) {
return null;
}
let latest = null;
for (const item of address.history) {
const height = Number(item.height);
if (
latest === null ||
(Number.isFinite(height) &&
height > Number(latest.height))
) {
latest = item;
}
}
return latest;
}
function getAddressBalance(address) {
const confirmed =
Number(address.confirmedBalance || 0);
const unconfirmed =
Number(address.unconfirmedBalance || 0);
return confirmed + unconfirmed;
}
function getWalletName(address) {
if (
typeof address.label === 'string' &&
address.label.trim()
) {
return address.label.trim();
}
return 'Wallet';
}
function shortenAddress(address) {
if (!address || address.length < 18) {
return address || '';
}
return (
address.slice(0, 10) +
'…' +
address.slice(-8)
);
}
function getTransactionForWallet(wallet, txid) {
const history = Array.isArray(wallet.history)
? wallet.history
: [];
return history.find(
(item) => item.tx_hash === txid,
);
}
function getWalletTransactions(wallet) {
if (!Array.isArray(wallet.history)) {
return [];
}
const transactions = new Map();
for (const item of wallet.history) {
if (!item || !item.tx_hash) {
continue;
}
if (!transactions.has(item.tx_hash)) {
transactions.set(item.tx_hash, {
txid: item.tx_hash,
height: item.height ?? null,
timestamp: null,
amount: 0,
});
}
const transaction =
transactions.get(item.tx_hash);
transaction.height =
transaction.height === null &&
item.height !== null &&
item.height !== undefined
? item.height
: transaction.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 transactionType(wallet, transaction) {
const historyItem = getTransactionForWallet(
wallet,
transaction.txid,
);
if (
historyItem &&
Number(historyItem.height) < 0
) {
return 'Pending';
}
return 'Transaction';
}
function renderWallets(wallets) {
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, index) => {
const walletId =
`wallet-${index}-${encodeURIComponent(wallet.address)}`;
const expanded =
expandedWallets.has(wallet.address);
const balance =
getAddressBalance(wallet);
const activity =
getLastActivity(wallet);
const activityText = activity
? formatDate(
activity.timestamp ||
activity.time ||
null,
)
: 'No activity';
const errorText =
wallet.error
? `<div class="error">${escapeHtml(wallet.error)}</div>`
: '';
return `
<article class="wallet">
<button
class="wallet-header"
type="button"
onclick="toggleWallet('${encodeURIComponent(wallet.address)}')"
>
<div class="wallet-name">
<div class="wallet-title">
${escapeHtml(getWalletName(wallet))}
</div>
<div class="wallet-address">
${escapeHtml(shortenAddress(wallet.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(activityText)}
</div>
</div>
<div class="wallet-chevron">
${expanded ? '⌄' : ''}
</div>
</button>
${
expanded
? renderWalletContent(wallet, walletId)
: ''
}
${errorText}
</article>
`;
})
.join('');
}
function renderWalletContent(wallet, walletId) {
const transactions =
getWalletTransactions(wallet);
const utxos =
Array.isArray(wallet.utxos)
? wallet.utxos
: [];
const utxoKey =
wallet.address;
const utxosExpanded =
expandedUtxos.has(utxoKey);
return `
<div class="wallet-content">
<div class="wallet-toolbar">
<div class="wallet-toolbar-left">
<span class="muted">
${escapeHtml(formatSyncAge(lastSuccessfulSync))}
</span>
</div>
<button
class="small-button"
type="button"
onclick="event.stopPropagation(); toggleUtxos('${encodeURIComponent(wallet.address)}')"
>
${utxosExpanded ? 'Hide' : 'Show'} UTXOs
(${utxos.length})
</button>
</div>
<div>
<div class="section-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>'
}
</div>
${
utxosExpanded
? renderUtxos(utxos, wallet.address)
: ''
}
</div>
`;
}
function renderTransactions(wallet, transactions) {
return `
<div class="transactions">
${transactions
.map((transaction) => {
const key =
`${wallet.address}:${transaction.txid}`;
const expanded =
expandedTransactions.has(key);
const historyItem =
getTransactionForWallet(
wallet,
transaction.txid,
);
const isReceived =
historyItem &&
Number(historyItem.height) >= 0;
const type =
isReceived ? 'Received' : 'Pending';
return `
<div class="transaction">
<button
class="transaction-button"
type="button"
onclick="toggleTransaction(
'${encodeURIComponent(wallet.address)}',
'${encodeURIComponent(transaction.txid)}'
)"
>
<div class="transaction-main">
<div class="transaction-date">
${
transaction.timestamp
? escapeHtml(
formatDate(
transaction.timestamp,
),
)
: escapeHtml(
transaction.height === null
? 'Pending'
: `Block ${transaction.height}`,
)
}
</div>
<div class="transaction-id">
${escapeHtml(transaction.txid)}
</div>
</div>
<span
class="transaction-type ${
isReceived
? 'received'
: 'sent'
}"
>
${escapeHtml(type)}
</span>
<div class="transaction-amount">
${expanded ? '⌃' : ''}
</div>
</button>
${
expanded
? renderTransactionDetails(
wallet,
transaction,
)
: ''
}
</div>
`;
})
.join('')}
</div>
`;
}
function renderTransactionDetails(
wallet,
transaction,
) {
const key =
`${wallet.address}:${transaction.txid}`;
const detail =
transactionDetails[transaction.txid];
if (!detail) {
return `
<div class="transaction-details">
<p class="muted">
Loading transaction...
</p>
</div>
`;
}
const outputValue =
Array.isArray(detail.vout)
? detail.vout.reduce(
(sum, output) =>
sum +
Math.round(
Number(output.value || 0) *
100000000,
),
0,
)
: 0;
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)}
</div>
</div>
<div class="detail-item">
<div class="detail-label">
Confirmations
</div>
<div class="detail-value">
${escapeHtml(
Number(
detail.confirmations || 0,
).toLocaleString('en-US'),
)}
</div>
</div>
<div class="detail-item">
<div class="detail-label">
Block
</div>
<div class="detail-value mono">
${
detail.blockhash
? escapeHtml(detail.blockhash)
: 'Pending'
}
</div>
</div>
<div class="detail-item">
<div class="detail-label">
Time
</div>
<div class="detail-value">
${escapeHtml(
formatDate(
detail.blocktime ||
detail.time ||
null,
),
)}
</div>
</div>
<div class="detail-item">
<div class="detail-label">
Size
</div>
<div class="detail-value">
${escapeHtml(
`${detail.vsize ?? detail.size ?? 'Unknown'} vB`,
)}
</div>
</div>
<div class="detail-item">
<div class="detail-label">
Outputs
</div>
<div class="detail-value">
${escapeHtml(
formatAmount(outputValue),
)}
</div>
</div>
</div>
<a
class="mempool-link"
href="https://mempool.space/tx/${encodeURIComponent(detail.txid)}"
target="_blank"
rel="noopener noreferrer"
>
View transaction on mempool.space ↗
</a>
</div>
`;
}
function renderUtxos(utxos, address) {
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 value =
Math.round(
Number(
utxo.value || 0,
) *
100000000,
);
const outpoint =
`${utxo.tx_hash}:${utxo.tx_pos}`;
return `
<div class="utxo">
<div>
<div class="utxo-outpoint">
${escapeHtml(outpoint)}
</div>
<div class="utxo-height">
${
utxo.height !== undefined
? `Block ${escapeHtml(utxo.height)}`
: 'Unconfirmed'
}
</div>
</div>
<div class="utxo-value">
${escapeHtml(
formatAmount(value),
)}
</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);
} else {
expandedWallets.add(address);
}
renderWallets(addresses);
const wallet =
addresses.find(
(item) => item.address === address,
);
if (
wallet &&
expandedWallets.has(address)
) {
await loadTransactionDetails(wallet);
}
}
async function toggleUtxos(encodedAddress) {
const address =
decodeURIComponent(encodedAddress);
if (expandedUtxos.has(address)) {
expandedUtxos.delete(address);
} else {
expandedUtxos.add(address);
}
renderWallets(addresses);
}
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(addresses);
return;
}
expandedTransactions.add(key);
renderWallets(addresses);
if (!transactionDetails[txid]) {
await loadTransaction(txid);
}
}
async function loadTransaction(txid) {
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();
transactionDetails[txid] = transaction;
renderWallets(addresses);
} catch (error) {
console.error(
'Failed to load transaction:',
error,
);
}
}
async function loadTransactionDetails(wallet) {
const transactions =
getWalletTransactions(wallet);
for (const transaction of transactions) {
if (transactionDetails[transaction.txid]) {
continue;
}
await loadTransaction(
transaction.txid,
);
}
}
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
? 'connected'
: 'disconnected'
}`;
document.getElementById(
'fulcrum-status',
).textContent =
fulcrumConnected
? 'Connected'
: fulcrum.configured
? 'Not connected'
: 'Not configured';
document.getElementById(
'fulcrum-dot',
).className =
`status-dot ${
fulcrumConnected
? 'connected'
: 'disconnected'
}`;
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 disconnected';
document.getElementById(
'fulcrum-dot',
).className =
'status-dot disconnected';
}
}
async function loadAddresses() {
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();
addresses =
Array.isArray(data.addresses)
? data.addresses
: [];
lastSuccessfulSync =
new Date();
renderWallets(addresses);
document.getElementById(
'last-refresh',
).textContent =
formatSyncAge(
lastSuccessfulSync,
);
}
async function loadAll() {
try {
await loadStatus();
await loadAddresses();
} 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 passwordInput =
document.getElementById(
'password',
);
const loginError =
document.getElementById(
'login-error',
);
loginError.textContent = '';
try {
await login(
passwordInput.value,
);
passwordInput.value = '';
} catch {
loginError.textContent =
'Invalid password.';
passwordInput.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',
() => {
saveBalanceUnit(
button.dataset.unit,
);
},
);
});
updateUnitButtons();
loadStatus();
loadAddresses();
setInterval(
loadAll,
60000,
);
</script>
</body>
</html>