feat(bitcoin-ui): add Insights, Peers and Connect tabs for UmbrelOS parity

The Bitcoin UI was one long scroll: sync card, RPC/ZMQ cards and the
relay-sharing panel stacked on a single page, with node details hidden in a
modal. umbrelOS's rebuilt Bitcoin Node app splits the same surface across a
Home/Insights/Settings dock and shows considerably more.

Added, all of it additive — the sync state machine, the status-snapshot
staleness logic and the relay-sharing panel are untouched and just move
inside a tab panel:

- Five tabs (Node / Insights / Peers / Connect / Sharing) using the same
  segmented control as lnd-ui, which becomes a fixed bottom dock under
  768px with safe-area padding.
- Insights: the four stats umbrel's StatSummary shows (Connections,
  Mempool, Blockchain Size, Node Uptime), a Latest Blocks strip, and
  Block Size / Fee Rate / Block Rewards charts — the same three umbrel
  plots, drawn as CSS bars so nothing has to load a chart library past
  the CSP's script-src 'self'.
- Peers: sortable, filterable table with umbrel's exact columns (Peer,
  Network, Relay TXNs, In/Out, Connected) plus ping. Network is derived
  from getpeerinfo's own `network` field, falling back to address
  matching, so Tor/I2P/CJDNS/Local/Clearnet are labelled correctly.
