fedimint_gateway_ui/
lightning.rs

1use axum::extract::State;
2use axum::response::Html;
3use fedimint_gateway_common::{ChannelInfo, GatewayInfo, LightningMode};
4use fedimint_ui_common::UiState;
5use maud::{Markup, html};
6
7use crate::{CHANNEL_FRAGMENT_ROUTE, DynGatewayApi};
8
9pub async fn render<E>(gateway_info: &GatewayInfo, api: &DynGatewayApi<E>) -> Markup
10where
11    E: std::fmt::Display,
12{
13    // Try to load channels
14    let channels_result = api.handle_list_channels_msg().await;
15
16    html! {
17        div class="card h-100" {
18            div class="card-header dashboard-header" { "Lightning" }
19            div class="card-body" {
20
21                // --- TABS ---
22                ul class="nav nav-tabs" id="lightningTabs" role="tablist" {
23                    li class="nav-item" role="presentation" {
24                        button class="nav-link active"
25                            id="connection-tab"
26                            data-bs-toggle="tab"
27                            data-bs-target="#connection-tab-pane"
28                            type="button"
29                            role="tab"
30                        { "Connection Info" }
31                    }
32                    li class="nav-item" role="presentation" {
33                        button class="nav-link"
34                            id="channels-tab"
35                            data-bs-toggle="tab"
36                            data-bs-target="#channels-tab-pane"
37                            type="button"
38                            role="tab"
39                        { "Channels" }
40                    }
41                }
42
43                div class="tab-content mt-3" id="lightningTabsContent" {
44
45                    // ──────────────────────────────────────────
46                    //   TAB: CONNECTION INFO
47                    // ──────────────────────────────────────────
48                    div class="tab-pane fade show active"
49                        id="connection-tab-pane"
50                        role="tabpanel"
51                        aria-labelledby="connection-tab" {
52
53                        @match &gateway_info.lightning_mode {
54                            LightningMode::Lnd { lnd_rpc_addr, lnd_tls_cert, lnd_macaroon } => {
55                                div id="node-type" class="alert alert-info" {
56                                    "Node Type: " strong { ("External LND") }
57                                }
58                                table class="table table-sm mb-0" {
59                                    tbody {
60                                        tr {
61                                            th { "RPC Address" }
62                                            td { (lnd_rpc_addr) }
63                                        }
64                                        tr {
65                                            th { "TLS Cert" }
66                                            td { (lnd_tls_cert) }
67                                        }
68                                        tr {
69                                            th { "Macaroon" }
70                                            td { (lnd_macaroon) }
71                                        }
72                                        @if let Some(alias) = &gateway_info.lightning_alias {
73                                            tr {
74                                                th { "Lightning Alias" }
75                                                td { (alias) }
76                                            }
77                                        }
78                                        @if let Some(pubkey) = &gateway_info.lightning_pub_key {
79                                            tr {
80                                                th { "Lightning Public Key" }
81                                                td { (pubkey) }
82                                            }
83                                        }
84                                    }
85                                }
86                            }
87                            LightningMode::Ldk { lightning_port, alias: _ } => {
88                                div id="node-type" class="alert alert-info" {
89                                    "Node Type: " strong { ("Internal LDK") }
90                                }
91                                table class="table table-sm mb-0" {
92                                    tbody {
93                                        tr {
94                                            th { "Port" }
95                                            td { (lightning_port) }
96                                        }
97                                        @if let Some(alias) = &gateway_info.lightning_alias {
98                                            tr {
99                                                th { "Alias" }
100                                                td { (alias) }
101                                            }
102                                        }
103                                        @if let Some(pubkey) = &gateway_info.lightning_pub_key {
104                                            tr {
105                                                th { "Public Key" }
106                                                td { (pubkey) }
107                                            }
108                                            @if let Some(host) = gateway_info.api.host_str() {
109                                                tr {
110                                                    th { "Connection String" }
111                                                    td { (format!("{pubkey}@{host}:{lightning_port}")) }
112                                                }
113                                            }
114                                        }
115                                    }
116                                }
117                            }
118                        }
119                    }
120
121                    // ──────────────────────────────────────────
122                    //   TAB: CHANNELS
123                    // ──────────────────────────────────────────
124                    div class="tab-pane fade"
125                        id="channels-tab-pane"
126                        role="tabpanel"
127                        aria-labelledby="channels-tab" {
128
129                        // header + refresh button aligned right
130                        div class="d-flex justify-content-between align-items-center mb-2" {
131                            div { strong { "Channels" } }
132                            // HTMX refresh button:
133                            // hx-get should point to the route we will create below
134                            button class="btn btn-sm btn-outline-secondary"
135                                hx-get=(CHANNEL_FRAGMENT_ROUTE)
136                                hx-target="#channels-container"
137                                hx-swap="outerHTML"
138                                type="button"
139                            { "Refresh" }
140                        }
141
142                        // The initial fragment markup (from the channels_result we fetched above)
143                        (channels_fragment_markup(channels_result))
144                    }
145                }
146            }
147        }
148    }
149}
150
151// channels_fragment_markup converts either the channels Vec or an error string
152// into a chunk of HTML (the thing HTMX will replace).
153pub fn channels_fragment_markup<E>(channels_result: Result<Vec<ChannelInfo>, E>) -> Markup
154where
155    E: std::fmt::Display,
156{
157    html! {
158        // This outer div is what we'll replace with hx-swap="outerHTML"
159        div id="channels-container" {
160            @match channels_result {
161                Err(err_str) => {
162                    div class="alert alert-danger" {
163                        "Failed to load channels: " (err_str)
164                    }
165                }
166                Ok(channels) => {
167                    @if channels.is_empty() {
168                        div class="alert alert-info" { "No channels found." }
169                    } @else {
170                        table class="table table-sm align-middle" {
171                            thead {
172                                tr {
173                                    th { "Remote PubKey" }
174                                    th { "Size (sats)" }
175                                    th { "Active" }
176                                    th { "Liquidity" }
177                                }
178                            }
179                            tbody {
180                                @for ch in channels {
181                                    // precompute safely (no @let inline arithmetic)
182                                    @let size = ch.channel_size_sats.max(1);
183                                    @let outbound_pct = (ch.outbound_liquidity_sats as f64 / size as f64) * 100.0;
184                                    @let inbound_pct  = (ch.inbound_liquidity_sats  as f64 / size as f64) * 100.0;
185
186                                    tr {
187                                        td { (ch.remote_pubkey.to_string()) }
188                                        td { (ch.channel_size_sats) }
189                                        td {
190                                            @if ch.is_active {
191                                                span class="badge bg-success" { "active" }
192                                            } @else {
193                                                span class="badge bg-secondary" { "inactive" }
194                                            }
195                                        }
196
197                                        // Liquidity bar: single horizontal bar split by two divs
198                                        td {
199                                            div style="width:240px;" {
200                                                div style="display:flex;height:10px;width:100%;border-radius:3px;overflow:hidden" {
201                                                    div style=(format!("background:#28a745;width:{:.2}%;", outbound_pct)) {}
202                                                    div style=(format!("background:#0d6efd;width:{:.2}%;", inbound_pct)) {}
203                                                }
204
205                                                div style="font-size:0.75rem;display:flex;justify-content:space-between;margin-top:3px;" {
206                                                    span {
207                                                        span style="display:inline-block;width:10px;height:10px;background:#28a745;margin-right:4px;border-radius:2px;" {}
208                                                        "Outbound"
209                                                    }
210                                                    span {
211                                                        span style="display:inline-block;width:10px;height:10px;background:#0d6efd;margin-right:4px;border-radius:2px;" {}
212                                                        "Inbound"
213                                                    }
214                                                }
215                                            }
216                                        }
217                                    }
218                                }
219                            }
220                        }
221                    }
222                }
223            }
224        }
225    }
226}
227
228pub async fn channels_fragment_handler<E>(
229    State(state): State<UiState<DynGatewayApi<E>>>,
230) -> Html<String>
231where
232    E: std::fmt::Display,
233{
234    let channels_result: Result<_, E> = state.api.handle_list_channels_msg().await;
235
236    let markup = channels_fragment_markup(channels_result);
237    Html(markup.into_string())
238}