2026-03-19 17:02:17 +00:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
2026-08-02 18:19:34 -04:00
< meta name = "viewport" content = "width=device-width, initial-scale=1.0, viewport-fit=cover" >
2026-03-19 17:02:17 +00:00
< meta http-equiv = "Cache-Control" content = "no-cache, no-store, must-revalidate" >
2026-08-02 18:19:34 -04:00
< meta http-equiv = "Pragma" content = "no-cache" >
< meta http-equiv = "Expires" content = "0" >
2026-03-19 17:02:17 +00:00
< title > LND - Archipelago</ title >
< style >
2026-08-02 18:19:34 -04:00
/* ── 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. */
2026-03-19 17:02:17 +00:00
* { margin : 0 ; padding : 0 ; box-sizing : border-box ; }
2026-08-02 18:19:34 -04:00
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 : 100 vh ; 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 : 1000 px ; 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.45 s 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 ( 24 px ); -webkit- backdrop-filter : blur ( 24 px );
box-shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , 0.45 ), inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.22 );
border-radius : 1 rem ; border : none ; overflow-x : hidden ; overflow-y : visible ;
}
. glass-card :: before {
content : '' ; position : absolute ; inset : 0 ; border-radius : inherit ; padding : 2 px ;
background : linear-gradient ( 135 deg , 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 ( 24 px ); -webkit- backdrop-filter : blur ( 24 px );
box-shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , 0.45 ), inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.22 );
border-radius : 16 px ; padding : 12 px ; border : none ;
}
. info-card :: before {
content : '' ; position : absolute ; inset : 0 ; border-radius : inherit ; padding : 2 px ;
background : linear-gradient ( 135 deg , 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 ( 24 px ); -webkit- backdrop-filter : blur ( 24 px );
box-shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , 0.45 ), inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.22 );
border-radius : 16 px ; padding : 12 px ; border : none ; cursor : pointer ;
color : rgba ( 255 , 255 , 255 , 0.9 ); transition : all 0.3 s ease ; text-align : left ; width : 100 % ;
display : block ; text-decoration : none ;
}
. info-card-button :: before {
content : '' ; position : absolute ; inset : 0 ; border-radius : inherit ; padding : 2 px ;
background : linear-gradient ( 135 deg , 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.3 s ease ;
}
. info-card-button > * { position : relative ; z-index : 2 ; }
2026-08-02 19:00:31 -04:00
/* 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.5 rem ; padding : 0.65 rem 1 rem ; text-align : center ;
font-size : 0.875 rem ; font-weight : 500 ;
}
2026-08-02 18:19:34 -04:00
. info-card-button : hover {
transform : translateY ( -2 px ); background : rgba ( 0 , 0 , 0 , 0.35 );
box-shadow : 0 12 px 32 px rgba ( 0 , 0 , 0 , 0.6 ), inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.25 );
color : rgba ( 255 , 255 , 255 , 1 );
}
. info-card-button : hover :: before { background : linear-gradient ( 135 deg , rgba ( 255 , 255 , 255 , 0.3 ), transparent ); }
2026-03-19 17:02:17 +00:00
. info-card-button : active { transform : translateY ( 1 px ); }
2026-08-02 18:19:34 -04:00
. glass-button {
background-color : rgba ( 0 , 0 , 0 , 0.6 ); backdrop-filter : blur ( 18 px ); -webkit- backdrop-filter : blur ( 18 px );
border : 1 px solid rgba ( 255 , 255 , 255 , 0.18 ); color : rgba ( 255 , 255 , 255 , 0.9 );
transition : all 0.3 s ease ; cursor : pointer ;
}
. glass-button : hover { color : white ; background-color : rgba ( 0 , 0 , 0 , 0.7 ); }
2026-03-19 17:02:17 +00:00
. glass-button : disabled { opacity : 0.5 ; cursor : not-allowed ; }
2026-08-02 18:19:34 -04:00
. gradient-button {
background : linear-gradient ( 135 deg , rgba ( 255 , 255 , 255 , 0.15 ) 0 % , rgba ( 0 , 0 , 0 , 0.8 ) 100 % );
backdrop-filter : blur ( 10 px ); -webkit- backdrop-filter : blur ( 10 px );
border : 1 px solid rgba ( 255 , 255 , 255 , 0.2 ); color : rgba ( 255 , 255 , 255 , 0.95 );
transition : all 0.3 s ease ; cursor : pointer ;
}
. gradient-button : hover {
background : linear-gradient ( 135 deg , 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 8 px 24 px rgba ( 0 , 0 , 0 , 0.6 ); transform : translateY ( -1 px );
}
. gradient-button : active { transform : translateY ( 1 px ); }
2026-08-02 19:00:31 -04:00
/* 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. */
2026-08-02 18:19:34 -04:00
. logo-gradient-border {
position : relative ; border-radius : 16 px ; padding : 3 px ;
background : linear-gradient ( 135 deg , rgba ( 167 , 139 , 250 , 0.85 ) 0 % , rgba ( 0 , 0 , 0 , 0.8 ) 100 % );
box-shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , 0.5 ); display : inline-block ;
}
2026-08-02 19:00:31 -04:00
. logo-gradient-border :: after { content : '' ; position : absolute ; inset : 3 px ; border-radius : 13 px ; background : #fff ; z-index : 0 ; }
. logo-gradient-border img { border-radius : 13 px ; display : block ; position : relative ; z-index : 1 ; width : 64 px ; height : 64 px ; object-fit : cover ; }
2026-08-02 18:19:34 -04:00
. container { max-width : 1400 px ; margin : 0 auto ; padding : 2 rem ; padding-bottom : 4 rem ; }
2026-08-02 19:00:31 -04:00
/* ── 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 : 1 rem ; }
. app-header-id { display : flex ; flex-direction : column ; align-items : center ; gap : 1 rem ; 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.75 rem ; flex-wrap : wrap ; justify-content : center ; margin-bottom : 0.25 rem ; }
. app-header-actions { display : flex ; align-items : center ; gap : 0.75 rem ; 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 ; }
}
2026-08-02 18:19:34 -04:00
/* 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 ; }
2026-03-19 17:02:17 +00:00
. grid { display : grid ; }
2026-08-02 18:19:34 -04:00
. grid-cols-1 { grid-template-columns : repeat ( 1 , minmax ( 0 , 1 fr )); }
. grid-cols-2 { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); }
. grid-cols-3 { grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )); }
. gap-2 { gap : 0.5 rem ; } . gap-3 { gap : 0.75 rem ; } . gap-4 { gap : 1 rem ; } . gap-6 { gap : 1.5 rem ; }
. mb-1 { margin-bottom : 0.25 rem ; } . mb-2 { margin-bottom : 0.5 rem ; } . mb-3 { margin-bottom : 0.75 rem ; }
. mb-4 { margin-bottom : 1 rem ; } . mb-6 { margin-bottom : 1.5 rem ; } . mb-8 { margin-bottom : 2 rem ; }
. mt-1 { margin-top : 0.25 rem ; } . mt-2 { margin-top : 0.5 rem ; } . mt-3 { margin-top : 0.75 rem ; } . mt-4 { margin-top : 1 rem ; }
. p-2 { padding : 0.5 rem ; } . p-3 { padding : 0.75 rem ; } . p-4 { padding : 1 rem ; } . p-5 { padding : 1.25 rem ; } . p-6 { padding : 1.5 rem ; }
. px-3 { padding-left : 0.75 rem ; padding-right : 0.75 rem ; } . px-4 { padding-left : 1 rem ; padding-right : 1 rem ; } . px-6 { padding-left : 1.5 rem ; padding-right : 1.5 rem ; }
. py-1-5 { padding-top : 0.375 rem ; padding-bottom : 0.375 rem ; } . py-2 { padding-top : 0.5 rem ; padding-bottom : 0.5 rem ; }
. py-2-5 { padding-top : 0.625 rem ; padding-bottom : 0.625 rem ; } . py-3 { padding-top : 0.75 rem ; padding-bottom : 0.75 rem ; }
. rounded-lg { border-radius : 0.5 rem ; } . rounded-xl { border-radius : 0.75 rem ; } . rounded-full { border-radius : 9999 px ; }
2026-03-19 17:02:17 +00:00
. overflow-hidden { overflow : hidden ; }
2026-08-02 18:19:34 -04:00
. text-center { text-align : center ; } . text-right { text-align : right ; }
. text-xs { font-size : 0.75 rem ; } . text-sm { font-size : 0.875 rem ; } . text-base { font-size : 1 rem ; }
. text-lg { font-size : 1.125 rem ; } . text-xl { font-size : 1.25 rem ; } . text-2xl { font-size : 1.5 rem ; }
. text-3xl { font-size : 1.875 rem ; } . text-4xl { font-size : 2.25 rem ; }
. 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.05 em ; }
2026-03-19 17:02:17 +00:00
. truncate { white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; }
2026-08-02 18:19:34 -04:00
. 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 ); }
2026-03-19 17:02:17 +00:00
. text-white-40 { color : rgba ( 255 , 255 , 255 , 0.4 ); }
2026-08-02 18:19:34 -04:00
. 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 : 3 rem ; height : 3 rem ; border-radius : 0.75 rem ; 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 ); }
2026-03-19 17:02:17 +00:00
. status-dot { width : 0.75 rem ; height : 0.75 rem ; border-radius : 9999 px ; }
2026-08-02 18:19:34 -04:00
. status-dot-sm { width : 0.5 rem ; height : 0.5 rem ; border-radius : 9999 px ; display : inline-block ; flex-shrink : 0 ; }
. sub-row { display : flex ; align-items : center ; justify-content : space-between ; gap : 0.75 rem ; padding : 0.75 rem ; background : rgba ( 255 , 255 , 255 , 0.05 ); border-radius : 0.5 rem ; }
2026-03-19 17:02:17 +00:00
2026-08-02 18:19:34 -04:00
/* Animations */
2026-03-19 17:02:17 +00:00
@ keyframes ping { 75 %, 100 % { transform : scale ( 2 ); opacity : 0 ; } }
2026-08-02 18:19:34 -04:00
. animate-ping { animation : ping 1 s cubic-bezier ( 0 , 0 , 0.2 , 1 ) infinite ; position : absolute ; inset : 0 ; }
@ keyframes spin { from { transform : rotate ( 0 deg ); } to { transform : rotate ( 360 deg ); } }
. animate-spin-slow { animation : spin 3 s linear infinite ; }
@ keyframes shimmer { 0 % { background-position : -1000 px 0 ; } 100 % { background-position : 1000 px 0 ; } }
. shimmer { background : linear-gradient ( 90 deg , rgba ( 255 , 255 , 255 , 0 ) 0 % , rgba ( 255 , 255 , 255 , 0.1 ) 50 % , rgba ( 255 , 255 , 255 , 0 ) 100 % ); background-size : 1000 px 100 % ; animation : shimmer 3 s infinite ; }
@ keyframes progressGlow {
0 %, 100 % { box-shadow : 0 0 10 px rgba ( 167 , 139 , 250 , 0.5 ), 0 0 20 px rgba ( 167 , 139 , 250 , 0.3 ); }
50 % { box-shadow : 0 0 20 px rgba ( 167 , 139 , 250 , 0.8 ), 0 0 30 px rgba ( 167 , 139 , 250 , 0.5 ); }
}
. progress-glow { animation : progressGlow 2 s ease-in-out infinite ; }
@ keyframes fadeUp { from { opacity : 0 ; transform : translateY ( 6 px ); } to { opacity : 1 ; transform : translateY ( 0 ); } }
. tab-panel . active { animation : fadeUp 0.28 s ease both ; }
2026-03-19 17:02:17 +00:00
2026-08-02 18:19:34 -04:00
/* ── Primary tab bar (Umbrel's dock, Archipelago-styled) ────────── */
. tabbar-wrap { margin-bottom : 1.5 rem ; }
. tabbar {
display : flex ; gap : 0.25 rem ; padding : 0.3 rem ; border-radius : 0.85 rem ;
background : rgba ( 0 , 0 , 0 , 0.55 ); backdrop-filter : blur ( 24 px ); -webkit- backdrop-filter : blur ( 24 px );
border : 1 px solid rgba ( 255 , 255 , 255 , 0.12 );
box-shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , 0.45 ), inset 0 1 px 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.5 rem ;
padding : 0.65 rem 1 rem ; border : none ; background : none ; border-radius : 0.6 rem ;
font-size : 0.875 rem ; font-weight : 500 ; color : rgba ( 255 , 255 , 255 , 0.6 );
cursor : pointer ; transition : all 0.25 s ease ; white-space : nowrap ;
}
. tab-btn svg { width : 1.05 rem ; height : 1.05 rem ; flex-shrink : 0 ; }
. tab-btn : hover { color : rgba ( 255 , 255 , 255 , 0.9 ); }
. tab-btn . active {
color : #fff ;
background : linear-gradient ( 135 deg , rgba ( 167 , 139 , 250 , 0.28 ) 0 % , rgba ( 255 , 255 , 255 , 0.08 ) 100 % );
box-shadow : inset 0 1 px 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 : 1 fr ; gap : 0.75 rem ; }
. balance-tile { padding : 1.15 rem ; background : rgba ( 255 , 255 , 255 , 0.045 ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.08 ); border-radius : 0.85 rem ; }
. balance-tile . label { font-size : 0.6875 rem ; font-weight : 600 ; text-transform : uppercase ; letter-spacing : 0.06 em ; color : rgba ( 255 , 255 , 255 , 0.45 ); margin-bottom : 0.4 rem ; display : flex ; align-items : center ; gap : 0.4 rem ; }
. balance-tile . value { font-size : 1.6 rem ; font-weight : 700 ; line-height : 1.15 ; font-variant-numeric : tabular-nums ; }
. balance-tile . sub { font-size : 0.75 rem ; color : rgba ( 255 , 255 , 255 , 0.45 ); margin-top : 0.25 rem ; }
. balance-tile . total { background : linear-gradient ( 135 deg , 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.2 rem ; background : rgba ( 255 , 255 , 255 , 0.07 ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ); border-radius : 0.6 rem ; }
. unit-switch button { padding : 0.3 rem 0.7 rem ; border : none ; background : none ; border-radius : 0.45 rem ; font-size : 0.75 rem ; font-weight : 600 ; color : rgba ( 255 , 255 , 255 , 0.5 ); cursor : pointer ; transition : all 0.2 s 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.6 rem ; width : 100 % ; border-radius : 9999 px ; background : rgba ( 255 , 255 , 255 , 0.1 ); overflow : hidden ; display : flex ; }
. liq-bar . local { background : linear-gradient ( 90 deg , #a78bfa , #8b5cf6 ); height : 100 % ; transition : width 0.6 s ease ; }
. liq-bar . remote { background : linear-gradient ( 90 deg , #4ade80 , #22c55e ); height : 100 % ; transition : width 0.6 s ease ; }
/* Sync banner */
. progress-track { width : 100 % ; height : 0.6 rem ; background : rgba ( 255 , 255 , 255 , 0.1 ); border-radius : 9999 px ; overflow : hidden ; position : relative ; }
. progress-fill { height : 100 % ; border-radius : 9999 px ; background : linear-gradient ( 90 deg , #8b5cf6 , #facc15 ); transition : width 0.5 s ease ; }
/* Status pill */
. pill { display : inline-flex ; align-items : center ; gap : 0.4 rem ; padding : 0.25 rem 0.6 rem ; border-radius : 9999 px ; font-size : 0.6875 rem ; font-weight : 600 ; letter-spacing : 0.02 em ; background : rgba ( 255 , 255 , 255 , 0.08 ); border : 1 px 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.6875 rem ; font-weight : 600 ; text-transform : uppercase ; letter-spacing : 0.05 em ; color : rgba ( 255 , 255 , 255 , 0.45 ); margin-bottom : 0.25 rem ; }
. field-row { display : flex ; align-items : center ; background : rgba ( 255 , 255 , 255 , 0.06 ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ); border-radius : 0.5 rem ; overflow : hidden ; min-width : 0 ; }
. field-value { flex : 1 ; min-width : 0 ; padding : 0.625 rem 0.875 rem ; font-family : ui-monospace , SFMono-Regular , Menlo , monospace ; font-size : 0.8125 rem ; color : rgba ( 255 , 255 , 255 , 0.9 ); white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; }
. copy-btn { padding : 0.625 rem 0.75 rem ; background : none ; border : none ; border-left : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ); cursor : pointer ; color : rgba ( 255 , 255 , 255 , 0.4 ); transition : all 0.2 s 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.75 rem ; }
. field-row-split > div { flex : 1 ; min-width : 0 ; }
. help-text { margin-top : 1 rem ; padding-top : 1 rem ; border-top : 1 px solid rgba ( 255 , 255 , 255 , 0.08 ); font-size : 0.875 rem ; color : rgba ( 255 , 255 , 255 , 0.5 ); line-height : 1.5 ; }
/* Connect */
2026-03-19 17:02:17 +00:00
. conn-select { width : 100 % ; padding : 0.75 rem 1 rem ; background : rgba ( 255 , 255 , 255 , 0.05 ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ); border-radius : 0.5 rem ; color : white ; font-size : 0.875 rem ; 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 12 px center ; background-size : 20 px ; }
. 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.5 rem ; }
2026-08-02 19:00:31 -04:00
/* 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 : 1 rem ; background : rgba ( 0 , 0 , 0 , 0.45 );
border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ); border-radius : 0.9 rem ;
}
. qr-box { width : 100 % ; max-width : 200 px ; aspect-ratio : 1 ; background : #fff ; border-radius : 0.6 rem ; padding : 0.55 rem ; display : flex ; align-items : center ; justify-content : center ; }
2026-03-19 17:02:17 +00:00
. 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.75 rem ; }
2026-08-02 18:19:34 -04:00
/* Tables (peers / activity) */
. table-scroll { overflow-x : auto ; -webkit- overflow-scrolling : touch ; }
. data-table { width : 100 % ; border-collapse : collapse ; font-size : 0.8125 rem ; }
. data-table th {
text-align : left ; padding : 0.6 rem 0.75 rem ; font-size : 0.6875 rem ; font-weight : 600 ;
text-transform : uppercase ; letter-spacing : 0.05 em ; color : rgba ( 255 , 255 , 255 , 0.45 );
border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ); white-space : nowrap ; cursor : pointer ; user-select : none ;
2026-03-19 17:02:17 +00:00
}
2026-08-02 18:19:34 -04:00
. data-table th : hover { color : rgba ( 255 , 255 , 255 , 0.75 ); }
. data-table th . caret { opacity : 0.35 ; font-size : 0.6 rem ; margin-left : 0.25 rem ; }
. data-table th . sorted . caret { opacity : 1 ; }
. data-table td { padding : 0.65 rem 0.75 rem ; border-bottom : 1 px 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.2 s ease ; }
. data-table tbody tr : hover { background : rgba ( 255 , 255 , 255 , 0.04 ); }
. filter-input {
width : 100 % ; padding : 0.55 rem 0.85 rem ; background : rgba ( 0 , 0 , 0 , 0.4 );
border : 1 px solid rgba ( 255 , 255 , 255 , 0.12 ); border-radius : 0.5 rem ;
color : white ; font-size : 0.8125 rem ; 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.85 rem ; padding : 0.75 rem ; border-radius : 0.6 rem ; transition : background 0.2 s ease ; }
. act-row : hover { background : rgba ( 255 , 255 , 255 , 0.04 ); }
. act-icon { width : 2.15 rem ; height : 2.15 rem ; border-radius : 9999 px ; 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.4 rem ; flex-wrap : wrap ; }
. chip { padding : 0.35 rem 0.8 rem ; border-radius : 9999 px ; font-size : 0.75 rem ; font-weight : 500 ; background : rgba ( 255 , 255 , 255 , 0.07 ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ); color : rgba ( 255 , 255 , 255 , 0.6 ); cursor : pointer ; transition : all 0.2 s 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.5 rem 1 rem ; text-align : center ; color : rgba ( 255 , 255 , 255 , 0.4 ); font-size : 0.875 rem ; }
. logs-box { background : rgba ( 0 , 0 , 0 , 0.45 ); border-radius : 0.5 rem ; padding : 1 rem ; font-family : ui-monospace , SFMono-Regular , Menlo , monospace ; font-size : 0.75 rem ; color : rgba ( 255 , 255 , 255 , 0.8 ); white-space : pre-wrap ; word-break : break-all ; min-height : 220 px ; max-height : 55 vh ; overflow-y : auto ; }
. skel { display : inline-block ; height : 0.85 em ; width : 4.5 rem ; border-radius : 0.25 rem ; background : rgba ( 255 , 255 , 255 , 0.1 ); }
/* ── Responsive ─────────────────────────────────────────────────── */
@ media ( min-width : 640px ) {
. sm-grid-cols-2 { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); }
. sm-grid-cols-3 { grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )); }
. balance-hero { grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )); }
. conn-layout { flex-direction : row ; }
2026-08-02 19:00:31 -04:00
. qr-box { width : 200 px ; }
2026-08-02 18:19:34 -04:00
}
@ media ( min-width : 768px ) {
. md-grid-cols-2 { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); }
. md-grid-cols-3 { grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )); }
. md-grid-cols-4 { grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr )); }
. 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 , 1 fr )); }
. lg-grid-cols-3 { grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )); }
. lg-grid-cols-4 { grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr )); }
}
/* Mobile: the tab bar becomes a bottom dock, like umbrelOS. */
@ media ( max-width : 767px ) {
. container { padding : 1 rem ; padding-bottom : 6.5 rem ; }
. balance-tile . value { font-size : 1.35 rem ; }
. logo-gradient-border img { width : 52 px ; height : 52 px ; }
. tabbar-wrap {
position : fixed ; left : 0 ; right : 0 ; bottom : 0 ; z-index : 40 ;
margin : 0 ; padding : 0.5 rem 0.75 rem calc ( 0.5 rem + env ( safe - area - inset - bottom , 0 px ));
background : linear-gradient ( to top , rgba ( 0 , 0 , 0 , 0.9 ) 55 % , rgba ( 0 , 0 , 0 , 0 ));
}
. tabbar { border-radius : 1 rem ; }
. tab-btn { flex : 1 1 0 ; flex-direction : column ; gap : 0.2 rem ; padding : 0.5 rem 0.35 rem ; font-size : 0.625 rem ; }
. tab-btn svg { width : 1.25 rem ; height : 1.25 rem ; }
. hide-mobile { display : none ; }
. data-table th , . data-table td { padding : 0.55 rem 0.5 rem ; }
}
@ media ( min-width : 768px ) { . only-mobile { display : none ; } }
2026-03-19 17:02:17 +00:00
</ style >
</ head >
< body >
2026-08-02 18:19:34 -04:00
< div class = "bg-perspective-container" >< div class = "bg-layer" ></ div ></ div >
2026-03-19 17:02:17 +00:00
< div class = "overlay" ></ div >
< div class = "container" >
2026-08-02 18:19:34 -04:00
<!-- ── Header ──────────────────────────────────────────────────── -->
2026-03-19 17:02:17 +00:00
< div class = "glass-card p-6 mb-6" >
2026-08-02 19:00:31 -04:00
< 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 >
2026-08-02 18:19:34 -04:00
</ div >
</ div >
2026-08-02 19:00:31 -04:00
< div class = "app-header-actions" >
2026-08-02 18:19:34 -04:00
< 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 >
2026-08-02 19:00:31 -04:00
< button onclick = "refreshAll()" class = "info-card-button compact" id = "refreshBtn" title = "Refresh" >
2026-08-02 18:19:34 -04:00
< 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 >
2026-03-19 17:02:17 +00:00
</ div >
< div class = "flex-1 min-w-0" >
2026-08-02 18:19:34 -04:00
< 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 >
2026-03-19 17:02:17 +00:00
</ div >
2026-08-02 18:19:34 -04:00
</ 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 >
2026-03-19 17:02:17 +00:00
</ button >
</ div >
</ div >
2026-08-02 18:19:34 -04:00
<!-- ══ 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 >
2026-03-19 17:02:17 +00:00
</ div >
</ div >
2026-08-02 18:19:34 -04:00
< 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" >
2026-03-19 17:02:17 +00:00
< p class = "text-xs text-white-60 mb-1" > Channels</ p >
2026-08-02 18:19:34 -04:00
< 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 >
2026-03-19 17:02:17 +00:00
</ div >
</ div >
2026-08-02 18:19:34 -04:00
</ 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" >
2026-07-14 03:39:56 +01:00
< div >
2026-08-02 18:19:34 -04:00
< 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 >
2026-07-14 03:39:56 +01:00
</ div >
2026-08-02 18:19:34 -04:00
< input class = "filter-input" style = "max-width:16rem" id = "peerFilter" placeholder = "Filter peers…" oninput = "renderPeers()" >
2026-07-14 03:39:56 +01:00
</ div >
2026-08-02 18:19:34 -04:00
< 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 >
2026-03-19 17:02:17 +00:00
</ div >
</ div >
</ div >
2026-08-02 18:19:34 -04:00
<!-- ══ 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 >
2026-03-19 17:02:17 +00:00
2026-08-02 18:19:34 -04:00
< 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 >
2026-03-19 17:02:17 +00:00
</ div >
2026-08-02 18:19:34 -04:00
< div class = "conn-layout" id = "connDisplay" >
2026-08-02 19:00:31 -04:00
< 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 >
2026-03-19 17:02:17 +00:00
</ div >
2026-08-02 18:19:34 -04:00
< div class = "conn-fields" >
2026-03-19 17:02:17 +00:00
< div >
2026-08-02 18:19:34 -04:00
< div class = "field-label" > Host</ div >
2026-03-19 17:02:17 +00:00
< div class = "field-row" >
2026-08-02 18:19:34 -04:00
< span class = "field-value" id = "connHost" > — </ span >
< button class = "copy-btn" onclick = "copyEl('connHost', this)" title = "Copy" aria-label = "Copy host" >
2026-03-19 17:02:17 +00:00
< 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 >
2026-08-02 18:19:34 -04:00
< 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 >
2026-03-19 17:02:17 +00:00
</ div >
</ div >
2026-08-02 18:19:34 -04:00
< 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 >
2026-03-19 17:02:17 +00:00
</ div >
2026-08-02 18:19:34 -04:00
</ 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.
2026-03-19 17:02:17 +00:00
</ div >
</ div >
2026-08-02 18:19:34 -04:00
<!-- 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" >
2026-08-02 19:00:31 -04:00
< div class = "qr-panel" >
< div class = "qr-box" id = "nodeIdQrBox" >
< div style = "color:#999;font-size:12px;text-align:center;padding:2rem" > — </ div >
</ div >
2026-08-02 18:19:34 -04:00
</ 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 >
2026-03-19 17:02:17 +00:00
</ div >
</ div >
2026-08-02 18:19:34 -04:00
<!-- ══ 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 >
2026-03-19 17:02:17 +00:00
</ div >
2026-08-02 18:19:34 -04:00
<!-- 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 >
2026-03-19 17:02:17 +00:00
</ div >
2026-08-02 18:19:34 -04:00
<!-- 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 >
2026-08-02 19:00:31 -04:00
< button onclick = "loadLogs()" class = "info-card-button compact" style = "font-size:0.75rem;padding:0.45rem 0.8rem" > Refresh</ button >
2026-03-19 17:02:17 +00:00
</ div >
2026-08-02 18:19:34 -04:00
< div class = "logs-box" id = "logsContent" > Open this tab to load logs.</ div >
2026-03-19 17:02:17 +00:00
</ div >
</ div >
</ div >
< script src = "qrcode.js" ></ script >
< script >
2026-08-02 18:19:34 -04:00
'use strict';
2026-03-19 17:02:17 +00:00
const REST_PORT = 8080;
const GRPC_PORT = 10009;
const P2P_PORT = 9735;
const host = window.location.hostname;
2026-08-02 18:19:34 -04:00
// 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.
2026-03-19 17:02:17 +00:00
function getBackendUrl() {
2026-08-02 18:19:34 -04:00
return new URLSearchParams(window.location.search).get('backend') || '';
2026-03-19 17:02:17 +00:00
}
2026-08-02 19:33:21 -04:00
// 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';
2026-03-19 17:02:17 +00:00
2026-08-02 18:19:34 -04:00
// ── 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;
2026-03-19 17:02:17 +00:00
2026-08-02 18:19:34 -04:00
try { unit = localStorage.getItem('lndui.unit') || 'sats'; } catch (_) {}
2026-03-19 17:02:17 +00:00
2026-08-02 18:19:34 -04:00
// ── Formatting ──────────────────────────────────────────────────
function num(v) { const n = Number(v); return isFinite(n) ? n : 0; }
2026-03-19 17:02:17 +00:00
2026-08-02 18:19:34 -04:00
// 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';
2026-03-19 17:02:17 +00:00
}
2026-08-02 18:19:34 -04:00
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]);
}
2026-08-02 19:33:21 -04:00
// 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);
}
2026-08-02 18:19:34 -04:00
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';
2026-03-19 17:02:17 +00:00
}
2026-08-02 18:19:34 -04:00
// ── 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();
2026-03-19 17:02:17 +00:00
}
2026-08-02 18:19:34 -04:00
// ── 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; }
2026-07-14 03:39:56 +01:00
}
2026-08-02 18:19:34 -04:00
async function refreshAll() {
const icon = document.getElementById('refreshIcon');
if (icon) icon.classList.add('animate-spin-slow');
2026-06-19 05:03:18 -04:00
try {
2026-08-02 18:19:34 -04:00
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.
2026-08-02 19:33:21 -04:00
// 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 || '—');
2026-08-02 18:19:34 -04:00
const uris = (g && g.uris) || [];
const uriWrap = document.getElementById('nodeUriWrap');
2026-08-02 19:33:21 -04:00
let uri = '';
let hint = '';
2026-08-02 18:19:34 -04:00
if (uris.length) {
2026-08-02 19:33:21 -04:00
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) {
2026-08-02 18:19:34 -04:00
uriWrap.style.display = '';
2026-08-02 19:33:21 -04:00
setText('nodeUri', uri);
setText('nodeUriHint', hint);
renderQR('nodeIdQrBox', uri);
2026-08-02 18:19:34 -04:00
}
}
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);
2026-08-02 19:33:21 -04:00
copyText(lines.join('\n')).then(() => flash(btn, 'Copied!'));
2026-08-02 18:19:34 -04:00
}
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));
2026-06-19 05:03:18 -04:00
const json = await res.json();
const lines = json.result || json.logs || (Array.isArray(json) ? json : []);
2026-08-02 18:19:34 -04:00
el.textContent = Array.isArray(lines) ? lines.join('\n') : String(lines);
el.scrollTop = el.scrollHeight;
2026-06-19 05:03:18 -04:00
} catch (e) {
2026-08-02 18:19:34 -04:00
el.textContent = 'Could not load logs: ' + e.message;
2026-03-19 17:02:17 +00:00
}
}
2026-08-02 18:19:34 -04:00
// ── Connect Your Wallet ─────────────────────────────────────────
2026-03-19 17:02:17 +00:00
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);
2026-08-02 18:19:34 -04:00
} catch (e) {
2026-03-19 17:02:17 +00:00
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;
}
2026-08-02 18:19:34 -04:00
function connSelection() {
2026-03-19 17:02:17 +00:00
const mode = document.getElementById('connMode').value;
const isTor = mode.includes('tor');
const isRest = mode.includes('rest');
2026-08-02 18:19:34 -04:00
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();
2026-03-19 17:02:17 +00:00
setText('connHost', connHost);
setText('connPort', String(port));
2026-08-02 18:19:34 -04:00
setText('connProto', isRest ? 'REST' : 'gRPC');
2026-03-19 17:02:17 +00:00
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 > ';
2026-08-02 18:19:34 -04:00
setText('connSubtitle', 'Tor hidden service not available. Use Local Network mode.');
2026-03-19 17:02:17 +00:00
return;
}
2026-08-02 18:19:34 -04:00
setText('connSubtitle', 'Scan QR with Zeus, Zap, or BlueWallet to connect.');
renderQR('lndQrBox', buildLndconnectUri(connHost, port, lndConnInfo.cert_base64url, lndConnInfo.macaroon_base64url, isTor));
2026-03-19 17:02:17 +00:00
}
function copyEl(id, btn) {
const text = document.getElementById(id).textContent.trim();
if (!text || text === '—') return;
2026-08-02 19:33:21 -04:00
copyText(text).then(() => {
2026-03-19 17:02:17 +00:00
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;
2026-08-02 18:19:34 -04:00
const { isTor, port, connHost } = connSelection();
2026-03-19 17:02:17 +00:00
const uri = buildLndconnectUri(connHost, port, lndConnInfo.cert_base64url, lndConnInfo.macaroon_base64url, isTor);
const btn = document.getElementById('copyUriBtn');
2026-08-02 19:33:21 -04:00
copyText(uri).then(() => flash(btn, 'Copied!'));
2026-03-19 17:02:17 +00:00
}
async function fetchConnectInfo() {
try {
2026-06-19 05:03:18 -04:00
const resp = await fetch(getBackendUrl() + '/lnd-connect-info', { credentials: 'include' });
2026-03-19 17:02:17 +00:00
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';
2026-08-02 18:19:34 -04:00
setText('connSubtitle', 'Tor hidden service not available. Use Local Network mode.');
2026-03-19 17:02:17 +00:00
}
2026-08-02 18:19:34 -04:00
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 > ';
2026-03-19 17:02:17 +00:00
}
} else if (data.error) {
throw new Error(data.error);
}
2026-08-02 18:19:34 -04:00
} catch (e) {
document.getElementById('lndQrBox').innerHTML = '< div style = "color:#f87171;font-size:12px;text-align:center;padding:2rem" > ' + escapeHtml(e.message) + '</ div > ';
2026-03-19 17:02:17 +00:00
}
}
2026-08-02 18:19:34 -04:00
// ── 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();
2026-03-19 17:02:17 +00:00
fetchConnectInfo();
2026-08-02 18:19:34 -04:00
// Keep the numbers live without hammering LND.
setInterval(refreshAll, 30000);
2026-03-19 17:02:17 +00:00
</ script >
</ body >
</ html >