Files
archy/docker/lnd-ui/index.html
T
archipelagoandClaude Opus 5 341bff4b44 feat(lnd-ui): rebuild the LND app UI with UmbrelOS feature parity
The LND UI was a single scrolling page with a flat gradient instead of a
background, a permanently-disabled "Wallet" card, and four tabs buried
inside a Settings modal. Everything a node runner actually wants to see —
routing revenue, peers, liquidity, activity — was absent.

Rebuilt against the feature set of umbrelOS's Lightning Node and Bitcoin
Node apps (getumbrel/umbrel-lightning, getumbrel/umbrel-bitcoin), rendered
in Archipelago's own idiom rather than copying their visual design:

- Real background. bg-web5.jpg was already being COPYd into the image by
  the Dockerfile and simply never referenced; it now drives the same
  perspective-layer + 0.8 overlay treatment bitcoin-ui uses. Paths stay
  relative because the app is served at / on :18083 but under /app/lnd/
  when proxied by the host nginx, where absolute /assets 404s.
- Glass cards with the masked gradient border, matching bitcoin-ui exactly.
- Six top-level tabs (Overview / Channels / Activity / Insights / Connect /
  Settings) replacing the one-page scroll — umbrelOS's Home/Insights/
  Settings dock, widened for Lightning. On mobile the bar becomes a fixed
  bottom dock with safe-area padding; on desktop it is a segmented control.
