2167 lines
45 KiB
HTML
2167 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);
|
|
}
|
|
|
|
|
|
/* 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;
|
|
}
|
|
|
|
.transaction-chevron {
|
|
display: none;
|
|
}
|
|
|
|
/* 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 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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
function renderWalletContent(wallet, transactionCount) {
|
|
const transactions = getWalletTransactions(wallet);
|
|
|
|
return `
|
|
<div class="wallet-content">
|
|
<div class="transactions-header">
|
|
<h3>Transactions</h3>
|
|
|
|
<span class="muted">
|
|
${transactionCount}
|
|
</span>
|
|
</div>
|
|
|
|
${
|
|
transactions.length
|
|
? renderTransactions(wallet, transactions)
|
|
: `
|
|
<div class="empty">
|
|
No transactions found for this wallet.
|
|
</div>
|
|
`
|
|
}
|
|
</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();
|
|
|
|
const wallet =
|
|
wallets.find(
|
|
(item) =>
|
|
item.address === address,
|
|
);
|
|
|
|
if (!wallet) {
|
|
return;
|
|
}
|
|
|
|
await loadWalletTransactionDetails(
|
|
wallet,
|
|
);
|
|
}
|
|
|
|
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
|
|
.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>snes@lotta-thinkpad:~/start9/munin-bitcoin$ |