fedimint_gateway_ui/
lightning.rs

1use std::fmt::Display;
2
3use axum::Form;
4use axum::extract::State;
5use axum::response::Html;
6use fedimint_core::bitcoin::Network;
7use fedimint_gateway_common::{
8    ChannelInfo, CloseChannelsWithPeerRequest, GatewayInfo, LightningInfo, LightningMode,
9    OpenChannelRequest,
10};
11use fedimint_ui_common::UiState;
12use fedimint_ui_common::auth::UserAuth;
13use maud::{Markup, html};
14
15use crate::{CHANNEL_FRAGMENT_ROUTE, CLOSE_CHANNEL_ROUTE, DynGatewayApi, OPEN_CHANNEL_ROUTE};
16
17pub async fn render<E>(gateway_info: &GatewayInfo, api: &DynGatewayApi<E>) -> Markup
18where
19    E: std::fmt::Display,
20{
21    // Try to load channels
22    let channels_result = api.handle_list_channels_msg().await;
23
24    // Extract LightningInfo status
25    let (block_height, status_badge, network, alias, pubkey) = match &gateway_info.lightning_info {
26        LightningInfo::Connected {
27            network,
28            block_height,
29            synced_to_chain,
30            alias,
31            public_key,
32        } => {
33            let badge = if *synced_to_chain {
34                html! { span class="badge bg-success" { "🟢 Synced" } }
35            } else {
36                html! { span class="badge bg-warning" { "🟡 Syncing" } }
37            };
38            (
39                *block_height,
40                badge,
41                *network,
42                Some(alias.clone()),
43                Some(*public_key),
44            )
45        }
46        LightningInfo::NotConnected => (
47            0,
48            html! { span class="badge bg-danger" { "❌ Not Connected" } },
49            Network::Bitcoin,
50            None,
51            None,
52        ),
53    };
54
55    let is_lnd = matches!(api.lightning_mode(), LightningMode::Lnd { .. });
56
57    html! {
58        div class="card h-100" {
59            div class="card-header dashboard-header" { "Lightning" }
60            div class="card-body" {
61
62                // --- TABS ---
63                ul class="nav nav-tabs" id="lightningTabs" role="tablist" {
64                    li class="nav-item" role="presentation" {
65                        button class="nav-link active"
66                            id="connection-tab"
67                            data-bs-toggle="tab"
68                            data-bs-target="#connection-tab-pane"
69                            type="button"
70                            role="tab"
71                        { "Connection Info" }
72                    }
73                    li class="nav-item" role="presentation" {
74                        button class="nav-link"
75                            id="channels-tab"
76                            data-bs-toggle="tab"
77                            data-bs-target="#channels-tab-pane"
78                            type="button"
79                            role="tab"
80                        { "Channels" }
81                    }
82                }
83
84                div class="tab-content mt-3" id="lightningTabsContent" {
85
86                    // ──────────────────────────────────────────
87                    //   TAB: CONNECTION INFO
88                    // ──────────────────────────────────────────
89                    div class="tab-pane fade show active"
90                        id="connection-tab-pane"
91                        role="tabpanel"
92                        aria-labelledby="connection-tab" {
93
94                        @match &gateway_info.lightning_mode {
95                            LightningMode::Lnd { lnd_rpc_addr, lnd_tls_cert, lnd_macaroon } => {
96                                div id="node-type" class="alert alert-info" {
97                                    "Node Type: " strong { "External LND" }
98                                }
99                                table class="table table-sm mb-0" {
100                                    tbody {
101                                        tr {
102                                            th { "RPC Address" }
103                                            td { (lnd_rpc_addr) }
104                                        }
105                                        tr {
106                                            th { "TLS Cert" }
107                                            td { (lnd_tls_cert) }
108                                        }
109                                        tr {
110                                            th { "Macaroon" }
111                                            td { (lnd_macaroon) }
112                                        }
113                                        tr {
114                                            th { "Network" }
115                                            td { (network) }
116                                        }
117                                        tr {
118                                            th { "Block Height" }
119                                            td { (block_height) }
120                                        }
121                                        tr {
122                                            th { "Status" }
123                                            td { (status_badge) }
124                                        }
125                                        @if let Some(a) = alias {
126                                            tr {
127                                                th { "Lightning Alias" }
128                                                td { (a) }
129                                            }
130                                        }
131                                        @if let Some(pk) = pubkey {
132                                            tr {
133                                                th { "Lightning Public Key" }
134                                                td { (pk) }
135                                            }
136                                        }
137                                    }
138                                }
139                            }
140                            LightningMode::Ldk { lightning_port, .. } => {
141                                div id="node-type" class="alert alert-info" {
142                                    "Node Type: " strong { "Internal LDK" }
143                                }
144                                table class="table table-sm mb-0" {
145                                    tbody {
146                                        tr {
147                                            th { "Port" }
148                                            td { (lightning_port) }
149                                        }
150                                        tr {
151                                            th { "Network" }
152                                            td { (network) }
153                                        }
154                                        tr {
155                                            th { "Block Height" }
156                                            td { (block_height) }
157                                        }
158                                        tr {
159                                            th { "Status" }
160                                            td { (status_badge) }
161                                        }
162                                        @if let Some(a) = alias {
163                                            tr {
164                                                th { "Alias" }
165                                                td { (a) }
166                                            }
167                                        }
168                                        @if let Some(pk) = pubkey {
169                                            tr {
170                                                th { "Public Key" }
171                                                td { (pk) }
172                                            }
173                                        }
174                                    }
175                                }
176                            }
177                        }
178                    }
179
180                    // ──────────────────────────────────────────
181                    //   TAB: CHANNELS
182                    // ──────────────────────────────────────────
183                    div class="tab-pane fade"
184                        id="channels-tab-pane"
185                        role="tabpanel"
186                        aria-labelledby="channels-tab" {
187
188                        div class="d-flex justify-content-between align-items-center mb-2" {
189                            div { strong { "Channels" } }
190                            button class="btn btn-sm btn-outline-secondary"
191                                hx-get=(CHANNEL_FRAGMENT_ROUTE)
192                                hx-target="#channels-container"
193                                hx-swap="outerHTML"
194                                type="button"
195                            { "Refresh" }
196                        }
197
198                        (channels_fragment_markup(channels_result, None, None, is_lnd))
199                    }
200                }
201            }
202        }
203    }
204}
205
206// channels_fragment_markup converts either the channels Vec or an error string
207// into a chunk of HTML (the thing HTMX will replace).
208pub fn channels_fragment_markup<E>(
209    channels_result: Result<Vec<ChannelInfo>, E>,
210    success_msg: Option<String>,
211    error_msg: Option<String>,
212    is_lnd: bool,
213) -> Markup
214where
215    E: std::fmt::Display,
216{
217    html! {
218        // This outer div is what we'll replace with hx-swap="outerHTML"
219        div id="channels-container" {
220            @match channels_result {
221                Err(err_str) => {
222                    div class="alert alert-danger" {
223                        "Failed to load channels: " (err_str)
224                    }
225                }
226                Ok(channels) => {
227
228                    @if let Some(success) = success_msg {
229                        div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
230                            span { (success) }
231                        }
232                    }
233
234                    @if let Some(error) = error_msg {
235                        div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
236                            span { (error) }
237                        }
238                    }
239
240                    @if channels.is_empty() {
241                        div class="alert alert-info" { "No channels found." }
242                    } @else {
243                        table class="table table-sm align-middle" {
244                            thead {
245                                tr {
246                                    th { "Remote PubKey" }
247                                    th { "Funding OutPoint" }
248                                    th { "Size (sats)" }
249                                    th { "Active" }
250                                    th { "Liquidity" }
251                                    th { "" }
252                                }
253                            }
254                            tbody {
255                                @for ch in channels {
256                                    @let row_id = format!("close-form-{}", ch.remote_pubkey);
257                                    // precompute safely (no @let inline arithmetic)
258                                    @let size = ch.channel_size_sats.max(1);
259                                    @let outbound_pct = (ch.outbound_liquidity_sats as f64 / size as f64) * 100.0;
260                                    @let inbound_pct  = (ch.inbound_liquidity_sats  as f64 / size as f64) * 100.0;
261                                    @let funding_outpoint = if let Some(funding_outpoint) = ch.funding_outpoint {
262                                        funding_outpoint.to_string()
263                                    } else {
264                                        "".to_string()
265                                    };
266
267                                    tr {
268                                        td { (ch.remote_pubkey.to_string()) }
269                                        td { (funding_outpoint) }
270                                        td { (ch.channel_size_sats) }
271                                        td {
272                                            @if ch.is_active {
273                                                span class="badge bg-success" { "active" }
274                                            } @else {
275                                                span class="badge bg-secondary" { "inactive" }
276                                            }
277                                        }
278
279                                        // Liquidity bar: single horizontal bar split by two divs
280                                        td {
281                                            div style="width:240px;" {
282                                                div style="display:flex;height:10px;width:100%;border-radius:3px;overflow:hidden" {
283                                                    div style=(format!("background:#28a745;width:{:.2}%;", outbound_pct)) {}
284                                                    div style=(format!("background:#0d6efd;width:{:.2}%;", inbound_pct)) {}
285                                                }
286
287                                                div style="font-size:0.75rem;display:flex;justify-content:space-between;margin-top:3px;" {
288                                                    span {
289                                                        span style="display:inline-block;width:10px;height:10px;background:#28a745;margin-right:4px;border-radius:2px;" {}
290                                                        "Outbound"
291                                                    }
292                                                    span {
293                                                        span style="display:inline-block;width:10px;height:10px;background:#0d6efd;margin-right:4px;border-radius:2px;" {}
294                                                        "Inbound"
295                                                    }
296                                                }
297                                            }
298                                        }
299
300                                        td style="width: 70px" {
301                                            // X button toggles a per-row collapse
302                                            button class="btn btn-sm btn-outline-danger"
303                                                type="button"
304                                                data-bs-toggle="collapse"
305                                                data-bs-target=(format!("#{row_id}"))
306                                                aria-expanded="false"
307                                                aria-controls=(row_id)
308                                            { "X" }
309                                        }
310                                    }
311
312                                    tr class="collapse" id=(row_id) {
313                                        td colspan="6" {
314                                            div class="card card-body" {
315                                                form
316                                                    hx-post=(CLOSE_CHANNEL_ROUTE)
317                                                    hx-target="#channels-container"
318                                                    hx-swap="outerHTML"
319                                                    hx-indicator=(format!("#close-spinner-{}", ch.remote_pubkey))
320                                                    hx-disabled-elt="button[type='submit']"
321                                                {
322                                                    // always required
323                                                    input type="hidden"
324                                                        name="pubkey"
325                                                        value=(ch.remote_pubkey.to_string()) {}
326
327                                                    div class="form-check mb-3" {
328                                                        input class="form-check-input"
329                                                            type="checkbox"
330                                                            name="force"
331                                                            value="true"
332                                                            id=(format!("force-{}", ch.remote_pubkey))
333                                                            onchange=(format!(
334                                                                "const input = document.getElementById('sats-vb-{}'); \
335                                                                input.disabled = this.checked;",
336                                                                ch.remote_pubkey
337                                                            )) {}
338                                                        label class="form-check-label"
339                                                            for=(format!("force-{}", ch.remote_pubkey)) {
340                                                            "Force Close"
341                                                        }
342                                                    }
343
344                                                    // -------------------------------------------
345                                                    // CONDITIONAL sats/vbyte input
346                                                    // -------------------------------------------
347                                                    @if is_lnd {
348                                                        div class="mb-3" id=(format!("sats-vb-div-{}", ch.remote_pubkey)) {
349                                                            label class="form-label" for=(format!("sats-vb-{}", ch.remote_pubkey)) {
350                                                                "Sats per vbyte"
351                                                            }
352                                                            input
353                                                                type="number"
354                                                                min="1"
355                                                                step="1"
356                                                                class="form-control"
357                                                                id=(format!("sats-vb-{}", ch.remote_pubkey))
358                                                                name="sats_per_vbyte"
359                                                                required
360                                                                placeholder="Enter fee rate" {}
361
362                                                            small class="text-muted" {
363                                                                "Required for LND fee estimation"
364                                                            }
365                                                        }
366                                                    } @else {
367                                                        // LDK → auto-filled, hidden
368                                                        input type="hidden"
369                                                            name="sats_per_vbyte"
370                                                            value="1" {}
371                                                    }
372
373                                                    // spinner for this specific channel
374                                                    div class="htmx-indicator mt-2"
375                                                        id=(format!("close-spinner-{}", ch.remote_pubkey)) {
376                                                        div class="spinner-border spinner-border-sm text-danger" role="status" {}
377                                                        span { " Closing..." }
378                                                    }
379
380                                                    button type="submit"
381                                                        class="btn btn-danger btn-sm" {
382                                                        "Confirm Close"
383                                                    }
384                                                }
385                                            }
386                                        }
387                                    }
388                                }
389                            }
390                        }
391                    }
392
393                    div class="mt-3" {
394                        // Toggle button
395                        button id="open-channel-btn" class="btn btn-sm btn-primary"
396                            type="button"
397                            data-bs-toggle="collapse"
398                            data-bs-target="#open-channel-form"
399                            aria-expanded="false"
400                            aria-controls="open-channel-form"
401                        { "Open Channel" }
402
403                        // Collapsible form
404                        div id="open-channel-form" class="collapse mt-3" {
405                            form hx-post=(OPEN_CHANNEL_ROUTE)
406                                hx-target="#channels-container"
407                                hx-swap="outerHTML"
408                                class="card card-body" {
409
410                                h5 class="card-title" { "Open New Channel" }
411
412                                div class="mb-2" {
413                                    label class="form-label" { "Remote Node Public Key" }
414                                    input type="text" name="pubkey" class="form-control" placeholder="03abcd..." required {}
415                                }
416
417                                div class="mb-2" {
418                                    label class="form-label" { "Host" }
419                                    input type="text" name="host" class="form-control" placeholder="1.2.3.4:9735" required {}
420                                }
421
422                                div class="mb-2" {
423                                    label class="form-label" { "Channel Size (sats)" }
424                                    input type="number" name="channel_size_sats" class="form-control" placeholder="1000000" required {}
425                                }
426
427                                input type="hidden" name="push_amount_sats" value="0" {}
428
429                                button type="submit" class="btn btn-success" { "Confirm Open" }
430                            }
431                        }
432                    }
433                }
434            }
435        }
436    }
437}
438
439pub async fn channels_fragment_handler<E>(
440    State(state): State<UiState<DynGatewayApi<E>>>,
441    _auth: UserAuth,
442) -> Html<String>
443where
444    E: std::fmt::Display,
445{
446    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
447    let channels_result: Result<_, E> = state.api.handle_list_channels_msg().await;
448
449    let markup = channels_fragment_markup(channels_result, None, None, is_lnd);
450    Html(markup.into_string())
451}
452
453pub async fn open_channel_handler<E: Display + Send + Sync>(
454    State(state): State<UiState<DynGatewayApi<E>>>,
455    _auth: UserAuth,
456    Form(payload): Form<OpenChannelRequest>,
457) -> Html<String> {
458    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
459    match state.api.handle_open_channel_msg(payload).await {
460        Ok(txid) => {
461            let channels_result = state.api.handle_list_channels_msg().await;
462            let markup = channels_fragment_markup(
463                channels_result,
464                Some(format!("Successfully initiated channel open. TxId: {txid}")),
465                None,
466                is_lnd,
467            );
468            Html(markup.into_string())
469        }
470        Err(err) => {
471            let channels_result = state.api.handle_list_channels_msg().await;
472            let markup =
473                channels_fragment_markup(channels_result, None, Some(err.to_string()), is_lnd);
474            Html(markup.into_string())
475        }
476    }
477}
478
479pub async fn close_channel_handler<E: Display + Send + Sync>(
480    State(state): State<UiState<DynGatewayApi<E>>>,
481    _auth: UserAuth,
482    Form(payload): Form<CloseChannelsWithPeerRequest>,
483) -> Html<String> {
484    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
485    match state.api.handle_close_channels_with_peer_msg(payload).await {
486        Ok(_) => {
487            let channels_result = state.api.handle_list_channels_msg().await;
488            let markup = channels_fragment_markup(
489                channels_result,
490                Some("Successfully initiated channel close".to_string()),
491                None,
492                is_lnd,
493            );
494            Html(markup.into_string())
495        }
496        Err(err) => {
497            let channels_result = state.api.handle_list_channels_msg().await;
498            let markup =
499                channels_fragment_markup(channels_result, None, Some(err.to_string()), is_lnd);
500            Html(markup.into_string())
501        }
502    }
503}