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}