- Connect: RPC and P2P details with a Local/Tor selector, QR codes and
  per-field copy buttons (umbrel's ConnectionDetails), including its
  unencrypted-LAN warning on Local.

Block statistics come from getblockstats, one call per block for the last
ten, cached by height so only the new tip is re-fetched. The live tabs poll
only while visible rather than adding a third unconditional 5s timer.

Also removed the hardcoded "archipelago123" from copyRPCInfo. That string
was never the node's actual RPC password — the real one is a
manifest-declared generated secret rendered into this app's nginx upstream
and deliberately never sent to the browser — so copying it could only ever
mislead. The Connect tab says where the password actually lives instead.

qrcode.js is vendored from docker/lnd-ui (same file, already CSP-clean) and
added to the Dockerfile's COPY set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 18:31:52 -04:00
co-authored by Claude Opus 5
parent 341bff4b44
commit f4a323226e
3 changed files with 2941 additions and 1 deletions
+1
View File
@@ -2,6 +2,7 @@ FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Static site content.
COPY index.html /usr/share/nginx/html/
COPY tailwind.css /usr/share/nginx/html/
COPY qrcode.js /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/
COPY assets/ /usr/share/nginx/html/assets/
#
+643 -1
View File
@@ -309,6 +309,126 @@
animation: shimmer 3s infinite;
}
/* ── Primary tab bar (umbrelOS's dock, Archipelago-styled) ─────── */
.tabbar-wrap { margin-bottom: 1.5rem; }
.tabbar {
display: flex; gap: 0.25rem; padding: 0.3rem; border-radius: 0.85rem;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.18);
overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch;
}
.tabbar::-webkit-scrollbar { display: none; }
.tab-btn {
flex: 1 0 auto; display: flex; align-items: center; justify-content: center; gap: 0.5rem;
padding: 0.65rem 1rem; border: none; background: none; border-radius: 0.6rem;
font-size: 0.875rem; font-weight: 500; color: rgba(255, 255, 255, 0.6);
cursor: pointer; transition: all 0.25s ease; white-space: nowrap;
}
.tab-btn svg { width: 1.05rem; height: 1.05rem; flex-shrink: 0; }
.tab-btn:hover { color: rgba(255, 255, 255, 0.9); }
.tab-btn.active {
color: #fff;
background: linear-gradient(135deg, rgba(251, 146, 60, 0.28) 0%, rgba(255, 255, 255, 0.08) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
.tab-panel { display: none; }
.tab-panel.active { display: block; animation: fadeUp 0.28s ease both; }
@keyframes fadeUp { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
/* Status pill */
.pill {
display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.6rem;
border-radius: 9999px; font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.02em;
background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.75); white-space: nowrap;
}
.pill.ok { background: rgba(74, 222, 128, 0.14); border-color: rgba(74, 222, 128, 0.3); color: #4ade80; }
.pill.warn { background: rgba(250, 204, 21, 0.14); border-color: rgba(250, 204, 21, 0.3); color: #facc15; }
.pill.bad { background: rgba(248, 113, 113, 0.14); border-color: rgba(248, 113, 113, 0.3); color: #f87171; }
/* Data tables (peers) */
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.data-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.data-table th {
text-align: left; padding: 0.6rem 0.75rem; font-size: 0.6875rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255, 255, 255, 0.45);
border-bottom: 1px solid rgba(255, 255, 255, 0.1); white-space: nowrap;
cursor: pointer; user-select: none;
}
.data-table th:hover { color: rgba(255, 255, 255, 0.75); }
.data-table th .caret { opacity: 0.35; font-size: 0.6rem; margin-left: 0.25rem; }
.data-table th.sorted .caret { opacity: 1; }
.data-table td {
padding: 0.65rem 0.75rem; border-bottom: 1px solid rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.8); vertical-align: middle;
}
.data-table tr:last-child td { border-bottom: none; }
.data-table tbody tr { transition: background 0.2s ease; }
.data-table tbody tr:hover { background: rgba(255, 255, 255, 0.04); }
.filter-input {
width: 100%; padding: 0.55rem 0.85rem; background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 0.5rem;
color: white; font-size: 0.8125rem; outline: none;
}
.filter-input::placeholder { color: rgba(255, 255, 255, 0.35); }
.filter-input:focus { border-color: rgba(251, 146, 60, 0.5); }
/* Latest blocks strip */
.blocks-strip { display: flex; gap: 0.75rem; overflow-x: auto; padding-bottom: 0.5rem; scrollbar-width: thin; -webkit-overflow-scrolling: touch; }
.block-tile {
flex: 0 0 auto; width: 8.5rem; padding: 0.9rem;
background: linear-gradient(135deg, rgba(251, 146, 60, 0.14) 0%, rgba(255, 255, 255, 0.04) 100%);
border: 1px solid rgba(251, 146, 60, 0.25); border-radius: 0.75rem;
}
.block-tile .h { font-size: 1rem; font-weight: 700; color: #fff; font-variant-numeric: tabular-nums; }
.block-tile .m { font-size: 0.6875rem; color: rgba(255, 255, 255, 0.5); margin-top: 0.15rem; }
/* Mini bar charts (Umbrel's Insights charts, CSP-safe / no chart lib) */
.chart-rows { display: flex; align-items: flex-end; gap: 0.3rem; height: 7rem; }
.chart-col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 0.3rem; min-width: 0; }
.chart-bar { width: 100%; border-radius: 0.25rem 0.25rem 0 0; min-height: 2px; transition: height 0.5s ease; }
.chart-bar.orange { background: linear-gradient(180deg, #fb923c, rgba(251, 146, 60, 0.35)); }
.chart-bar.yellow { background: linear-gradient(180deg, #facc15, rgba(250, 204, 21, 0.35)); }
.chart-bar.green { background: linear-gradient(180deg, #4ade80, rgba(74, 222, 128, 0.35)); }
.chart-lbl { font-size: 0.5625rem; color: rgba(255, 255, 255, 0.35); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; }
/* Field rows with copy (connect tab) */
.field-label { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255, 255, 255, 0.45); margin-bottom: 0.25rem; }
.field-row { display: flex; align-items: center; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 0.5rem; overflow: hidden; min-width: 0; }
.field-value { flex: 1; min-width: 0; padding: 0.625rem 0.875rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8125rem; color: rgba(255, 255, 255, 0.9); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.copy-btn { padding: 0.625rem 0.75rem; background: none; border: none; border-left: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; color: rgba(255, 255, 255, 0.4); transition: all 0.2s ease; display: flex; align-items: center; flex-shrink: 0; }
.copy-btn:hover { color: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.05); }
.conn-layout { display: flex; flex-direction: column; gap: 1.5rem; }
.conn-fields { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.75rem; }
.qr-box { flex-shrink: 0; width: 100%; max-width: 216px; aspect-ratio: 1; margin: 0 auto; background: white; border-radius: 0.75rem; padding: 0.6rem; display: flex; align-items: center; justify-content: center; }
.qr-box img { width: 100%; height: auto; display: block; image-rendering: pixelated; }
.conn-select { width: 100%; padding: 0.75rem 1rem; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 0.5rem; color: white; font-size: 0.875rem; font-weight: 500; appearance: none; cursor: pointer; outline: none; background-image: url('data:image/svg+xml;utf8,<svg fill="white" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7 10l5 5 5-5z"/></svg>'); background-repeat: no-repeat; background-position: right 12px center; background-size: 20px; }
.conn-select option { background: #1a1a2e; color: white; }
.empty-state { padding: 2.5rem 1rem; text-align: center; color: rgba(255, 255, 255, 0.4); font-size: 0.875rem; }
.tabular { font-variant-numeric: tabular-nums; }
@media (min-width: 640px) {
.conn-layout { flex-direction: row; }
.qr-box { margin: 0; width: 216px; }
}
/* Mobile: the tab bar becomes a bottom dock, like umbrelOS. */
@media (max-width: 767px) {
.container { padding: 1rem; padding-bottom: 6.5rem; }
.tabbar-wrap {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
margin: 0; padding: 0.5rem 0.75rem calc(0.5rem + env(safe-area-inset-bottom, 0px));
background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 55%, rgba(0, 0, 0, 0));
}
.tabbar { border-radius: 1rem; }
.tab-btn { flex: 1 1 0; flex-direction: column; gap: 0.2rem; padding: 0.5rem 0.35rem; font-size: 0.625rem; }
.tab-btn svg { width: 1.25rem; height: 1.25rem; }
.data-table th, .data-table td { padding: 0.55rem 0.5rem; }
}
/* Number increment animation */
@keyframes numberPulse {
0%, 100% {
@@ -409,6 +529,35 @@
</div>
</div>
<!-- ── Tab bar ─────────────────────────────────────────────────── -->
<div class="tabbar-wrap">
<div class="tabbar" role="tablist">
<button class="tab-btn active" data-tab="node" role="tab">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 12l9-9 9 9M5 10v10a1 1 0 001 1h3v-6h6v6h3a1 1 0 001-1V10"/></svg>
<span>Node</span>
</button>
<button class="tab-btn" data-tab="insights" role="tab">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 3v18h18M7 15l3-4 3 3 5-7"/></svg>
<span>Insights</span>
</button>
<button class="tab-btn" data-tab="peers" role="tab">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.36-1.86M17 20H7m10 0v-2c0-.66-.13-1.3-.36-1.86m0 0A5 5 0 0012 13a5 5 0 00-4.64 3.14M7 20H2v-2a3 3 0 015.36-1.86M7 20v-2c0-.66.13-1.3.36-1.86m0 0A5 5 0 019 13m6-4a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>Peers</span>
</button>
<button class="tab-btn" data-tab="connect" role="tab">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h2v2h-2zM18 14h2v2h-2zM14 18h2v2h-2zM18 18h2v2h-2z"/></svg>
<span>Connect</span>
</button>
<button class="tab-btn" data-tab="sharing" role="tab">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.684 13.342a3 3 0 000-2.684m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.368-2.684 3 3 0 00-5.368 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/></svg>
<span>Sharing</span>
</button>
</div>
</div>
<!-- ══ NODE ══════════════════════════════════════════════════════ -->
<div class="tab-panel active" id="panel-node">
<!-- Blockchain Sync Status Card - NEW -->
<div class="glass-card p-6 mb-6" id="syncStatusCard">
<div class="flex items-start gap-4 mb-4">
@@ -559,6 +708,199 @@
</div>
</div>
</div><!-- /panel-node -->
<!-- ══ INSIGHTS ══════════════════════════════════════════════════ -->
<div class="tab-panel" id="panel-insights">
<!-- Stat summary (umbrel StatSummary parity) -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div class="info-card">
<p class="text-xs text-white/60 mb-1">Connections</p>
<p class="text-2xl font-bold text-white tabular" id="insPeers">-</p>
<p class="text-xs text-white/40 mt-1" id="insPeersSub">peers</p>
</div>
<div class="info-card">
<p class="text-xs text-white/60 mb-1">Mempool</p>
<p class="text-2xl font-bold text-orange-400 tabular" id="insMempool">-</p>
<p class="text-xs text-white/40 mt-1" id="insMempoolSub">unconfirmed</p>
</div>
<div class="info-card">
<p class="text-xs text-white/60 mb-1">Blockchain Size</p>
<p class="text-2xl font-bold text-white tabular" id="insChainSize">-</p>
<p class="text-xs text-white/40 mt-1" id="insChainSizeSub">on disk</p>
</div>
<div class="info-card">
<p class="text-xs text-white/60 mb-1">Node Uptime</p>
<p class="text-2xl font-bold text-white" id="insUptime">-</p>
<p class="text-xs text-white/40 mt-1">since last restart</p>
</div>
</div>
<!-- Latest blocks -->
<div class="glass-card p-6 mb-6">
<div class="flex items-center justify-between gap-3 mb-4">
<div>
<h2 class="text-xl font-semibold text-white mb-1">Latest Blocks</h2>
<p class="text-white/60 text-sm">Most recent blocks this node has validated.</p>
</div>
</div>
<div class="blocks-strip" id="blocksStrip">
<div class="empty-state">Loading blocks…</div>
</div>
</div>
<!-- Charts (umbrel BlockSize / FeeRate / BlockRewards parity) -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="glass-card p-6">
<h3 class="text-lg font-semibold text-white mb-1">Block Size</h3>
<p class="text-white/60 text-sm mb-4" id="chartSizeSub">MB per block</p>
<div class="chart-rows" id="chartSize"></div>
</div>
<div class="glass-card p-6">
<h3 class="text-lg font-semibold text-white mb-1">Fee Rate</h3>
<p class="text-white/60 text-sm mb-4" id="chartFeeSub">average sat/vB</p>
<div class="chart-rows" id="chartFee"></div>
</div>
<div class="glass-card p-6">
<h3 class="text-lg font-semibold text-white mb-1">Block Rewards</h3>
<p class="text-white/60 text-sm mb-4" id="chartRewardSub">subsidy + fees, BTC</p>
<div class="chart-rows" id="chartReward"></div>
</div>
</div>
</div>
<!-- ══ PEERS ═════════════════════════════════════════════════════ -->
<div class="tab-panel" id="panel-peers">
<div class="glass-card p-6">
<div class="flex flex-col md:flex-row md:items-start justify-between gap-3 mb-4">
<div>
<h2 class="text-xl font-semibold text-white mb-1">Peers <span class="text-white/40 text-sm font-medium" id="peersCount"></span></h2>
<p class="text-white/60 text-sm">Nodes this node is exchanging blocks and transactions with.</p>
</div>
<input class="filter-input" style="max-width:16rem" id="peerFilter" placeholder="Filter peers…" oninput="renderPeers()">
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th onclick="sortPeers('subver')" data-col="subver">Peer<span class="caret">&#9660;</span></th>
<th onclick="sortPeers('network')" data-col="network">Network<span class="caret">&#9660;</span></th>
<th onclick="sortPeers('relay')" data-col="relay">Relay TXNs<span class="caret">&#9660;</span></th>
<th onclick="sortPeers('direction')" data-col="direction">In/Out<span class="caret">&#9660;</span></th>
<th onclick="sortPeers('conntime')" data-col="conntime">Connected<span class="caret">&#9660;</span></th>
<th onclick="sortPeers('ping')" data-col="ping">Ping<span class="caret">&#9660;</span></th>
</tr>
</thead>
<tbody id="peersBody">
<tr><td colspan="6"><div class="empty-state">Loading peers…</div></td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ══ CONNECT ═══════════════════════════════════════════════════ -->
<div class="tab-panel" id="panel-connect">
<div class="glass-card p-6 mb-6">
<h2 class="text-xl font-semibold text-white mb-1">RPC Details</h2>
<p class="text-white/70 text-sm mb-4">Point a wallet or app at this node's JSON-RPC interface.</p>
<div class="mb-4">
<select id="connMode" onchange="renderConnect()" class="conn-select">
<option value="local">Local Network</option>
<option value="tor">Tor</option>
</select>
</div>
<div class="conn-layout">
<div class="qr-box" id="rpcQrBox">
<div style="color:#999;font-size:12px;text-align:center;padding:2rem">Loading…</div>
</div>
<div class="conn-fields">
<div>
<div class="field-label">Host</div>
<div class="field-row">
<span class="field-value" id="rpcHostField">-</span>
<button class="copy-btn" onclick="copyEl('rpcHostField', this)" title="Copy" aria-label="Copy host">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
<div>
<div class="field-label">Port</div>
<div class="field-row">
<span class="field-value" id="rpcPortField">8332</span>
<button class="copy-btn" onclick="copyEl('rpcPortField', this)" title="Copy" aria-label="Copy port">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
<div>
<div class="field-label">Username</div>
<div class="field-row">
<span class="field-value" id="rpcUserField">archipelago</span>
<button class="copy-btn" onclick="copyEl('rpcUserField', this)" title="Copy" aria-label="Copy username">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
<div>
<div class="field-label">Password</div>
<div class="field-row">
<span class="field-value text-white/50" id="rpcPassField">Held in the node's secret store</span>
</div>
<p class="text-xs text-white/40 mt-2">
The RPC password is a manifest-declared generated secret. It is injected into this app's
nginx upstream by the orchestrator and is deliberately never sent to the browser.
</p>
</div>
</div>
</div>
<div id="rpcLocalWarning" class="mt-4 p-3 rounded-lg" style="background:rgba(250,204,21,0.1);border:1px solid rgba(250,204,21,0.25)">
<p class="text-sm" style="color:#facc15">Local Network mode sends RPC traffic unencrypted. Only use it on a network you trust.</p>
</div>
</div>
<div class="glass-card p-6">
<h2 class="text-xl font-semibold text-white mb-1">P2P Details</h2>
<p class="text-white/70 text-sm mb-4">Let another Bitcoin node peer directly with this one.</p>
<div class="conn-layout">
<div class="qr-box" id="p2pQrBox">
<div style="color:#999;font-size:12px;text-align:center;padding:2rem">Loading…</div>
</div>
<div class="conn-fields">
<div>
<div class="field-label">Host</div>
<div class="field-row">
<span class="field-value" id="p2pHostField">-</span>
<button class="copy-btn" onclick="copyEl('p2pHostField', this)" title="Copy" aria-label="Copy P2P host">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
<div>
<div class="field-label">Port</div>
<div class="field-row">
<span class="field-value" id="p2pPortField">8333</span>
<button class="copy-btn" onclick="copyEl('p2pPortField', this)" title="Copy" aria-label="Copy P2P port">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke-width="2"/></svg>
</button>
</div>
</div>
<div class="p-3 rounded-lg" style="background:rgba(255,255,255,0.05)">
<p class="text-sm text-white/70">
For an Electrum wallet, install the <strong class="text-white/90">electrs</strong> app, wait for it to
finish indexing, and use the connection details it publishes.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- ══ SHARING ═══════════════════════════════════════════════════ -->
<div class="tab-panel" id="panel-sharing">
<div class="glass-card p-6 mb-8">
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-6">
<div>
@@ -639,6 +981,7 @@
</div>
</div>
</div>
</div><!-- /panel-sharing -->
</div>
<!-- Settings Modal -->
@@ -686,6 +1029,7 @@
</div>
</div>
<script src="qrcode.js"></script>
<script>
console.log('[Bitcoin UI] Script loaded, initializing...');
@@ -1254,7 +1598,12 @@
setInterval(loadRelayAccess, 15000);
function copyRPCInfo() {
const info = `RPC Host: ${window.location.hostname}:8332\nRPC User: archipelago\nRPC Password: archipelago123\nRPC Endpoint: ${RPC_ENDPOINT}`;
// No password here. It is a manifest-declared generated secret that
// the orchestrator renders straight into this app's nginx upstream;
// the browser never receives it. The old version of this function
// pasted a hardcoded "archipelago123", which was not the real
// credential and only ever misled whoever copied it.
const info = `RPC Host: ${window.location.hostname}:8332\nRPC User: archipelago\nRPC Password: (held in the node secret store)\nRPC Endpoint: ${RPC_ENDPOINT}`;
navigator.clipboard.writeText(info).then(() => {
alert('RPC info copied to clipboard!');
});
@@ -1312,6 +1661,299 @@ RPC server active on port 8332`;
}
}
// ══ Tabs / Insights / Peers / Connect ═══════════════════════════
// Everything below is additive: it reads through the same callRPC()
// proxy the rest of the page uses and never touches the sync/relay
// state machines above.
const P2P_PORT = 8333;
const RPC_PORT = 8332;
let peerCache = [];
let peerSort = { col: 'conntime', dir: 1 };
let blockCache = [];
let insightsLoaded = false;
function tabular(n) { return Number(n || 0).toLocaleString('en-US'); }
function shortAgo(unixSeconds) {
const secs = Math.floor(Date.now() / 1000) - Number(unixSeconds || 0);
if (!isFinite(secs) || secs < 0) return '-';
if (secs < 60) return secs + 's ago';
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
return Math.floor(secs / 86400) + 'd ago';
}
function formatUptime(secs) {
const n = Number(secs || 0);
if (n <= 0) return '-';
const d = Math.floor(n / 86400);
const h = Math.floor((n % 86400) / 3600);
const m = Math.floor((n % 3600) / 60);
if (d > 0) return d + 'd ' + h + 'h';
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
}
function setTab(tab) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + tab));
if (tab === 'insights' && !insightsLoaded) { insightsLoaded = true; updateInsights(); }
if (tab === 'peers') updatePeers();
if (tab === 'connect') renderConnect();
if (window.matchMedia('(max-width: 767px)').matches) window.scrollTo({ top: 0, behavior: 'smooth' });
try { history.replaceState(null, '', '#' + tab); } catch (_) {}
}
document.querySelectorAll('.tab-btn').forEach(b => b.addEventListener('click', () => setTab(b.dataset.tab)));
// ── Insights ────────────────────────────────────────────────────
async function updateInsights() {
try {
const [mempool, chain, uptimeSecs, peers] = await Promise.all([
callRPC('getmempoolinfo'),
callRPC('getblockchaininfo'),
callRPC('uptime'),
callRPC('getpeerinfo'),
]);
if (peers) {
peerCache = peers;
const inbound = peers.filter(p => p.inbound).length;
setTextIfPresent('insPeers', String(peers.length));
setTextIfPresent('insPeersSub', inbound + ' in / ' + (peers.length - inbound) + ' out');
}
if (mempool) {
setTextIfPresent('insMempool', tabular(mempool.size));
setTextIfPresent('insMempoolSub', formatBytes(mempool.bytes || 0) + ' of ' + formatBytes(mempool.maxmempool || 0));
}
if (chain) {
setTextIfPresent('insChainSize', formatBytes(chain.size_on_disk || 0));
setTextIfPresent('insChainSizeSub', (chain.pruned ? 'pruned' : 'full') + ' · ' + (chain.chain || '-'));
}
if (uptimeSecs != null) setTextIfPresent('insUptime', formatUptime(uptimeSecs));
if (chain && chain.blocks) await loadRecentBlocks(chain.blocks);
} catch (e) {
console.error('[Bitcoin UI] insights update failed', e);
}
}
// getblockstats gives height/time/size/txs/subsidy/fees in one call per
// block, which is what umbrel's block charts are built from. Only the
// tip is re-fetched on later passes, so this stays cheap.
async function loadRecentBlocks(tipHeight) {
const WANT = 10;
const have = new Set(blockCache.map(b => b.height));
const wanted = [];
for (let h = tipHeight; h > tipHeight - WANT && h >= 0; h--) if (!have.has(h)) wanted.push(h);
if (wanted.length) {
const fetched = await Promise.all(wanted.map(h =>
callRPC('getblockstats', [h, ['height', 'time', 'total_size', 'txs', 'subsidy', 'totalfee', 'avgfeerate']])
));
for (const b of fetched) if (b && b.height != null) blockCache.push(b);
}
blockCache = blockCache
.filter(b => b.height > tipHeight - WANT)
.sort((a, b) => b.height - a.height)
.slice(0, WANT);
renderBlocks();
renderCharts();
}
function renderBlocks() {
const strip = document.getElementById('blocksStrip');
if (!strip) return;
if (!blockCache.length) {
strip.innerHTML = '<div class="empty-state">Block statistics unavailable. A pruned node cannot report stats for blocks it no longer stores.</div>';
return;
}
strip.innerHTML = blockCache.map(b =>
'<div class="block-tile">' +
'<div class="h">' + tabular(b.height) + '</div>' +
'<div class="m">' + escapeHtml(shortAgo(b.time)) + '</div>' +
'<div class="m">' + tabular(b.txs) + ' txs</div>' +
'<div class="m">' + escapeHtml(formatBytes(b.total_size || 0)) + '</div>' +
'</div>').join('');
}
function renderChart(elId, subId, values, labels, cls, subtitle) {
const el = document.getElementById(elId);
if (!el) return;
const max = Math.max(...values, 0) || 1;
el.innerHTML = values.map((v, i) =>
'<div class="chart-col" title="' + escapeHtml(String(labels[i])) + '">' +
'<div class="chart-bar ' + cls + '" style="height:' + Math.max(2, (v / max) * 100) + '%"></div>' +
'<div class="chart-lbl">' + escapeHtml(String(labels[i])) + '</div>' +
'</div>').join('');
if (subtitle) setTextIfPresent(subId, subtitle);
}
function renderCharts() {
const asc = blockCache.slice().sort((a, b) => a.height - b.height);
if (!asc.length) return;
const labels = asc.map(b => String(b.height).slice(-4));
renderChart('chartSize', 'chartSizeSub',
asc.map(b => (b.total_size || 0) / 1e6), labels, 'orange',
'MB per block · last ' + asc.length);
renderChart('chartFee', 'chartFeeSub',
asc.map(b => b.avgfeerate || 0), labels, 'yellow',
'average sat/vB · last ' + asc.length);
renderChart('chartReward', 'chartRewardSub',
asc.map(b => ((b.subsidy || 0) + (b.totalfee || 0)) / 1e8), labels, 'green',
'subsidy + fees, BTC · last ' + asc.length);
}
// ── Peers table (umbrel PeersTable parity) ──────────────────────
async function updatePeers() {
const peers = await callRPC('getpeerinfo');
if (peers) peerCache = peers;
renderPeers();
}
function peerNetwork(p) {
const net = String(p.network || '').toLowerCase();
if (net === 'onion') return 'Tor';
if (net === 'i2p') return 'I2P';
if (net === 'cjdns') return 'CJDNS';
if (net === 'not_publicly_routable') return 'Local';
const addr = String(p.addr || '').toLowerCase();
if (addr.includes('.onion')) return 'Tor';
if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])|\[?::1\]?)/.test(addr)) return 'Local';
return 'Clearnet';
}
function sortPeers(col) {
if (peerSort.col === col) peerSort.dir *= -1; else { peerSort.col = col; peerSort.dir = 1; }
renderPeers();
}
function renderPeers() {
const body = document.getElementById('peersBody');
if (!body) return;
const q = (document.getElementById('peerFilter')?.value || '').toLowerCase();
let rows = peerCache.map(p => ({
subver: String(p.subver || '').replace(/^\/|\/$/g, '') || 'unknown',
addr: p.addr || '',
network: peerNetwork(p),
relay: p.relaytxes !== false,
direction: p.inbound ? 'Inbound' : 'Outbound',
conntime: Number(p.conntime || 0),
ping: Number(p.pingtime || 0),
}));
setTextIfPresent('peersCount', peerCache.length ? '(' + peerCache.length + ')' : '');
if (q) rows = rows.filter(r =>
r.subver.toLowerCase().includes(q) || r.addr.toLowerCase().includes(q) ||
r.network.toLowerCase().includes(q) || r.direction.toLowerCase().includes(q));
rows.sort((a, b) => {
const x = a[peerSort.col], y = b[peerSort.col];
let cmp;
if (typeof x === 'boolean') cmp = (x === y) ? 0 : (x ? 1 : -1);
else if (typeof x === 'number') cmp = x - y;
else cmp = String(x).localeCompare(String(y));
return cmp * peerSort.dir;
});
document.querySelectorAll('.data-table th[data-col]').forEach(th =>
th.classList.toggle('sorted', th.dataset.col === peerSort.col));
if (!rows.length) {
body.innerHTML = '<tr><td colspan="6"><div class="empty-state">' +
(peerCache.length ? 'No peers match that filter.' : 'No connected peers.') + '</div></td></tr>';
return;
}
const check = '<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>';
body.innerHTML = rows.map(r =>
'<tr>' +
'<td><div class="text-white/90">' + escapeHtml(r.subver) + '</div>' +
'<div class="text-xs text-white/40 truncate" style="max-width:14rem">' + escapeHtml(r.addr) + '</div></td>' +
'<td><span class="pill">' + escapeHtml(r.network) + '</span></td>' +
'<td><span style="opacity:' + (r.relay ? '1' : '0.2') + '">' + check + '</span></td>' +
'<td class="text-white/70">' + escapeHtml(r.direction) + '</td>' +
'<td class="text-white/70 tabular">' + escapeHtml(shortAgo(r.conntime)) + '</td>' +
'<td class="text-white/70 tabular">' + (r.ping ? (r.ping * 1000).toFixed(0) + ' ms' : '-') + '</td>' +
'</tr>').join('');
}
// ── Connect tab ─────────────────────────────────────────────────
function renderQR(containerId, text) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
if (typeof qrcode !== 'function') {
container.innerHTML = '<div style="color:#999;font-size:12px;text-align:center;padding:2rem">QR unavailable</div>';
return;
}
try {
const qr = qrcode(0, 'L');
qr.addData(text);
qr.make();
container.innerHTML = qr.createImgTag(3, 0);
} catch (e) {
container.innerHTML = '<div style="color:#999;font-size:11px;text-align:center;padding:2rem">QR too large</div>';
}
}
function renderConnect() {
const mode = document.getElementById('connMode')?.value || 'local';
const isTor = mode === 'tor';
// The onion is published by the relay-sharing settings; reuse it so
// Tor mode shows a real address rather than a placeholder.
const torEndpoint = (document.getElementById('relayTorInput')?.value || '').trim()
.replace(/^https?:\/\//, '').replace(/\/$/, '');
const rpcHost = isTor ? (torEndpoint || 'Tor not configured') : window.location.hostname;
setTextIfPresent('rpcHostField', rpcHost);
setTextIfPresent('rpcPortField', String(RPC_PORT));
setTextIfPresent('p2pHostField', rpcHost);
setTextIfPresent('p2pPortField', String(P2P_PORT));
const warn = document.getElementById('rpcLocalWarning');
if (warn) warn.style.display = isTor ? 'none' : '';
const usable = !isTor || !!torEndpoint;
if (usable) {
renderQR('rpcQrBox', 'btcrpc://' + rpcHost + ':' + RPC_PORT);
renderQR('p2pQrBox', rpcHost + ':' + P2P_PORT);
} else {
const msg = '<div style="color:#999;font-size:12px;text-align:center;padding:2rem">Tor not configured</div>';
document.getElementById('rpcQrBox').innerHTML = msg;
document.getElementById('p2pQrBox').innerHTML = msg;
}
}
function copyEl(id, btn) {
const text = document.getElementById(id)?.textContent.trim();
if (!text || text === '-') return;
navigator.clipboard.writeText(text).then(() => {
const orig = btn.innerHTML;
btn.innerHTML = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
btn.style.color = '#4ade80';
setTimeout(() => { btn.innerHTML = orig; btn.style.color = ''; }, 1500);
});
}
// Refresh the live tabs on the same cadence as the rest of the page,
// but only for whichever tab is actually visible.
setInterval(() => {
if (document.getElementById('panel-insights')?.classList.contains('active')) updateInsights();
else if (document.getElementById('panel-peers')?.classList.contains('active')) updatePeers();
}, 15000);
{
const initialTab = (location.hash || '').replace('#', '');
if (initialTab && document.getElementById('panel-' + initialTab)) setTab(initialTab);
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeSettings();
File diff suppressed because it is too large Load Diff