- Overview: total/lightning/on-chain balances, a Max Send vs Max Receive
  liquidity bar (Umbrel's framing), and peers/channels/capacity/routing
  stat tiles.
- Insights: routing revenue over 24h/7d/30d from /v1/fees, channel totals,
  network graph stats, and a sortable+filterable peers table with Umbrel's
  Tor/I2P/Local/Clearnet classification.
- Activity: merged Lightning payments, settled invoices and on-chain
  transactions on one timeline, filterable by rail.
- Connect: the existing lndconnect QR flow, plus a Node ID panel with the
  pubkey and advertised URI (umbrel's NodeIdModal).
- sats/BTC unit switch persisted to localStorage (Umbrel's SatsBtcSwitch).

Channel management deliberately links out to the existing Archipelago
channels view at /apps/lnd/channels rather than being reimplemented here;
this app only shows a read-only channel overview.

Sync progress tracks the synced_to_chain/synced_to_graph booleans rather
than inventing a block-based percentage, because LND exposes no IBD ratio.

All data comes through the existing authenticated GET proxy at
/proxy/lnd, so no backend, manifest or nginx change is required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:19:34 -04:00

1594 lines
101 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>LND - Archipelago</title>
<style>
/* ── Archipelago design system ────────────────────────────────────
Mirrors docker/bitcoin-ui: perspective background layer, 0.8 dark
overlay, glass cards with a masked gradient border, and the same
glass/gradient button pair. Self-contained (no tailwind.css link)
because lnd-ui has always shipped its own utilities and the shared
tailwind.css is a hand-extracted subset built for bitcoin-ui. */
* { margin: 0; padding: 0; box-sizing: border-box; }
img, svg { display: block; vertical-align: middle; max-width: 100%; }
button { font-family: inherit; font-size: 100%; line-height: inherit; color: inherit; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
min-height: 100vh; background: #000; color: white; overflow-x: hidden;
-webkit-text-size-adjust: 100%;
}
/* Background — same perspective treatment as bitcoin-ui. Paths are
relative: the shell is served at / on :18083 but under /app/lnd/
when proxied by the host nginx, where absolute /assets 404s. */
.bg-perspective-container { position: fixed; inset: 0; z-index: -10; perspective: 1000px; perspective-origin: 50% 50%; overflow: hidden; }
.bg-layer {
position: absolute; inset: 0;
background-image: url('assets/img/bg-web5.jpg');
background-size: cover; background-position: center; background-repeat: no-repeat;
transition: all 0.45s cubic-bezier(0.68, -0.55, 0.265, 1.55);
transform-style: preserve-3d; opacity: 1; transform: translateZ(0) scale(1);
}
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.8); z-index: -5; pointer-events: none; }
/* Glass card — gradient border via masked ::before */
.glass-card {
position: relative; background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
box-shadow: 0 8px 24px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.22);
border-radius: 1rem; border: none; overflow-x: hidden; overflow-y: visible;
}
.glass-card::before {
content: ''; position: absolute; inset: 0; border-radius: inherit; padding: 2px;
background: linear-gradient(135deg, rgba(0,0,0,0.8), transparent);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none; z-index: 1;
}
.glass-card > * { position: relative; z-index: 2; }
.info-card {
position: relative; background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
box-shadow: 0 8px 24px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.22);
border-radius: 16px; padding: 12px; border: none;
}
.info-card::before {
content: ''; position: absolute; inset: 0; border-radius: inherit; padding: 2px;
background: linear-gradient(135deg, rgba(0,0,0,0.8), transparent);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none;
}
.info-card > * { position: relative; z-index: 2; }
.info-card-button {
position: relative; background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
box-shadow: 0 8px 24px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.22);
border-radius: 16px; padding: 12px; border: none; cursor: pointer;
color: rgba(255,255,255,0.9); transition: all 0.3s ease; text-align: left; width: 100%;
display: block; text-decoration: none;
}
.info-card-button::before {
content: ''; position: absolute; inset: 0; border-radius: inherit; padding: 2px;
background: linear-gradient(135deg, rgba(0,0,0,0.8), transparent);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none; transition: all 0.3s ease;
}
.info-card-button > * { position: relative; z-index: 2; }
.info-card-button:hover {
transform: translateY(-2px); background: rgba(0,0,0,0.35);
box-shadow: 0 12px 32px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.25);
color: rgba(255,255,255,1);
}
.info-card-button:hover::before { background: linear-gradient(135deg, rgba(255,255,255,0.3), transparent); }
.info-card-button:active { transform: translateY(1px); }
.glass-button {
background-color: rgba(0,0,0,0.6); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
border: 1px solid rgba(255,255,255,0.18); color: rgba(255,255,255,0.9);
transition: all 0.3s ease; cursor: pointer;
}
.glass-button:hover { color: white; background-color: rgba(0,0,0,0.7); }
.glass-button:disabled { opacity: 0.5; cursor: not-allowed; }
.gradient-button {
background: linear-gradient(135deg, rgba(255,255,255,0.15) 0%, rgba(0,0,0,0.8) 100%);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.2); color: rgba(255,255,255,0.95);
transition: all 0.3s ease; cursor: pointer;
}
.gradient-button:hover {
background: linear-gradient(135deg, rgba(255,255,255,0.2) 0%, rgba(0,0,0,0.9) 100%);
border-color: rgba(255,255,255,0.3); box-shadow: 0 8px 24px rgba(0,0,0,0.6); transform: translateY(-1px);
}
.gradient-button:active { transform: translateY(1px); }
/* Logo — LND purple gradient ring */
.logo-gradient-border {
position: relative; border-radius: 16px; padding: 3px;
background: linear-gradient(135deg, rgba(167,139,250,0.85) 0%, rgba(0,0,0,0.8) 100%);
box-shadow: 0 8px 24px rgba(0,0,0,0.5); display: inline-block;
}
.logo-gradient-border::after { content: ''; position: absolute; inset: 3px; border-radius: 13px; background: #0d0b16; z-index: 0; }
.logo-gradient-border img { border-radius: 13px; display: block; position: relative; z-index: 1; width: 64px; height: 64px; object-fit: contain; padding: 6px; }
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; padding-bottom: 4rem; }
/* Layout utilities */
.flex { display: flex; } .flex-col { flex-direction: column; }
.items-center { align-items: center; } .items-start { align-items: flex-start; } .items-end { align-items: flex-end; }
.justify-between { justify-content: space-between; } .justify-center { justify-content: center; }
.flex-1 { flex: 1 1 0%; } .flex-shrink-0 { flex-shrink: 0; } .flex-wrap { flex-wrap: wrap; }
.min-w-0 { min-width: 0; } .w-full { width: 100%; } .relative { position: relative; }
.grid { display: grid; }
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0,1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
.gap-2 { gap: 0.5rem; } .gap-3 { gap: 0.75rem; } .gap-4 { gap: 1rem; } .gap-6 { gap: 1.5rem; }
.mb-1 { margin-bottom: 0.25rem; } .mb-2 { margin-bottom: 0.5rem; } .mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; } .mb-6 { margin-bottom: 1.5rem; } .mb-8 { margin-bottom: 2rem; }
.mt-1 { margin-top: 0.25rem; } .mt-2 { margin-top: 0.5rem; } .mt-3 { margin-top: 0.75rem; } .mt-4 { margin-top: 1rem; }
.p-2 { padding: 0.5rem; } .p-3 { padding: 0.75rem; } .p-4 { padding: 1rem; } .p-5 { padding: 1.25rem; } .p-6 { padding: 1.5rem; }
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; } .px-4 { padding-left: 1rem; padding-right: 1rem; } .px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-1-5 { padding-top: 0.375rem; padding-bottom: 0.375rem; } .py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.py-2-5 { padding-top: 0.625rem; padding-bottom: 0.625rem; } .py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.rounded-lg { border-radius: 0.5rem; } .rounded-xl { border-radius: 0.75rem; } .rounded-full { border-radius: 9999px; }
.overflow-hidden { overflow: hidden; }
.text-center { text-align: center; } .text-right { text-align: right; }
.text-xs { font-size: 0.75rem; } .text-sm { font-size: 0.875rem; } .text-base { font-size: 1rem; }
.text-lg { font-size: 1.125rem; } .text-xl { font-size: 1.25rem; } .text-2xl { font-size: 1.5rem; }
.text-3xl { font-size: 1.875rem; } .text-4xl { font-size: 2.25rem; }
.font-bold { font-weight: 700; } .font-semibold { font-weight: 600; } .font-medium { font-weight: 500; }
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.uppercase { text-transform: uppercase; } .tracking-wide { letter-spacing: 0.05em; }
.truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.break-all { word-break: break-all; }
.tabular { font-variant-numeric: tabular-nums; }
.text-white { color: #fff; }
.text-white-90 { color: rgba(255,255,255,0.9); } .text-white-80 { color: rgba(255,255,255,0.8); }
.text-white-70 { color: rgba(255,255,255,0.7); } .text-white-60 { color: rgba(255,255,255,0.6); }
.text-white-50 { color: rgba(255,255,255,0.5); } .text-white-45 { color: rgba(255,255,255,0.45); }
.text-white-40 { color: rgba(255,255,255,0.4); }
.text-green { color: #4ade80; } .text-orange { color: #fb923c; } .text-red { color: #f87171; }
.text-purple { color: #a78bfa; } .text-yellow { color: #facc15; } .text-btc { color: #f7931a; }
.bg-green { background: #4ade80; } .bg-yellow { background: #facc15; } .bg-red { background: #f87171; }
.bg-grey { background: rgba(255,255,255,0.35); }
.icon-box { width: 3rem; height: 3rem; border-radius: 0.75rem; background: rgba(139,92,246,0.2); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.icon-box-neutral { background: rgba(255,255,255,0.1); }
.icon-box-btc { background: rgba(247,147,26,0.18); }
.status-dot { width: 0.75rem; height: 0.75rem; border-radius: 9999px; }
.status-dot-sm { width: 0.5rem; height: 0.5rem; border-radius: 9999px; display: inline-block; flex-shrink: 0; }
.sub-row { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.75rem; background: rgba(255,255,255,0.05); border-radius: 0.5rem; }
/* Animations */
@keyframes ping { 75%, 100% { transform: scale(2); opacity: 0; } }
.animate-ping { animation: ping 1s cubic-bezier(0,0,0.2,1) infinite; position: absolute; inset: 0; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.animate-spin-slow { animation: spin 3s linear infinite; }
@keyframes shimmer { 0% { background-position: -1000px 0; } 100% { background-position: 1000px 0; } }
.shimmer { background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0) 100%); background-size: 1000px 100%; animation: shimmer 3s infinite; }
@keyframes progressGlow {
0%, 100% { box-shadow: 0 0 10px rgba(167,139,250,0.5), 0 0 20px rgba(167,139,250,0.3); }
50% { box-shadow: 0 0 20px rgba(167,139,250,0.8), 0 0 30px rgba(167,139,250,0.5); }
}
.progress-glow { animation: progressGlow 2s ease-in-out infinite; }
@keyframes fadeUp { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
.tab-panel.active { animation: fadeUp 0.28s ease both; }
/* ── Primary tab bar (Umbrel'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(167,139,250,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; }
/* ── Balance hero ───────────────────────────────────────────────── */
.balance-hero { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
.balance-tile { padding: 1.15rem; background: rgba(255,255,255,0.045); border: 1px solid rgba(255,255,255,0.08); border-radius: 0.85rem; }
.balance-tile .label { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: rgba(255,255,255,0.45); margin-bottom: 0.4rem; display: flex; align-items: center; gap: 0.4rem; }
.balance-tile .value { font-size: 1.6rem; font-weight: 700; line-height: 1.15; font-variant-numeric: tabular-nums; }
.balance-tile .sub { font-size: 0.75rem; color: rgba(255,255,255,0.45); margin-top: 0.25rem; }
.balance-tile.total { background: linear-gradient(135deg, rgba(167,139,250,0.16) 0%, rgba(255,255,255,0.04) 100%); border-color: rgba(167,139,250,0.28); }
/* Unit switch (sats / BTC) */
.unit-switch { display: inline-flex; padding: 0.2rem; background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.1); border-radius: 0.6rem; }
.unit-switch button { padding: 0.3rem 0.7rem; border: none; background: none; border-radius: 0.45rem; font-size: 0.75rem; font-weight: 600; color: rgba(255,255,255,0.5); cursor: pointer; transition: all 0.2s ease; }
.unit-switch button.active { background: rgba(255,255,255,0.16); color: #fff; }
/* Liquidity bar — local (send) vs remote (receive) */
.liq-bar { height: 0.6rem; width: 100%; border-radius: 9999px; background: rgba(255,255,255,0.1); overflow: hidden; display: flex; }
.liq-bar .local { background: linear-gradient(90deg, #a78bfa, #8b5cf6); height: 100%; transition: width 0.6s ease; }
.liq-bar .remote { background: linear-gradient(90deg, #4ade80, #22c55e); height: 100%; transition: width 0.6s ease; }
/* Sync banner */
.progress-track { width: 100%; height: 0.6rem; background: rgba(255,255,255,0.1); border-radius: 9999px; overflow: hidden; position: relative; }
.progress-fill { height: 100%; border-radius: 9999px; background: linear-gradient(90deg, #8b5cf6, #facc15); transition: width 0.5s ease; }
/* 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); }
.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; }
/* Field rows with copy */
.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); }
.field-row-split { display: flex; gap: 0.75rem; }
.field-row-split > div { flex: 1; min-width: 0; }
.help-text { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid rgba(255,255,255,0.08); font-size: 0.875rem; color: rgba(255,255,255,0.5); line-height: 1.5; }
/* Connect */
.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:focus { border-color: rgba(255,255,255,0.25); }
.conn-select option { background: #1a1a2e; color: white; }
.conn-layout { display: flex; flex-direction: column; gap: 1.5rem; }
.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-fields { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.75rem; }
/* Tables (peers / activity) */
.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(167,139,250,0.5); }
/* Activity list */
.act-row { display: flex; align-items: center; gap: 0.85rem; padding: 0.75rem; border-radius: 0.6rem; transition: background 0.2s ease; }
.act-row:hover { background: rgba(255,255,255,0.04); }
.act-icon { width: 2.15rem; height: 2.15rem; border-radius: 9999px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.act-icon.in { background: rgba(74,222,128,0.15); color: #4ade80; }
.act-icon.out { background: rgba(167,139,250,0.18); color: #a78bfa; }
.act-icon.pending { background: rgba(250,204,21,0.15); color: #facc15; }
.act-icon.failed { background: rgba(248,113,113,0.15); color: #f87171; }
.chip-row { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.chip { padding: 0.35rem 0.8rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.1); color: rgba(255,255,255,0.6); cursor: pointer; transition: all 0.2s ease; }
.chip:hover { color: rgba(255,255,255,0.9); }
.chip.active { background: rgba(167,139,250,0.22); border-color: rgba(167,139,250,0.4); color: #fff; }
.empty-state { padding: 2.5rem 1rem; text-align: center; color: rgba(255,255,255,0.4); font-size: 0.875rem; }
.logs-box { background: rgba(0,0,0,0.45); border-radius: 0.5rem; padding: 1rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.75rem; color: rgba(255,255,255,0.8); white-space: pre-wrap; word-break: break-all; min-height: 220px; max-height: 55vh; overflow-y: auto; }
.skel { display: inline-block; height: 0.85em; width: 4.5rem; border-radius: 0.25rem; background: rgba(255,255,255,0.1); }
/* ── Responsive ─────────────────────────────────────────────────── */
@media (min-width: 640px) {
.sm-grid-cols-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.sm-grid-cols-3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
.balance-hero { grid-template-columns: repeat(3, minmax(0,1fr)); }
.conn-layout { flex-direction: row; }
.qr-box { margin: 0; width: 216px; }
}
@media (min-width: 768px) {
.md-grid-cols-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.md-grid-cols-3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
.md-grid-cols-4 { grid-template-columns: repeat(4, minmax(0,1fr)); }
.md-flex-row { flex-direction: row; }
.md-text-right { text-align: right; }
}
@media (min-width: 1024px) {
.lg-grid-cols-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.lg-grid-cols-3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
.lg-grid-cols-4 { grid-template-columns: repeat(4, minmax(0,1fr)); }
}
/* Mobile: the tab bar becomes a bottom dock, like umbrelOS. */
@media (max-width: 767px) {
.container { padding: 1rem; padding-bottom: 6.5rem; }
.balance-tile .value { font-size: 1.35rem; }
.logo-gradient-border img { width: 52px; height: 52px; }
.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; }
.hide-mobile { display: none; }
.data-table th, .data-table td { padding: 0.55rem 0.5rem; }
}
@media (min-width: 768px) { .only-mobile { display: none; } }
</style>
</head>
<body>
<div class="bg-perspective-container"><div class="bg-layer"></div></div>
<div class="overlay"></div>
<div class="container">
<!-- ── Header ──────────────────────────────────────────────────── -->
<div class="glass-card p-6 mb-6">
<div class="flex flex-col md-flex-row items-center gap-4">
<div class="flex-shrink-0">
<div class="logo-gradient-border">
<img src="assets/img/app-icons/lnd.svg" alt="LND" onerror="this.style.display='none'" />
</div>
</div>
<div class="flex-1 min-w-0 w-full">
<div class="flex items-center gap-3 flex-wrap mb-1">
<h1 class="text-2xl font-bold text-white">LND</h1>
<span class="pill" id="headerStatusPill">
<span class="status-dot-sm bg-grey" id="headerStatusDot"></span>
<span id="headerStatusText">Connecting…</span>
</span>
<span class="pill" id="headerNetworkPill" style="display:none"></span>
</div>
<p class="text-white-70 text-sm" id="headerAlias">Lightning Network Daemon for instant Bitcoin payments</p>
<p class="text-xs text-white-40 mt-1 font-mono truncate" id="headerPubkey">&mdash;</p>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<div class="unit-switch" role="group" aria-label="Display unit">
<button id="unitSats" class="active" onclick="setUnit('sats')">sats</button>
<button id="unitBtc" onclick="setUnit('btc')">BTC</button>
</div>
<button onclick="refreshAll()" class="glass-button flex items-center gap-2 px-4 py-2-5 rounded-lg text-sm font-medium" id="refreshBtn" title="Refresh">
<svg style="width:1.1rem;height:1.1rem" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" id="refreshIcon">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span class="hide-mobile">Refresh</span>
</button>
</div>
</div>
</div>
<!-- ── Sync banner (only while the node is not fully synced) ────── -->
<div class="glass-card p-6 mb-6" id="syncCard" style="display:none">
<div class="flex items-start gap-4 mb-4">
<div class="icon-box">
<svg style="width:1.5rem;height:1.5rem;color:#a78bfa" class="animate-spin-slow" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-white mb-1">Synchronizing</h2>
<p class="text-white-70 text-sm" id="syncSubtitle">Waiting for the Bitcoin backend…</p>
</div>
</div>
<div class="flex justify-between text-sm text-white-60 mb-2">
<span id="syncBlockLabel">Block &mdash;</span>
<span id="syncPercent">&mdash;</span>
</div>
<div class="progress-track shimmer">
<div class="progress-fill progress-glow" id="syncProgressBar" style="width:0%"></div>
</div>
<div class="grid grid-cols-2 md-grid-cols-4 gap-3 mt-4">
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Chain</p>
<p class="text-sm font-semibold text-white" id="syncChain">&mdash;</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Graph</p>
<p class="text-sm font-semibold text-white" id="syncGraph">&mdash;</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Block Height</p>
<p class="text-sm font-semibold text-white tabular" id="syncHeight">&mdash;</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Peers</p>
<p class="text-sm font-semibold text-white tabular" id="syncPeers">&mdash;</p>
</div>
</div>
</div>
<!-- ── Tab bar ─────────────────────────────────────────────────── -->
<div class="tabbar-wrap">
<div class="tabbar" role="tablist">
<button class="tab-btn active" data-tab="overview" 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>Overview</span>
</button>
<button class="tab-btn" data-tab="channels" role="tab">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
<span>Channels</span>
</button>
<button class="tab-btn" data-tab="activity" role="tab">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 12h4l2-7 4 14 2-7h4"/></svg>
<span>Activity</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="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="settings" role="tab">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>Settings</span>
</button>
</div>
</div>
<!-- ══ OVERVIEW ══════════════════════════════════════════════════ -->
<div class="tab-panel active" id="panel-overview">
<!-- Balances -->
<div class="glass-card p-6 mb-6">
<div class="flex items-center justify-between gap-4 mb-4">
<div class="flex items-center gap-4 min-w-0">
<div class="icon-box">
<svg style="width:1.4rem;height:1.4rem;color:#a78bfa" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/></svg>
</div>
<div class="min-w-0">
<h2 class="text-xl font-semibold text-white">Balance</h2>
<p class="text-white-60 text-sm">Funds held by this node</p>
</div>
</div>
</div>
<div class="balance-hero mb-4">
<div class="balance-tile total">
<div class="label">Total</div>
<div class="value text-white" id="balTotal">&mdash;</div>
<div class="sub" id="balTotalSub">on-chain + lightning</div>
</div>
<div class="balance-tile">
<div class="label"><span class="text-purple">&#9889;</span> Lightning</div>
<div class="value text-purple" id="balLightning">&mdash;</div>
<div class="sub" id="balLightningSub">&mdash;</div>
</div>
<div class="balance-tile">
<div class="label"><span class="text-btc">&#8383;</span> On-chain</div>
<div class="value text-btc" id="balOnchain">&mdash;</div>
<div class="sub" id="balOnchainSub">&mdash;</div>
</div>
</div>
<!-- Liquidity (Umbrel: Max Send / Max Receive) -->
<div class="p-4" style="background:rgba(255,255,255,0.04);border-radius:0.75rem;border:1px solid rgba(255,255,255,0.08)">
<div class="flex items-center justify-between mb-2">
<div>
<div class="text-xs text-white-50 uppercase tracking-wide mb-1">Max Send</div>
<div class="text-base font-semibold text-purple tabular" id="liqLocal">&mdash;</div>
</div>
<div class="text-right">
<div class="text-xs text-white-50 uppercase tracking-wide mb-1">Max Receive</div>
<div class="text-base font-semibold text-green tabular" id="liqRemote">&mdash;</div>
</div>
</div>
<div class="liq-bar">
<div class="local" id="liqBarLocal" style="width:50%"></div>
<div class="remote" id="liqBarRemote" style="width:50%"></div>
</div>
<p class="text-xs text-white-40 mt-2" id="liqHint">Outbound liquidity lets you send; inbound liquidity lets you receive.</p>
</div>
</div>
<!-- Summary stats -->
<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="statPeers">&mdash;</p>
<p class="text-xs text-white-40 mt-1">peers</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Active Channels</p>
<p class="text-2xl font-bold text-orange tabular" id="statActiveChannels">&mdash;</p>
<p class="text-xs text-white-40 mt-1" id="statChannelsSub">&mdash;</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Total Capacity</p>
<p class="text-2xl font-bold text-white tabular" id="statCapacity">&mdash;</p>
<p class="text-xs text-white-40 mt-1">across all channels</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Routing Earned</p>
<p class="text-2xl font-bold text-green tabular" id="statRoutingMonth">&mdash;</p>
<p class="text-xs text-white-40 mt-1">last 30 days</p>
</div>
</div>
<!-- Channels + node health -->
<div class="grid grid-cols-1 lg-grid-cols-2 gap-6">
<a class="info-card-button p-5" id="channelsLinkCard" href="#" target="_top">
<div class="flex items-center gap-4">
<div class="icon-box">
<svg style="width:1.4rem;height:1.4rem;color:#a78bfa" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-white mb-1">Payment Channels</h3>
<p class="text-white-60 text-sm" id="channelsLinkSub">Open, manage and close channels</p>
</div>
<svg style="width:1.25rem;height:1.25rem;color:rgba(255,255,255,0.5);flex-shrink:0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
</div>
</a>
<div class="glass-card p-5">
<h3 class="text-lg font-semibold text-white mb-3">Node Health</h3>
<div class="flex flex-col gap-2">
<div class="sub-row">
<span class="text-white-80 text-sm">Synced to chain</span>
<span class="pill" id="healthChain">&mdash;</span>
</div>
<div class="sub-row">
<span class="text-white-80 text-sm">Synced to graph</span>
<span class="pill" id="healthGraph">&mdash;</span>
</div>
<div class="sub-row">
<span class="text-white-80 text-sm">Block height</span>
<span class="text-white-60 text-sm font-mono tabular" id="healthHeight">&mdash;</span>
</div>
<div class="sub-row">
<span class="text-white-80 text-sm">Pending channels</span>
<span class="text-white-60 text-sm tabular" id="healthPending">&mdash;</span>
</div>
</div>
</div>
</div>
</div>
<!-- ══ CHANNELS ══════════════════════════════════════════════════ -->
<div class="tab-panel" id="panel-channels">
<div class="glass-card p-6 mb-6">
<div class="flex flex-col md-flex-row items-start gap-4 justify-between mb-5">
<div class="flex items-start gap-4 min-w-0">
<div class="icon-box">
<svg style="width:1.4rem;height:1.4rem;color:#a78bfa" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
</div>
<div class="min-w-0">
<h2 class="text-xl font-semibold text-white mb-1">Payment Channels</h2>
<p class="text-white-60 text-sm">Full channel management &mdash; opening, balancing and closing &mdash; lives in the Archipelago channels view.</p>
</div>
</div>
<a class="gradient-button px-4 py-2-5 rounded-lg text-sm font-medium flex-shrink-0 flex items-center gap-2" id="channelsOpenBtn" href="#" target="_top" style="text-decoration:none">
Manage Channels
<svg style="width:1rem;height:1rem" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg>
</a>
</div>
<div class="grid grid-cols-2 md-grid-cols-4 gap-3">
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Active</p>
<p class="text-xl font-bold text-green tabular" id="chActive">&mdash;</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Inactive</p>
<p class="text-xl font-bold text-white-60 tabular" id="chInactive">&mdash;</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Pending</p>
<p class="text-xl font-bold text-yellow tabular" id="chPending">&mdash;</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Capacity</p>
<p class="text-xl font-bold text-white tabular" id="chCapacity">&mdash;</p>
</div>
</div>
</div>
<!-- Compact read-only channel list; the full view is the Archipelago one -->
<div class="glass-card p-6">
<div class="flex items-center justify-between gap-3 mb-4">
<h3 class="text-lg font-semibold text-white">Channel Overview</h3>
<input class="filter-input" style="max-width:16rem" id="channelFilter" placeholder="Filter channels…" oninput="renderChannels()">
</div>
<div id="channelList"><div class="empty-state">Loading channels…</div></div>
</div>
</div>
<!-- ══ ACTIVITY ══════════════════════════════════════════════════ -->
<div class="tab-panel" id="panel-activity">
<div class="glass-card p-6">
<div class="flex flex-col md-flex-row items-start gap-4 justify-between mb-4">
<div class="min-w-0">
<h2 class="text-xl font-semibold text-white mb-1">Activity</h2>
<p class="text-white-60 text-sm">Recent Lightning payments, invoices and on-chain transactions.</p>
</div>
<div class="chip-row flex-shrink-0">
<button class="chip active" data-filter="all" onclick="setActivityFilter('all')">All</button>
<button class="chip" data-filter="lightning" onclick="setActivityFilter('lightning')">Lightning</button>
<button class="chip" data-filter="onchain" onclick="setActivityFilter('onchain')">On-chain</button>
</div>
</div>
<div id="activityList"><div class="empty-state">Loading activity…</div></div>
</div>
</div>
<!-- ══ INSIGHTS ══════════════════════════════════════════════════ -->
<div class="tab-panel" id="panel-insights">
<!-- Routing revenue -->
<div class="glass-card p-6 mb-6">
<div class="flex items-start gap-4 mb-4">
<div class="icon-box">
<svg style="width:1.4rem;height:1.4rem;color:#4ade80" 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>
</div>
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-white mb-1">Routing Revenue</h2>
<p class="text-white-60 text-sm">Fees earned forwarding payments for the network.</p>
</div>
</div>
<div class="grid grid-cols-1 sm-grid-cols-3 gap-3">
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Last 24 hours</p>
<p class="text-2xl font-bold text-green tabular" id="feeDay">&mdash;</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Last 7 days</p>
<p class="text-2xl font-bold text-green tabular" id="feeWeek">&mdash;</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Last 30 days</p>
<p class="text-2xl font-bold text-green tabular" id="feeMonth">&mdash;</p>
</div>
</div>
</div>
<!-- Node totals -->
<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">Sent</p>
<p class="text-lg font-semibold text-purple tabular" id="insSent">&mdash;</p>
<p class="text-xs text-white-40 mt-1">over channels</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Received</p>
<p class="text-lg font-semibold text-green tabular" id="insReceived">&mdash;</p>
<p class="text-xs text-white-40 mt-1">over channels</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Channel Updates</p>
<p class="text-lg font-semibold text-white tabular" id="insUpdates">&mdash;</p>
<p class="text-xs text-white-40 mt-1">state transitions</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Avg. Channel Age</p>
<p class="text-lg font-semibold text-white" id="insAge">&mdash;</p>
<p class="text-xs text-white-40 mt-1">since open</p>
</div>
</div>
<!-- Lightning Network graph -->
<div class="glass-card p-6 mb-6">
<h3 class="text-lg font-semibold text-white mb-1">Lightning Network</h3>
<p class="text-white-60 text-sm mb-4">What this node sees of the public network graph.</p>
<div class="grid grid-cols-2 md-grid-cols-4 gap-3">
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Nodes</p>
<p class="text-lg font-semibold text-white tabular" id="graphNodes">&mdash;</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Channels</p>
<p class="text-lg font-semibold text-white tabular" id="graphChannels">&mdash;</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Network Capacity</p>
<p class="text-lg font-semibold text-white tabular" id="graphCapacity">&mdash;</p>
</div>
<div class="info-card">
<p class="text-xs text-white-60 mb-1">Avg. Channel Size</p>
<p class="text-lg font-semibold text-white tabular" id="graphAvgSize">&mdash;</p>
</div>
</div>
</div>
<!-- Peers table (Umbrel PeersTable parity) -->
<div class="glass-card p-6">
<div class="flex flex-col md-flex-row items-start justify-between gap-3 mb-4">
<div>
<h3 class="text-lg font-semibold text-white mb-1">Peers <span class="text-white-40 text-sm font-medium" id="peersCount"></span></h3>
<p class="text-white-60 text-sm">Nodes this node holds a live connection to.</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('peer')" data-col="peer">Peer<span class="caret">&#9660;</span></th>
<th onclick="sortPeers('network')" data-col="network">Network<span class="caret">&#9660;</span></th>
<th onclick="sortPeers('direction')" data-col="direction">In/Out<span class="caret">&#9660;</span></th>
<th onclick="sortPeers('ping')" data-col="ping">Ping<span class="caret">&#9660;</span></th>
<th onclick="sortPeers('sent')" data-col="sent">Sent<span class="caret">&#9660;</span></th>
<th onclick="sortPeers('recv')" data-col="recv">Received<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">Connect Your Wallet</h2>
<p class="text-white-70 text-sm mb-4" id="connSubtitle">Use a wallet like Zeus, Zap, or BlueWallet to connect remotely.</p>
<div class="mb-4">
<select id="connMode" onchange="updateConnInfo()" class="conn-select">
<option value="rest-tor">REST (Tor)</option>
<option value="rest-local">REST (Local Network)</option>
<option value="grpc-tor">gRPC (Tor)</option>
<option value="grpc-local">gRPC (Local Network)</option>
</select>
</div>
<div class="conn-layout" id="connDisplay">
<div class="qr-box" id="lndQrBox">
<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="connHost">&mdash;</span>
<button class="copy-btn" onclick="copyEl('connHost', 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 class="field-row-split">
<div>
<div class="field-label">Port</div>
<div class="field-row">
<span class="field-value" id="connPort">&mdash;</span>
<button class="copy-btn" onclick="copyEl('connPort', 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">Protocol</div>
<div class="field-row">
<span class="field-value" id="connProto">&mdash;</span>
</div>
</div>
</div>
<button onclick="copyLndconnectUri()" class="gradient-button w-full mt-2 px-4 py-2-5 rounded-lg text-sm font-medium" id="copyUriBtn">Copy lndconnect URI</button>
</div>
</div>
<div class="help-text">
Scan the QR code with <strong style="color:rgba(255,255,255,0.8)">Zeus</strong>, <strong style="color:rgba(255,255,255,0.8)">Zap</strong>, or <strong style="color:rgba(255,255,255,0.8)">BlueWallet</strong> to connect. Tor mode is recommended for remote access &mdash; Local Network mode sends the macaroon unencrypted over your LAN.
</div>
</div>
<!-- Node ID (Umbrel NodeIdModal parity) -->
<div class="glass-card p-6">
<h2 class="text-xl font-semibold text-white mb-1">Node ID</h2>
<p class="text-white-70 text-sm mb-4">Share this so other nodes can open a channel with you.</p>
<div class="conn-layout">
<div class="qr-box" id="nodeIdQrBox">
<div style="color:#999;font-size:12px;text-align:center;padding:2rem">&mdash;</div>
</div>
<div class="conn-fields">
<div>
<div class="field-label">Public Key</div>
<div class="field-row">
<span class="field-value" id="nodePubkey">&mdash;</span>
<button class="copy-btn" onclick="copyEl('nodePubkey', this)" title="Copy" aria-label="Copy public key">
<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 id="nodeUriWrap">
<div class="field-label">Node URI</div>
<div class="field-row">
<span class="field-value" id="nodeUri">&mdash;</span>
<button class="copy-btn" onclick="copyEl('nodeUri', this)" title="Copy" aria-label="Copy node URI">
<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>
<p class="text-xs text-white-40 mt-2" id="nodeUriHint"></p>
</div>
</div>
</div>
</div>
</div>
<!-- ══ SETTINGS ══════════════════════════════════════════════════ -->
<div class="tab-panel" id="panel-settings">
<div class="grid grid-cols-1 lg-grid-cols-2 gap-6 mb-6">
<div class="glass-card p-6">
<div class="flex items-start gap-4 mb-4">
<div class="icon-box">
<svg style="width:1.4rem;height:1.4rem;color:#a78bfa" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-white mb-1">Node</h2>
<p class="text-white-60 text-sm">Identity and chain configuration</p>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="sub-row"><span class="text-white-80 text-sm">Status</span><span class="text-sm font-medium" id="setStatus">&mdash;</span></div>
<div class="sub-row"><span class="text-white-80 text-sm">Alias</span><span class="text-white-60 text-sm truncate" id="setAlias">&mdash;</span></div>
<div class="sub-row"><span class="text-white-80 text-sm">Version</span><span class="text-white-60 text-sm font-mono truncate" id="setVersion">&mdash;</span></div>
<div class="sub-row"><span class="text-white-80 text-sm">Network</span><span class="text-white-60 text-sm" id="setNetwork">&mdash;</span></div>
<div class="sub-row"><span class="text-white-80 text-sm">Chain</span><span class="text-white-60 text-sm" id="setChain">&mdash;</span></div>
<div class="sub-row"><span class="text-white-80 text-sm">Commit hash</span><span class="text-white-60 text-sm font-mono truncate" id="setCommit">&mdash;</span></div>
</div>
</div>
<div class="glass-card p-6">
<div class="flex items-start gap-4 mb-4">
<div class="icon-box icon-box-neutral">
<svg style="width:1.4rem;height:1.4rem;color:rgba(255,255,255,0.8)" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"/></svg>
</div>
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-white mb-1">Interfaces</h2>
<p class="text-white-60 text-sm">API endpoints exposed by this node</p>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="sub-row">
<span class="flex items-center gap-2 text-white-80 text-sm"><span class="status-dot-sm bg-grey" id="restDot"></span>REST API</span>
<span class="text-white-60 text-sm font-mono truncate" id="setRest">&mdash;</span>
</div>
<div class="sub-row">
<span class="flex items-center gap-2 text-white-80 text-sm"><span class="status-dot-sm bg-grey" id="grpcDot"></span>gRPC</span>
<span class="text-white-60 text-sm font-mono truncate" id="setGrpc">&mdash;</span>
</div>
<div class="sub-row">
<span class="text-white-80 text-sm">P2P</span>
<span class="text-white-60 text-sm font-mono" id="setP2p">&mdash;</span>
</div>
<div class="sub-row">
<span class="text-white-80 text-sm">Tor hidden service</span>
<span class="text-white-60 text-sm font-mono truncate" id="setTor">&mdash;</span>
</div>
</div>
<button class="info-card-button w-full mt-4 text-sm font-medium text-center" onclick="copyEndpointInfo(this)">Copy Connection Info</button>
</div>
</div>
<!-- Advanced settings live in the orchestrator, not here -->
<div class="glass-card p-6 mb-6">
<h3 class="text-lg font-semibold text-white mb-1">Advanced Configuration</h3>
<p class="text-white-60 text-sm mb-4">
LND's runtime configuration is manifest-declared and owned by the Archipelago orchestrator, so it stays reproducible across reinstalls and node migrations. This panel reflects the values the node is running with.
</p>
<div class="grid grid-cols-1 md-grid-cols-2 gap-3">
<div class="sub-row"><span class="text-white-80 text-sm">Bitcoin backend</span><span class="text-white-60 text-sm" id="cfgBackend">&mdash;</span></div>
<div class="sub-row"><span class="text-white-80 text-sm">Watchtower client</span><span class="text-white-60 text-sm" id="cfgWatchtower">&mdash;</span></div>
<div class="sub-row"><span class="text-white-80 text-sm">Keysend</span><span class="text-white-60 text-sm" id="cfgKeysend">&mdash;</span></div>
<div class="sub-row"><span class="text-white-80 text-sm">Wumbo channels</span><span class="text-white-60 text-sm" id="cfgWumbo">&mdash;</span></div>
</div>
</div>
<!-- Logs -->
<div class="glass-card p-6">
<div class="flex justify-between items-center gap-3 mb-3">
<h3 class="text-lg font-semibold text-white">Node Logs</h3>
<button onclick="loadLogs()" class="glass-button px-3 py-1-5 rounded-lg text-xs font-medium">Refresh</button>
</div>
<div class="logs-box" id="logsContent">Open this tab to load logs.</div>
</div>
</div>
</div>
<script src="qrcode.js"></script>
<script>
'use strict';
const REST_PORT = 8080;
const GRPC_PORT = 10009;
const P2P_PORT = 9735;
const host = window.location.hostname;
// Same-origin by default: the app's own nginx (:18083) proxies these paths
// to the archipelago backend, so a relative base ('') avoids any
// cross-origin/CORS issues (which broke this on http-only nodes).
// ?backend=http://HOST:5678 still overrides for local dev.
function getBackendUrl() {
return new URLSearchParams(window.location.search).get('backend') || '';
}
// The full channel-management UI is the Archipelago one at
// /apps/lnd/channels, served by the main UI on the standard web port —
// not this app's :18083. Drop the port to land on it.
const CHANNELS_URL = window.location.protocol + '//' + window.location.hostname + '/apps/lnd/channels';
// ── State ───────────────────────────────────────────────────────
let unit = 'sats';
let state = { info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
let peerSort = { col: 'peer', dir: 1 };
let activityFilter = 'all';
let logsLoaded = false;
try { unit = localStorage.getItem('lndui.unit') || 'sats'; } catch (_) {}
// ── Formatting ──────────────────────────────────────────────────
function num(v) { const n = Number(v); return isFinite(n) ? n : 0; }
// Amounts are always sats internally; `unit` only changes presentation.
function fmtAmount(sats) {
const n = num(sats);
if (unit === 'btc') {
const btc = n / 1e8;
return (btc < 0.00001 && btc > 0 ? btc.toFixed(8) : btc.toFixed(5)) + ' BTC';
}
return n.toLocaleString('en-US') + ' sats';
}
function fmtAmountShort(sats) {
const n = num(sats);
if (unit === 'btc') return (n / 1e8).toFixed(4) + ' BTC';
if (n >= 1e6) return (n / 1e6).toFixed(2).replace(/\.00$/, '') + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
return n.toLocaleString('en-US');
}
function fmtCount(v) { return num(v).toLocaleString('en-US'); }
function fmtBytes(b) {
const n = num(b);
if (n >= 1e9) return (n / 1e9).toFixed(2) + ' GB';
if (n >= 1e6) return (n / 1e6).toFixed(1) + ' MB';
if (n >= 1e3) return (n / 1e3).toFixed(1) + ' KB';
return n + ' B';
}
function timeAgo(unixSeconds) {
const secs = Math.floor(Date.now() / 1000) - num(unixSeconds);
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';
if (secs < 2592000) return Math.floor(secs / 86400) + 'd ago';
if (secs < 31536000) return Math.floor(secs / 2592000) + 'mo ago';
return Math.floor(secs / 31536000) + 'y ago';
}
function fmtDuration(secs) {
const n = num(secs);
if (n <= 0) return '—';
const d = Math.floor(n / 86400);
if (d >= 1) return d + 'd';
const h = Math.floor(n / 3600);
if (h >= 1) return h + 'h';
return Math.max(1, Math.floor(n / 60)) + 'm';
}
function shortKey(k) { return !k ? '—' : k.slice(0, 8) + '…' + k.slice(-6); }
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
}
function setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; }
function setHtml(id, html) { const el = document.getElementById(id); if (el) el.innerHTML = html; }
// Classify a peer address the way Umbrel's peers table does.
function classifyNetwork(addr) {
const a = String(addr || '').toLowerCase();
if (a.includes('.onion')) return 'Tor';
if (a.includes('.b32.i2p') || a.includes('i2p')) return 'I2P';
if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?|localhost)/.test(a)) return 'Local';
return 'Clearnet';
}
// ── Unit switch ─────────────────────────────────────────────────
function setUnit(u) {
unit = u;
try { localStorage.setItem('lndui.unit', u); } catch (_) {}
document.getElementById('unitSats').classList.toggle('active', u === 'sats');
document.getElementById('unitBtc').classList.toggle('active', u === 'btc');
renderAll();
}
// ── Tabs ────────────────────────────────────────────────────────
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 === 'settings' && !logsLoaded) { logsLoaded = true; loadLogs(); }
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)));
// ── Fetch helpers ───────────────────────────────────────────────
// Every LND path goes through the backend's authenticated GET proxy,
// which attaches the macaroon and trusts LND's self-signed cert.
async function lnd(path) {
const res = await fetch(getBackendUrl() + '/proxy/lnd' + path, { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
async function lndSafe(path, fallback) {
try { return await lnd(path); } catch (_) { return fallback; }
}
async function refreshAll() {
const icon = document.getElementById('refreshIcon');
if (icon) icon.classList.add('animate-spin-slow');
try {
const [info, channels, pending, peers, fees, graph, payments, invoices, txns] = await Promise.all([
lndSafe('/v1/getinfo', null),
lndSafe('/v1/channels', { channels: [] }),
lndSafe('/v1/channels/pending', null),
lndSafe('/v1/peers', { peers: [] }),
lndSafe('/v1/fees', null),
lndSafe('/v1/graph/info', null),
lndSafe('/v1/payments?max_payments=25&reversed=true', { payments: [] }),
lndSafe('/v1/invoices?num_max_invoices=25&reversed=true', { invoices: [] }),
lndSafe('/v1/transactions', { transactions: [] }),
]);
state.info = info;
state.channels = (channels && channels.channels) || [];
state.pending = pending;
state.peers = (peers && peers.peers) || [];
state.fees = fees;
state.graph = graph;
state.payments = (payments && payments.payments) || [];
state.invoices = (invoices && invoices.invoices) || [];
state.txns = (txns && txns.transactions) || [];
// Balances are separate so one failing endpoint can't blank the rest.
state.onchain = await lndSafe('/v1/balance/blockchain', null);
state.chanbal = await lndSafe('/v1/balance/channels', null);
} finally {
if (icon) icon.classList.remove('animate-spin-slow');
}
renderAll();
}
function renderAll() {
renderHeader();
renderBalances();
renderSummary();
renderChannels();
renderActivity();
renderInsights();
renderPeers();
renderSettings();
}
// ── Header + sync ───────────────────────────────────────────────
function renderHeader() {
const g = state.info;
const pill = document.getElementById('headerStatusPill');
const dot = document.getElementById('headerStatusDot');
if (!g) {
setText('headerStatusText', 'Unreachable');
pill.className = 'pill bad';
dot.className = 'status-dot-sm bg-red';
document.getElementById('syncCard').style.display = 'none';
return;
}
const synced = !!g.synced_to_chain;
setText('headerStatusText', synced ? 'Running' : 'Synchronizing');
pill.className = 'pill ' + (synced ? 'ok' : 'warn');
dot.className = 'status-dot-sm ' + (synced ? 'bg-green' : 'bg-yellow');
const network = (g.chains && g.chains[0] && g.chains[0].network) || '—';
const netPill = document.getElementById('headerNetworkPill');
netPill.textContent = network;
netPill.style.display = '';
setText('headerAlias', g.alias || 'Lightning Network Daemon for instant Bitcoin payments');
setText('headerPubkey', g.identity_pubkey || '—');
// Sync banner — only while the node has work left to do.
const syncCard = document.getElementById('syncCard');
if (synced && g.synced_to_graph) {
syncCard.style.display = 'none';
} else {
syncCard.style.display = '';
setText('syncSubtitle', synced
? 'Chain is synced. Downloading the network graph…'
: 'Catching up with the Bitcoin backend. Lightning is unavailable until this finishes.');
setText('syncBlockLabel', 'Block ' + fmtCount(g.block_height));
// LND exposes no IBD percentage, so the bar tracks the two sync
// booleans rather than inventing a fake block-based percentage.
const done = (synced ? 1 : 0) + (g.synced_to_graph ? 1 : 0);
const pct = Math.round((done / 2) * 100);
setText('syncPercent', pct + '%');
document.getElementById('syncProgressBar').style.width = pct + '%';
setText('syncChain', synced ? 'Synced' : 'Syncing');
setText('syncGraph', g.synced_to_graph ? 'Synced' : 'Syncing');
setText('syncHeight', fmtCount(g.block_height));
setText('syncPeers', fmtCount(g.num_peers));
}
}
// ── Balances ────────────────────────────────────────────────────
function renderBalances() {
const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance));
const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance);
const cb = state.chanbal;
const lnLocal = num(cb && ((cb.local_balance && cb.local_balance.sat) ?? cb.balance));
const lnRemote = num(cb && ((cb.remote_balance && cb.remote_balance.sat) ?? 0));
const lnPending = num(cb && ((cb.pending_open_local_balance && cb.pending_open_local_balance.sat) ?? 0));
setText('balTotal', fmtAmount(onchainConfirmed + lnLocal));
setText('balTotalSub', state.onchain || cb ? 'on-chain + lightning' : 'balances unavailable');
setText('balLightning', fmtAmount(lnLocal));
setText('balLightningSub', lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
setText('balOnchain', fmtAmount(onchainConfirmed));
setText('balOnchainSub', onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
setText('liqLocal', fmtAmount(lnLocal));
setText('liqRemote', fmtAmount(lnRemote));
const total = lnLocal + lnRemote;
const localPct = total > 0 ? (lnLocal / total) * 100 : 50;
document.getElementById('liqBarLocal').style.width = localPct + '%';
document.getElementById('liqBarRemote').style.width = (100 - localPct) + '%';
setText('liqHint', total > 0
? Math.round(localPct) + '% of your channel capacity is outbound (sendable).'
: 'Open a channel to start sending and receiving over Lightning.');
}
// ── Overview summary ────────────────────────────────────────────
function pendingCount() {
const p = state.pending;
if (!p) return num(state.info && state.info.num_pending_channels);
return ((p.pending_open_channels || []).length)
+ ((p.pending_force_closing_channels || []).length)
+ ((p.waiting_close_channels || []).length);
}
function renderSummary() {
const g = state.info;
const chans = state.channels;
const active = chans.filter(c => c.active).length;
const inactive = chans.length - active;
const capacity = chans.reduce((s, c) => s + num(c.capacity), 0);
setText('statPeers', g ? fmtCount(g.num_peers) : '—');
setText('statActiveChannels', String(active));
setText('statChannelsSub', inactive > 0 ? inactive + ' inactive' : 'all channels online');
setText('statCapacity', capacity ? fmtAmountShort(capacity) : '—');
setText('statRoutingMonth', state.fees ? fmtAmountShort(state.fees.month_fee_sum) : '—');
const pending = pendingCount();
setText('healthHeight', g ? fmtCount(g.block_height) : '—');
setText('healthPending', String(pending));
const chainPill = document.getElementById('healthChain');
chainPill.textContent = g ? (g.synced_to_chain ? 'Yes' : 'No') : '—';
chainPill.className = 'pill ' + (g && g.synced_to_chain ? 'ok' : 'warn');
const graphPill = document.getElementById('healthGraph');
graphPill.textContent = g ? (g.synced_to_graph ? 'Yes' : 'No') : '—';
graphPill.className = 'pill ' + (g && g.synced_to_graph ? 'ok' : 'warn');
setText('channelsLinkSub', chans.length
? active + ' active · ' + fmtAmountShort(capacity) + ' capacity'
: 'Open, manage and close channels');
// Channels tab counters
setText('chActive', String(active));
setText('chInactive', String(inactive));
setText('chPending', String(pending));
setText('chCapacity', capacity ? fmtAmountShort(capacity) : '—');
}
// ── Channels (read-only overview; management lives in the main UI) ──
function renderChannels() {
const el = document.getElementById('channelList');
if (!el) return;
const q = (document.getElementById('channelFilter').value || '').toLowerCase();
let list = state.channels.slice();
if (q) list = list.filter(c => String(c.remote_pubkey || '').toLowerCase().includes(q) || String(c.chan_id || '').includes(q));
if (!list.length) {
el.innerHTML = '<div class="empty-state">' +
(state.channels.length ? 'No channels match that filter.' : 'No payment channels yet. Open one to start sending and receiving.') +
'</div>';
return;
}
list.sort((a, b) => num(b.capacity) - num(a.capacity));
el.innerHTML = list.map(c => {
const local = num(c.local_balance), remote = num(c.remote_balance);
const total = local + remote || 1;
const pct = (local / total) * 100;
const statusCls = c.active ? 'ok' : 'bad';
const statusTxt = c.active ? 'Online' : 'Offline';
return '' +
'<div class="p-3 mb-2" style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.07);border-radius:0.7rem">' +
'<div class="flex items-center justify-between gap-3 mb-2 flex-wrap">' +
'<div class="flex items-center gap-2 min-w-0">' +
'<span class="pill ' + statusCls + '">' + statusTxt + '</span>' +
'<span class="pill">' + (c.private ? 'Private' : 'Public') + '</span>' +
'</div>' +
'<span class="text-xs text-white-45 font-mono truncate">' + escapeHtml(shortKey(c.remote_pubkey)) + '</span>' +
'</div>' +
'<div class="flex items-center justify-between gap-3 mb-2">' +
'<div><div class="text-sm font-semibold text-purple tabular">' + escapeHtml(fmtAmountShort(local)) + '</div>' +
'<div class="text-xs text-white-45">Max Send</div></div>' +
'<div class="text-right"><div class="text-sm font-semibold text-green tabular">' + escapeHtml(fmtAmountShort(remote)) + '</div>' +
'<div class="text-xs text-white-45">Max Receive</div></div>' +
'</div>' +
'<div class="liq-bar"><div class="local" style="width:' + pct + '%"></div><div class="remote" style="width:' + (100 - pct) + '%"></div></div>' +
'<div class="flex justify-between mt-2 text-xs text-white-40">' +
'<span>Capacity ' + escapeHtml(fmtAmountShort(c.capacity)) + '</span>' +
'<span>Uptime ' + escapeHtml(fmtDuration(c.uptime)) + ' / ' + escapeHtml(fmtDuration(c.lifetime)) + '</span>' +
'</div>' +
'</div>';
}).join('');
}
// ── Activity ────────────────────────────────────────────────────
function setActivityFilter(f) {
activityFilter = f;
document.querySelectorAll('.chip[data-filter]').forEach(c => c.classList.toggle('active', c.dataset.filter === f));
renderActivity();
}
function buildActivity() {
const items = [];
for (const p of state.payments) {
items.push({
kind: 'lightning', dir: 'out',
label: p.payment_request ? 'Lightning payment' : 'Keysend payment',
sats: num(p.value_sat), fee: num(p.fee_sat),
ts: num(p.creation_date), status: p.status || 'SUCCEEDED',
});
}
for (const i of state.invoices) {
// Skip never-paid, expired invoices — they are noise, not activity.
const settled = i.settled || i.state === 'SETTLED';
if (!settled && i.state === 'CANCELED') continue;
items.push({
kind: 'lightning', dir: 'in',
label: i.memo || 'Lightning invoice',
sats: num(i.amt_paid_sat || i.value), fee: 0,
ts: num(settled ? (i.settle_date || i.creation_date) : i.creation_date),
status: settled ? 'SUCCEEDED' : (i.state || 'OPEN'),
});
}
for (const t of state.txns) {
const amt = num(t.amount);
items.push({
kind: 'onchain', dir: amt >= 0 ? 'in' : 'out',
label: t.label || 'On-chain transaction',
sats: Math.abs(amt), fee: num(t.total_fees),
ts: num(t.time_stamp),
status: num(t.num_confirmations) > 0 ? 'SUCCEEDED' : 'PENDING',
});
}
return items.sort((a, b) => b.ts - a.ts);
}
function renderActivity() {
const el = document.getElementById('activityList');
if (!el) return;
let items = buildActivity();
if (activityFilter !== 'all') items = items.filter(i => i.kind === activityFilter);
if (!items.length) {
el.innerHTML = '<div class="empty-state">No activity yet.</div>';
return;
}
el.innerHTML = items.slice(0, 60).map(i => {
const pending = i.status === 'PENDING' || i.status === 'IN_FLIGHT' || i.status === 'OPEN' || i.status === 'ACCEPTED';
const failed = i.status === 'FAILED';
const cls = failed ? 'failed' : pending ? 'pending' : i.dir;
const arrow = failed
? '<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" d="M18 6L6 18M6 6l12 12"/></svg>'
: pending
? '<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path stroke-linecap="round" d="M12 7v5l3 2"/></svg>'
: i.dir === 'in'
? '<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 19V5M5 12l7-7 7 7"/></svg>'
: '<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M19 12l-7 7-7-7"/></svg>';
const sign = failed ? '' : i.dir === 'in' ? '+' : '';
const amtCls = failed ? 'text-white-40' : i.dir === 'in' ? 'text-green' : 'text-white-90';
return '' +
'<div class="act-row">' +
'<div class="act-icon ' + cls + '">' + arrow + '</div>' +
'<div class="flex-1 min-w-0">' +
'<div class="text-sm font-medium text-white-90 truncate">' + escapeHtml(i.label) + '</div>' +
'<div class="text-xs text-white-45">' +
escapeHtml(i.kind === 'lightning' ? 'Lightning' : 'On-chain') + ' · ' + escapeHtml(timeAgo(i.ts)) +
(pending ? ' · pending' : '') + (failed ? ' · failed' : '') +
'</div>' +
'</div>' +
'<div class="text-right flex-shrink-0">' +
'<div class="text-sm font-semibold tabular ' + amtCls + '">' + sign + escapeHtml(fmtAmountShort(i.sats)) + '</div>' +
(i.fee > 0 ? '<div class="text-xs text-white-40">fee ' + escapeHtml(fmtAmountShort(i.fee)) + '</div>' : '') +
'</div>' +
'</div>';
}).join('');
}
// ── Insights ────────────────────────────────────────────────────
function renderInsights() {
const f = state.fees;
setText('feeDay', f ? fmtAmount(f.day_fee_sum) : '—');
setText('feeWeek', f ? fmtAmount(f.week_fee_sum) : '—');
setText('feeMonth', f ? fmtAmount(f.month_fee_sum) : '—');
const chans = state.channels;
const sent = chans.reduce((s, c) => s + num(c.total_satoshis_sent), 0);
const recv = chans.reduce((s, c) => s + num(c.total_satoshis_received), 0);
const updates = chans.reduce((s, c) => s + num(c.num_updates), 0);
const lifetimes = chans.map(c => num(c.lifetime)).filter(v => v > 0);
const avgLife = lifetimes.length ? lifetimes.reduce((a, b) => a + b, 0) / lifetimes.length : 0;
setText('insSent', chans.length ? fmtAmountShort(sent) : '—');
setText('insReceived', chans.length ? fmtAmountShort(recv) : '—');
setText('insUpdates', chans.length ? fmtCount(updates) : '—');
setText('insAge', avgLife ? fmtDuration(avgLife) : '—');
const gr = state.graph;
setText('graphNodes', gr ? fmtCount(gr.num_nodes) : '—');
setText('graphChannels', gr ? fmtCount(gr.num_channels) : '—');
setText('graphCapacity', gr ? fmtAmountShort(gr.total_network_capacity) : '—');
setText('graphAvgSize', gr ? fmtAmountShort(gr.avg_channel_size) : '—');
}
// ── Peers table ─────────────────────────────────────────────────
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 = state.peers.map(p => ({
peer: p.pub_key || '',
address: p.address || '',
network: classifyNetwork(p.address),
direction: p.inbound ? 'Inbound' : 'Outbound',
ping: num(p.ping_time),
sent: num(p.bytes_sent),
recv: num(p.bytes_recv),
}));
setText('peersCount', state.peers.length ? '(' + state.peers.length + ')' : '');
if (q) rows = rows.filter(r =>
r.peer.toLowerCase().includes(q) || r.address.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];
const cmp = typeof x === 'number' ? x - y : 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">' +
(state.peers.length ? 'No peers match that filter.' : 'No connected peers.') + '</div></td></tr>';
return;
}
body.innerHTML = rows.map(r => '' +
'<tr>' +
'<td><div class="font-mono text-xs text-white-90">' + escapeHtml(shortKey(r.peer)) + '</div>' +
'<div class="text-xs text-white-40 truncate" style="max-width:14rem">' + escapeHtml(r.address) + '</div></td>' +
'<td><span class="pill">' + escapeHtml(r.network) + '</span></td>' +
'<td class="text-white-70">' + escapeHtml(r.direction) + '</td>' +
'<td class="tabular text-white-70">' + (r.ping ? Math.round(r.ping / 1000) + ' ms' : '—') + '</td>' +
'<td class="tabular text-white-70">' + escapeHtml(fmtBytes(r.sent)) + '</td>' +
'<td class="tabular text-white-70">' + escapeHtml(fmtBytes(r.recv)) + '</td>' +
'</tr>').join('');
}
// ── Settings ────────────────────────────────────────────────────
function renderSettings() {
const g = state.info;
const reachable = !!g;
setText('setStatus', reachable ? (g.synced_to_chain ? 'Running' : 'Synchronizing') : 'Unreachable');
document.getElementById('setStatus').className = 'text-sm font-medium ' + (reachable ? (g.synced_to_chain ? 'text-green' : 'text-yellow') : 'text-red');
setText('setAlias', (g && g.alias) || '—');
setText('setVersion', (g && g.version) || '—');
setText('setNetwork', (g && g.chains && g.chains[0] && g.chains[0].network) || '—');
setText('setChain', (g && g.chains && g.chains[0] && g.chains[0].chain) || 'bitcoin');
setText('setCommit', (g && g.commit_hash) || '—');
setText('setRest', host + ':' + REST_PORT);
setText('setGrpc', host + ':' + GRPC_PORT);
setText('setP2p', host + ':' + P2P_PORT);
document.getElementById('restDot').className = 'status-dot-sm ' + (reachable ? 'bg-green' : 'bg-red');
document.getElementById('grpcDot').className = 'status-dot-sm ' + (reachable ? 'bg-green' : 'bg-red');
setText('setTor', (lndConnInfo && lndConnInfo.tor_onion) || 'Not configured');
// Reflect what getinfo actually reports rather than guessing at config.
setText('cfgBackend', reachable ? 'Bitcoin Core / Knots (archipelago-managed)' : '—');
const feats = (g && g.features) || {};
const has = code => Object.prototype.hasOwnProperty.call(feats, String(code));
setText('cfgWatchtower', reachable ? (has(2023) ? 'Enabled' : 'Not advertised') : '—');
setText('cfgKeysend', reachable ? (has(55) ? 'Enabled' : 'Not advertised') : '—');
setText('cfgWumbo', reachable ? (has(19) || has(18) ? 'Enabled' : 'Not advertised') : '—');
// Node ID panel lives in Connect, but is fed by getinfo.
setText('nodePubkey', (g && g.identity_pubkey) || '—');
const uris = (g && g.uris) || [];
const uriWrap = document.getElementById('nodeUriWrap');
if (uris.length) {
uriWrap.style.display = '';
setText('nodeUri', uris[0]);
setText('nodeUriHint', uris.length > 1 ? uris.length + ' advertised addresses' : '');
renderQR('nodeIdQrBox', uris[0]);
} else if (g && g.identity_pubkey) {
uriWrap.style.display = 'none';
setText('nodeUriHint', '');
renderQR('nodeIdQrBox', g.identity_pubkey);
}
}
function copyEndpointInfo(btn) {
const lines = [
'REST: http://' + host + ':' + REST_PORT,
'gRPC: ' + host + ':' + GRPC_PORT,
'P2P: ' + host + ':' + P2P_PORT,
];
if (lndConnInfo && lndConnInfo.tor_onion) lines.push('Tor: ' + lndConnInfo.tor_onion);
navigator.clipboard.writeText(lines.join('\n')).then(() => flash(btn, 'Copied!'));
}
function flash(btn, msg) {
const orig = btn.textContent;
btn.textContent = msg;
btn.style.color = '#4ade80';
setTimeout(() => { btn.textContent = orig; btn.style.color = ''; }, 1500);
}
// ── Logs ────────────────────────────────────────────────────────
async function loadLogs() {
const el = document.getElementById('logsContent');
el.textContent = 'Loading logs…';
try {
const res = await fetch(getBackendUrl() + '/api/container/logs?app_id=lnd&lines=200', { credentials: 'include' });
if (!res.ok) throw new Error(res.statusText || ('HTTP ' + res.status));
const json = await res.json();
const lines = json.result || json.logs || (Array.isArray(json) ? json : []);
el.textContent = Array.isArray(lines) ? lines.join('\n') : String(lines);
el.scrollTop = el.scrollHeight;
} catch (e) {
el.textContent = 'Could not load logs: ' + e.message;
}
}
// ── Connect Your Wallet ─────────────────────────────────────────
let lndConnInfo = null;
function renderQR(containerId, text) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
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 for this mode</div>';
}
}
function buildLndconnectUri(connHost, connPort, cert, macaroon, isTor) {
let uri = 'lndconnect://' + connHost + ':' + connPort + '?';
if (!isTor && cert) uri += 'cert=' + cert + '&';
uri += 'macaroon=' + macaroon;
return uri;
}
function connSelection() {
const mode = document.getElementById('connMode').value;
const isTor = mode.includes('tor');
const isRest = mode.includes('rest');
return {
isTor, isRest,
port: isRest ? lndConnInfo.rest_port : lndConnInfo.grpc_port,
connHost: isTor && lndConnInfo.tor_onion ? lndConnInfo.tor_onion : host,
};
}
function updateConnInfo() {
if (!lndConnInfo) return;
const { isTor, isRest, port, connHost } = connSelection();
setText('connHost', connHost);
setText('connPort', String(port));
setText('connProto', isRest ? 'REST' : 'gRPC');
if (isTor && !lndConnInfo.tor_onion) {
document.getElementById('lndQrBox').innerHTML = '<div style="color:#999;font-size:12px;text-align:center;padding:2rem">Tor not configured for LND</div>';
setText('connSubtitle', 'Tor hidden service not available. Use Local Network mode.');
return;
}
setText('connSubtitle', 'Scan QR with Zeus, Zap, or BlueWallet to connect.');
renderQR('lndQrBox', buildLndconnectUri(connHost, port, lndConnInfo.cert_base64url, lndConnInfo.macaroon_base64url, isTor));
}
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);
});
}
function copyLndconnectUri() {
if (!lndConnInfo) return;
const { isTor, port, connHost } = connSelection();
const uri = buildLndconnectUri(connHost, port, lndConnInfo.cert_base64url, lndConnInfo.macaroon_base64url, isTor);
const btn = document.getElementById('copyUriBtn');
navigator.clipboard.writeText(uri).then(() => flash(btn, 'Copied!'));
}
async function fetchConnectInfo() {
try {
const resp = await fetch(getBackendUrl() + '/lnd-connect-info', { credentials: 'include' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
if (data.cert_base64url) {
lndConnInfo = data;
// Auto-select local mode when Tor is not available
if (!data.tor_onion) {
document.getElementById('connMode').value = 'rest-local';
setText('connSubtitle', 'Tor hidden service not available. Use Local Network mode.');
}
setText('setTor', data.tor_onion || 'Not configured');
try { updateConnInfo(); } catch (ue) {
document.getElementById('lndQrBox').innerHTML = '<div style="color:#f87171;font-size:11px;text-align:center;padding:1rem">Update error: ' + escapeHtml(ue.message) + '</div>';
}
} else if (data.error) {
throw new Error(data.error);
}
} catch (e) {
document.getElementById('lndQrBox').innerHTML = '<div style="color:#f87171;font-size:12px;text-align:center;padding:2rem">' + escapeHtml(e.message) + '</div>';
}
}
// ── Boot ────────────────────────────────────────────────────────
document.getElementById('channelsLinkCard').href = CHANNELS_URL;
document.getElementById('channelsOpenBtn').href = CHANNELS_URL;
setUnit(unit);
const initialTab = (location.hash || '').replace('#', '');
if (document.getElementById('panel-' + initialTab)) setTab(initialTab);
refreshAll();
fetchConnectInfo();
// Keep the numbers live without hammering LND.
setInterval(refreshAll, 30000);
</script>
</body>
</html>