All four found by verifying on archi-dev-box rather than assuming.
container-specs.sh: archy-lnd-ui was specified as a BRIDGE container with
SPEC_PORTS="18083:80", but docker/lnd-ui/nginx.conf listens on 18083
directly (it must, to proxy the backend on 127.0.0.1:5678 same-origin).
Recreating from that spec publishes host 18083 to container port 80, where
nothing listens. Reproduced on the node: the app came back with :18083
refusing connections, HTTP 000. This never fired before because the running
containers are created by first-boot-containers.sh, which is host-networked
and never reads this file; the spec is only consulted when self-update.sh
rebuilds a UI image, and that only happens when a file under docker/lnd-ui/
changes — which is exactly what the previous two commits did. So the next
OTA would have taken lnd-ui down on every node. Now SPEC_NETWORK="host"
with no port mapping, matching what actually runs. NET_BIND_SERVICE dropped
with it: 18083 is unprivileged.
lnd-ui channels link: pointed at /apps/lnd/channels, but that route is a
CHILD of the /dashboard record in neode-ui's router, so the real path is
/dashboard/apps/lnd/channels. nginx's SPA fallback returns 200 for the
wrong path, so it failed as vue-router's NotFound view rather than an HTTP
404 — both the Payment Channels card and the Manage Channels button.
Both apps, copy buttons: navigator.clipboard only exists in a secure
context, and nodes serve these apps over plain http; the main UI also
embeds them in an iframe, where the async Clipboard API is separately gated
by the clipboard-write permission policy. Every copy button silently did
nothing there. Added an execCommand('copy') fallback behind a copyText()
helper and routed all six call sites through it.
lnd-ui Node ID: showed the bare pubkey whenever getinfo.uris was empty,
which is the common case — LND only populates uris once it is advertising
an external address. The bare pubkey is not what a peer pastes to open a
channel. The full pubkey@host:9735 URI is now built from the Tor onion
where available, falling back to this node's address, with a hint saying
which and what its reachability is. The QR encodes the URI too.
Verified on archi-dev-box: both images rebuilt and containers recreated
from the specs; lnd-ui and bitcoin-ui both serve 200 with the new assets;
and the RPCs the new tabs depend on all answer on the live node —
getblockstats returns every field the charts read, getpeerinfo returns 11
peers carrying relaytxes and network values the classifier handles.
Note for whoever tests bitcoin-ui's Insights/Peers tabs: /bitcoin-rpc/ now
sits behind auth_request /_session_check (a05956c4 et al), so it answers
401 to an unauthenticated curl by design. A logged-in browser sends the
session cookie same-origin, which is how those tabs get their data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1693 lines
107 KiB
HTML
1693 lines
107 KiB
HTML
<!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; }
|
||
/* Same treatment, sized for a normal inline button rather than a
|
||
full-width card, so every button on the page reads as one family. */
|
||
.info-card-button.compact {
|
||
width: auto; display: inline-flex; align-items: center; justify-content: center;
|
||
gap: 0.5rem; padding: 0.65rem 1rem; text-align: center;
|
||
font-size: 0.875rem; font-weight: 500;
|
||
}
|
||
.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. The app-store icon is a full-bleed
|
||
square with an opaque white background (not a transparent glyph), so
|
||
it fills the frame and gets clipped to the inner radius, exactly the
|
||
way bitcoin-ui frames its icon. */
|
||
.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: #fff; z-index: 0; }
|
||
.logo-gradient-border img { border-radius: 13px; display: block; position: relative; z-index: 1; width: 64px; height: 64px; object-fit: cover; }
|
||
|
||
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; padding-bottom: 4rem; }
|
||
|
||
/* ── App header ──────────────────────────────────────────────────
|
||
Three deliberate breakpoints, because the identity block and the
|
||
controls both need room before they sit side by side:
|
||
< 768px fully stacked, everything centred
|
||
< 1024px logo + text on one row, controls stacked underneath
|
||
(this is the width where the title used to squish
|
||
against the controls)
|
||
>= 1024px single row */
|
||
.app-header { display: flex; flex-direction: column; align-items: center; gap: 1rem; }
|
||
.app-header-id { display: flex; flex-direction: column; align-items: center; gap: 1rem; width: 100%; min-width: 0; }
|
||
.app-header-text { min-width: 0; width: 100%; text-align: center; }
|
||
.app-header-title { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; justify-content: center; margin-bottom: 0.25rem; }
|
||
.app-header-actions { display: flex; align-items: center; gap: 0.75rem; justify-content: center; width: 100%; flex-wrap: wrap; }
|
||
|
||
@media (min-width: 768px) {
|
||
.app-header-id { flex-direction: row; align-items: center; }
|
||
.app-header-text { text-align: left; }
|
||
.app-header-title { justify-content: flex-start; }
|
||
}
|
||
@media (min-width: 1024px) {
|
||
.app-header { flex-direction: row; align-items: center; }
|
||
.app-header-actions { width: auto; justify-content: flex-end; flex-shrink: 0; }
|
||
}
|
||
|
||
/* 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; }
|
||
/* The QR plate stays square. It sits inside a glass panel that takes
|
||
the height of the fields column, so a tall fields list no longer
|
||
stretches the white plate into a letterboxed rectangle. */
|
||
.qr-panel {
|
||
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
|
||
padding: 1rem; background: rgba(0, 0, 0, 0.45);
|
||
border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 0.9rem;
|
||
}
|
||
.qr-box { width: 100%; max-width: 200px; aspect-ratio: 1; background: #fff; border-radius: 0.6rem; padding: 0.55rem; 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 { width: 200px; }
|
||
}
|
||
@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="app-header">
|
||
<div class="app-header-id">
|
||
<div class="flex-shrink-0">
|
||
<div class="logo-gradient-border">
|
||
<img src="assets/img/app-icons/lnd.png" alt="LND" onerror="this.style.display='none'" />
|
||
</div>
|
||
</div>
|
||
<div class="app-header-text flex-1">
|
||
<div class="app-header-title">
|
||
<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">—</p>
|
||
</div>
|
||
</div>
|
||
<div class="app-header-actions">
|
||
<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="info-card-button compact" 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 —</span>
|
||
<span id="syncPercent">—</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">—</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">—</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">—</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">—</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">—</div>
|
||
<div class="sub" id="balTotalSub">on-chain + lightning</div>
|
||
</div>
|
||
<div class="balance-tile">
|
||
<div class="label"><span class="text-purple">⚡</span> Lightning</div>
|
||
<div class="value text-purple" id="balLightning">—</div>
|
||
<div class="sub" id="balLightningSub">—</div>
|
||
</div>
|
||
<div class="balance-tile">
|
||
<div class="label"><span class="text-btc">₿</span> On-chain</div>
|
||
<div class="value text-btc" id="balOnchain">—</div>
|
||
<div class="sub" id="balOnchainSub">—</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">—</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">—</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">—</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">—</p>
|
||
<p class="text-xs text-white-40 mt-1" id="statChannelsSub">—</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">—</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">—</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">—</span>
|
||
</div>
|
||
<div class="sub-row">
|
||
<span class="text-white-80 text-sm">Synced to graph</span>
|
||
<span class="pill" id="healthGraph">—</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">—</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">—</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 — opening, balancing and closing — 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">—</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">—</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">—</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">—</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">—</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">—</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">—</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">—</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">—</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">—</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">—</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">—</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">—</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">—</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">—</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">▼</span></th>
|
||
<th onclick="sortPeers('network')" data-col="network">Network<span class="caret">▼</span></th>
|
||
<th onclick="sortPeers('direction')" data-col="direction">In/Out<span class="caret">▼</span></th>
|
||
<th onclick="sortPeers('ping')" data-col="ping">Ping<span class="caret">▼</span></th>
|
||
<th onclick="sortPeers('sent')" data-col="sent">Sent<span class="caret">▼</span></th>
|
||
<th onclick="sortPeers('recv')" data-col="recv">Received<span class="caret">▼</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-panel">
|
||
<div class="qr-box" id="lndQrBox">
|
||
<div style="color:#999;font-size:12px;text-align:center;padding:2rem">Loading…</div>
|
||
</div>
|
||
</div>
|
||
<div class="conn-fields">
|
||
<div>
|
||
<div class="field-label">Host</div>
|
||
<div class="field-row">
|
||
<span class="field-value" id="connHost">—</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">—</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">—</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 — 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-panel">
|
||
<div class="qr-box" id="nodeIdQrBox">
|
||
<div style="color:#999;font-size:12px;text-align:center;padding:2rem">—</div>
|
||
</div>
|
||
</div>
|
||
<div class="conn-fields">
|
||
<div>
|
||
<div class="field-label">Public Key</div>
|
||
<div class="field-row">
|
||
<span class="field-value" id="nodePubkey">—</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">—</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">—</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">—</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">—</span></div>
|
||
<div class="sub-row"><span class="text-white-80 text-sm">Network</span><span class="text-white-60 text-sm" id="setNetwork">—</span></div>
|
||
<div class="sub-row"><span class="text-white-80 text-sm">Chain</span><span class="text-white-60 text-sm" id="setChain">—</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">—</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">—</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">—</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">—</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">—</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">—</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">—</span></div>
|
||
<div class="sub-row"><span class="text-white-80 text-sm">Keysend</span><span class="text-white-60 text-sm" id="cfgKeysend">—</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">—</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="info-card-button compact" style="font-size:0.75rem;padding:0.45rem 0.8rem">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, served by the
|
||
// main UI on the standard web port — not this app's :18083, so the port
|
||
// is dropped. The /dashboard prefix matters: the route is a CHILD of
|
||
// the /dashboard record in neode-ui/src/router/index.ts, so bare
|
||
// /apps/lnd/channels is not a route at all. nginx's SPA fallback still
|
||
// returns 200 for it, so it fails as vue-router's NotFound view rather
|
||
// than an HTTP 404 — which is exactly how it presented on archi-dev-box.
|
||
const CHANNELS_URL = window.location.protocol + '//' + window.location.hostname + '/dashboard/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 =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||
}
|
||
// Clipboard with a fallback. navigator.clipboard only exists in a secure
|
||
// context, and most nodes serve this app over plain http; on top of
|
||
// that, the main UI embeds these apps in an iframe, where the async
|
||
// Clipboard API is additionally gated by the clipboard-write permission
|
||
// policy. execCommand('copy') still works in both situations, so it is
|
||
// the fallback rather than letting the button silently do nothing.
|
||
function legacyCopy(text) {
|
||
return new Promise((resolve, reject) => {
|
||
try {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.setAttribute('readonly', '');
|
||
ta.style.position = 'fixed';
|
||
ta.style.top = '0';
|
||
ta.style.left = '0';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
ta.setSelectionRange(0, ta.value.length);
|
||
const ok = document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
if (ok) resolve(); else reject(new Error('copy rejected'));
|
||
} catch (e) { reject(e); }
|
||
});
|
||
}
|
||
function copyText(text) {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
return navigator.clipboard.writeText(text).catch(() => legacyCopy(text));
|
||
}
|
||
return legacyCopy(text);
|
||
}
|
||
|
||
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.
|
||
// The useful thing to share is the full pubkey@host:port URI — that
|
||
// is what a peer pastes to open a channel — not the bare pubkey.
|
||
// LND only populates getinfo.uris once it is advertising an
|
||
// external address, so when that list is empty we build the URI
|
||
// ourselves from the Tor onion (preferred: reachable from anywhere)
|
||
// or this node's host, rather than degrading to just the pubkey.
|
||
const pubkey = (g && g.identity_pubkey) || '';
|
||
setText('nodePubkey', pubkey || '—');
|
||
const uris = (g && g.uris) || [];
|
||
const uriWrap = document.getElementById('nodeUriWrap');
|
||
let uri = '';
|
||
let hint = '';
|
||
if (uris.length) {
|
||
uri = uris[0];
|
||
hint = uris.length > 1 ? uris.length + ' advertised addresses' : 'Advertised by your node';
|
||
} else if (pubkey) {
|
||
const onion = lndConnInfo && lndConnInfo.tor_onion;
|
||
uri = pubkey + '@' + (onion || host) + ':' + P2P_PORT;
|
||
hint = onion
|
||
? 'Built from your Tor address — your node is not advertising one yet.'
|
||
: 'Built from this node’s local address. It is only reachable from your network until Tor or a public address is configured.';
|
||
}
|
||
if (uri) {
|
||
uriWrap.style.display = '';
|
||
setText('nodeUri', uri);
|
||
setText('nodeUriHint', hint);
|
||
renderQR('nodeIdQrBox', uri);
|
||
}
|
||
}
|
||
|
||
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);
|
||
copyText(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;
|
||
copyText(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');
|
||
copyText(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>
|