2268 lines
45 KiB
HTML
2268 lines
45 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);
|
||
}
|
||
|
||
.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('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
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> |