Files
munin-bitcoin/web/index.html
T

2240 lines
47 KiB
HTML

<!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);
}
/* 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-date {
margin-bottom: 4px;
font-size: 14px;
}
.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;
}
/* 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;
}
.settings-select {
min-width: 190px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--background);
color: var(--text);
}
.settings-input {
width: min(360px, 100%);
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--background);
color: var(--text);
}
.transaction-grid {
display: grid;
grid-template-columns: 150px minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
width: 100%;
}
.transaction-type {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.transaction-type.received {
color: #86efac;
}
.transaction-type.sent {
color: #fca5a5;
}
.confirmation-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px solid currentColor;
border-radius: 50%;
font-size: 11px;
line-height: 1;
}
.transaction-amount {
font-weight: 600;
white-space: nowrap;
text-align: right;
}
/* 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;
}
.transaction-grid {
grid-template-columns: 95px minmax(0, 1fr) auto;
gap: 10px;
}
.transaction-date {
font-size: 12px;
}
.transaction-amount {
font-size: 13px;
}
.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">
Date format
</div>
<div class="settings-description">
Choose how dates and transaction times are displayed.
</div>
</div>
<select id="date-format" class="settings-select">
<option value="DD.MM.YY HH:MM">DD.MM.YY HH:MM</option>
<option value="DD.MM.YYYY HH:MM">DD.MM.YYYY HH:MM</option>
<option value="YYYY-MM-DD HH:MM">YYYY-MM-DD HH:MM</option>
<option value="MM/DD/YY HH:MM">MM/DD/YY HH:MM</option>
</select>
</div>
<div class="settings-row">
<div>
<div class="settings-title">
Mempool
</div>
<div class="settings-description">
Choose the mempool service used for transaction details.
</div>
</div>
<select id="mempool-provider" class="settings-select">
<option value="public">mempool.space</option>
<option value="custom">Self-hosted mempool</option>
</select>
</div>
<div id="custom-mempool-row" class="settings-row hidden">
<div>
<div class="settings-title">
Self-hosted mempool URL
</div>
<div class="settings-description">
Base URL, for example https://mempool.example.local
</div>
</div>
<input
id="custom-mempool-url"
class="settings-input"
type="url"
placeholder="https://mempool.example.local"
/>
</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';
const DATE_FORMAT_KEY =
'munin-bitcoin-date-format';
const MEMPOOL_PROVIDER_KEY =
'munin-bitcoin-mempool-provider';
const CUSTOM_MEMPOOL_URL_KEY =
'munin-bitcoin-custom-mempool-url';
let balanceUnit = loadBalanceUnit();
let dateFormat = loadDateFormat();
let mempoolProvider = loadMempoolProvider();
let customMempoolUrl = loadCustomMempoolUrl();
let wallets = [];
const expandedWallets = new Set();
const expandedTransactions = new Set();
const visibleTransactionCounts = new Map();
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 loadDateFormat() {
const value = localStorage.getItem(DATE_FORMAT_KEY);
return [
'DD.MM.YY HH:MM',
'DD.MM.YYYY HH:MM',
'YYYY-MM-DD HH:MM',
'MM/DD/YY HH:MM',
].includes(value)
? value
: 'DD.MM.YY HH:MM';
}
function setDateFormat(value) {
dateFormat = value;
localStorage.setItem(DATE_FORMAT_KEY, value);
renderWallets();
}
function loadMempoolProvider() {
return localStorage.getItem(MEMPOOL_PROVIDER_KEY) === 'custom'
? 'custom'
: 'public';
}
function loadCustomMempoolUrl() {
return localStorage.getItem(CUSTOM_MEMPOOL_URL_KEY) || '';
}
function setMempoolProvider(value) {
mempoolProvider = value === 'custom' ? 'custom' : 'public';
localStorage.setItem(MEMPOOL_PROVIDER_KEY, mempoolProvider);
updateMempoolSettings();
renderWallets();
}
function setCustomMempoolUrl(value) {
customMempoolUrl = value.trim().replace(/\/+$/, '');
localStorage.setItem(
CUSTOM_MEMPOOL_URL_KEY,
customMempoolUrl,
);
renderWallets();
}
function updateMempoolSettings() {
document.getElementById('mempool-provider').value =
mempoolProvider;
const row = document.getElementById('custom-mempool-row');
row.classList.toggle(
'hidden',
mempoolProvider !== 'custom',
);
document.getElementById('custom-mempool-url').value =
customMempoolUrl;
}
function updateDateSettings() {
document.getElementById('date-format').value =
dateFormat;
}
function getMempoolTransactionUrl(txid) {
const cleanTxid = encodeURIComponent(txid);
if (mempoolProvider === 'custom' && customMempoolUrl) {
return `${customMempoolUrl}/tx/${cleanTxid}`;
}
return `https://mempool.space/tx/${cleanTxid}`;
}
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)
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
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';
}
const pad = (number) => String(number).padStart(2, '0');
const year = date.getFullYear();
const shortYear = String(year).slice(-2);
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hour = pad(date.getHours());
const minute = pad(date.getMinutes());
switch (dateFormat) {
case 'DD.MM.YYYY HH:MM':
return `${day}.${month}.${year} ${hour}:${minute}`;
case 'YYYY-MM-DD HH:MM':
return `${year}-${month}-${day} ${hour}:${minute}`;
case 'MM/DD/YY HH:MM':
return `${month}/${day}/${shortYear} ${hour}:${minute}`;
case 'DD.MM.YY HH:MM':
default:
return `${day}.${month}.${shortYear} ${hour}:${minute}`;
}
}
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 false;
}
return transaction.height !== null &&
transaction.height !== undefined &&
Number(transaction.height) >= 0;
}
function getTransactionDate(transaction) {
const detail = getTransactionFromCache(transaction.txid);
if (!detail) {
return null;
}
return detail.blocktime || detail.time || null;
}
function getTransactionNet(wallet, detail) {
if (!detail) {
return null;
}
const address = wallet.address;
let received = 0;
let sent = 0;
if (Array.isArray(detail.vout)) {
for (const output of detail.vout) {
if (
output &&
output.scriptPubKey &&
output.scriptPubKey.address === address
) {
received += Math.round(Number(output.value || 0) * 100000000);
}
}
}
// The transaction API does not expose the previous output address/value
// directly in vin, so a complete outgoing amount cannot always be derived
// from the wallet-scoped transaction alone. Use the wallet history value
// when the backend provides it, otherwise show the received amount.
const history = getTransactionHistoryItem(wallet, detail.txid);
if (history) {
const historyValue = Number(
history.value ?? history.amount ?? history.satoshis
);
if (Number.isFinite(historyValue)) {
const sats = Math.round(
Math.abs(historyValue) <= 21 ? historyValue * 100000000 : historyValue
);
return {
sats,
direction: historyValue < 0 ? 'sent' : 'received',
};
}
}
if (received > 0) {
return {
sats: received,
direction: 'received',
};
}
return {
sats: 0,
direction: 'sent',
};
}
function getTransactionDisplay(wallet, transaction) {
const detail = getTransactionFromCache(transaction.txid);
const net = getTransactionNet(wallet, detail);
if (!net) {
return {
sats: 0,
direction: 'received',
};
}
return net;
}
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';
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 ? 'v' : '>'}
</div>
</button>
${
expanded
? renderWalletContent(wallet, 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,
});
}
async function showMoreTransactions(encodedAddress) {
const address =
decodeURIComponent(
encodedAddress,
);
const wallet =
wallets.find(
(item) =>
item.address === address,
);
if (!wallet) {
return;
}
const current =
getWalletTransactions(wallet).length;
const total =
Number(wallet.transactionCount) ||
current;
if (current >= total) {
return;
}
const button =
document.querySelector(
`[data-show-more-address="${encodeURIComponent(address)}"]`,
);
if (button) {
button.disabled = true;
button.textContent = 'Loading...';
}
try {
const response =
await fetch(
`/api/addresses/transactions?address=${encodeURIComponent(address)}&offset=${current}&limit=50`,
{
credentials:
'same-origin',
},
);
if (
response.status === 401
) {
showLogin();
return;
}
if (!response.ok) {
throw new Error(
`HTTP ${response.status}`,
);
}
const data =
await response.json();
const transactions =
Array.isArray(
data.transactions,
)
? data.transactions
: [];
wallet.history = [
...getWalletTransactions(wallet),
...transactions,
];
wallet.transactionCount =
Number(data.total) ||
wallet.history.length;
wallet.transactionOffset =
current;
renderWallets();
} catch (error) {
console.error(
'Failed to load more transactions:',
error,
);
renderWallets();
}
}
function renderWalletContent(
wallet,
transactionCount,
) {
const transactions =
getWalletTransactions(wallet);
const visibleCount =
transactions.length;
const total =
Number(wallet.transactionCount) ||
transactions.length;
const hasMore =
visibleCount < total;
return `
<div class="wallet-content">
<div class="transactions-header">
<h3>Transactions (${transactionCount})</h3>
</div>
${
transactions.length
? renderTransactions(
wallet,
transactions,
)
: `
<div class="empty">
No transactions found for this wallet.
</div>
`
}
${
hasMore
? `
<button
class="button"
type="button"
data-show-more-address="${encodeURIComponent(wallet.address)}"
onclick="showMoreTransactions('${encodeURIComponent(wallet.address)}')"
style="width: 100%; margin-top: 14px;"
>
Show more (${visibleCount} of ${total})
</button>
`
: ''
}
</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 confirmed = getTransactionStatus(wallet, transaction);
const timestamp = getTransactionDate(transaction);
const display = getTransactionDisplay(wallet, transaction);
const dateText = timestamp
? formatDate(timestamp)
: transaction.height === null ||
transaction.height === undefined
? 'Pending'
: `Block ${transaction.height}`;
const directionLabel =
display.direction === 'sent' ? 'Sent' : 'Received';
const directionClass =
display.direction === 'sent' ? 'sent' : 'received';
const amountText = display.direction === 'sent'
? `-${formatAmount(display.sats)}`
: `+${formatAmount(display.sats)}`;
return `
<div class="transaction">
<button
class="transaction-row"
type="button"
onclick="toggleTransaction('${encodeURIComponent(wallet.address)}', '${encodeURIComponent(transaction.txid)}')"
>
<div class="transaction-grid">
<div class="transaction-date">
${escapeHtml(dateText)}
</div>
<div class="transaction-type ${directionClass}">
${
confirmed
? '<span class="confirmation-check" aria-label="Confirmed">+</span>'
: ''
}
<span>${directionLabel}</span>
</div>
<div class="transaction-amount">
${escapeHtml(amountText)}
</div>
</div>
</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;
const mempoolUrl = getMempoolTransactionUrl(
detail.txid || transaction.txid
);
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="${escapeHtml(mempoolUrl)}"
target="_blank"
rel="noopener noreferrer"
>
View transaction in mempool
</a>
</div>
`;
}
async function toggleWallet(
encodedAddress,
) {
const address =
decodeURIComponent(
encodedAddress,
);
if (
expandedWallets.has(address)
) {
expandedWallets.delete(
address,
);
renderWallets();
return;
}
expandedWallets.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 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
.getElementById('date-format')
.addEventListener('change', (event) => {
setDateFormat(event.target.value);
});
document
.getElementById('mempool-provider')
.addEventListener('change', (event) => {
setMempoolProvider(event.target.value);
});
document
.getElementById('custom-mempool-url')
.addEventListener('change', (event) => {
setCustomMempoolUrl(event.target.value);
});
document
.querySelectorAll(
'.unit-button',
)
.forEach((button) => {
button.addEventListener(
'click',
() => {
setBalanceUnit(
button.dataset.unit,
);
},
);
});
updateUnitButtons();
updateDateSettings();
updateMempoolSettings();
loadStatus();
loadWallets();
setInterval(
loadAll,
60000,
);
setInterval(
updateSyncAge,
10000,
);
</script>
</body>
</html>