1use std::collections::HashMap;
2use std::fmt::Display;
3use std::time::{Duration, UNIX_EPOCH};
4
5use axum::Form;
6use axum::extract::{Query, State};
7use axum::response::Html;
8use chrono::offset::LocalResult;
9use chrono::{TimeZone, Utc};
10use fedimint_core::bitcoin::Network;
11use fedimint_core::time::now;
12use fedimint_gateway_common::{
13 ChannelInfo, CloseChannelsWithPeerRequest, CreateInvoiceForOperatorPayload, GatewayBalances,
14 GatewayInfo, LightningInfo, LightningMode, ListTransactionsPayload, ListTransactionsResponse,
15 OpenChannelRequest, PayInvoiceForOperatorPayload, PaymentStatus, SendOnchainRequest,
16};
17use fedimint_logging::LOG_GATEWAY_UI;
18use fedimint_ui_common::UiState;
19use fedimint_ui_common::auth::UserAuth;
20use maud::{Markup, PreEscaped, html};
21use qrcode::QrCode;
22use qrcode::render::svg;
23use tracing::debug;
24
25use crate::{
26 CHANNEL_FRAGMENT_ROUTE, CLOSE_CHANNEL_ROUTE, CREATE_BOLT11_INVOICE_ROUTE, DynGatewayApi,
27 LN_ONCHAIN_ADDRESS_ROUTE, OPEN_CHANNEL_ROUTE, PAY_BOLT11_INVOICE_ROUTE,
28 PAYMENTS_FRAGMENT_ROUTE, SEND_ONCHAIN_ROUTE, TRANSACTIONS_FRAGMENT_ROUTE,
29 WALLET_FRAGMENT_ROUTE,
30};
31
32pub async fn render<E>(gateway_info: &GatewayInfo, api: &DynGatewayApi<E>) -> Markup
33where
34 E: std::fmt::Display,
35{
36 debug!(target: LOG_GATEWAY_UI, "Listing lightning channels...");
37 let channels_result = api.handle_list_channels_msg().await;
39
40 let (block_height, status_badge, network, alias, pubkey) =
41 if gateway_info.gateway_state == "Syncing" {
42 (
43 0,
44 html! { span class="badge bg-warning" { "🟡 Syncing" } },
45 Network::Bitcoin,
46 None,
47 None,
48 )
49 } else {
50 match &gateway_info.lightning_info {
51 LightningInfo::Connected {
52 network,
53 block_height,
54 synced_to_chain,
55 alias,
56 public_key,
57 } => {
58 let badge = if *synced_to_chain {
59 html! { span class="badge bg-success" { "🟢 Synced" } }
60 } else {
61 html! { span class="badge bg-warning" { "🟡 Syncing" } }
62 };
63 (
64 *block_height,
65 badge,
66 *network,
67 Some(alias.clone()),
68 Some(*public_key),
69 )
70 }
71 LightningInfo::NotConnected => (
72 0,
73 html! { span class="badge bg-danger" { "❌ Not Connected" } },
74 Network::Bitcoin,
75 None,
76 None,
77 ),
78 }
79 };
80
81 let is_lnd = matches!(api.lightning_mode(), LightningMode::Lnd { .. });
82 debug!(target: LOG_GATEWAY_UI, "Getting all balances...");
83 let balances_result = api.handle_get_balances_msg().await;
84 let now = now();
85 let start = now
86 .checked_sub(Duration::from_secs(60 * 60 * 24))
87 .expect("Cannot be negative");
88 let start_secs = start
89 .duration_since(UNIX_EPOCH)
90 .expect("Cannot be before epoch")
91 .as_secs();
92 let end = now;
93 let end_secs = end
94 .duration_since(UNIX_EPOCH)
95 .expect("Cannot be before epoch")
96 .as_secs();
97 debug!(target: LOG_GATEWAY_UI, "Listing lightning transactions...");
98 let transactions_result = api
99 .handle_list_transactions_msg(ListTransactionsPayload {
100 start_secs,
101 end_secs,
102 })
103 .await;
104
105 html! {
106 script {
107 (PreEscaped(r#"
108 function copyToClipboard(input) {
109 input.select();
110 document.execCommand('copy');
111 const hint = input.nextElementSibling;
112 hint.textContent = 'Copied!';
113 setTimeout(() => hint.textContent = 'Click to copy', 2000);
114 }
115 "#))
116 }
117
118 div class="card h-100" {
119 div class="card-header dashboard-header" { "Lightning Node" }
120 div class="card-body" {
121
122 ul class="nav nav-tabs" id="lightningTabs" role="tablist" {
124 li class="nav-item" role="presentation" {
125 button class="nav-link active"
126 id="connection-tab"
127 data-bs-toggle="tab"
128 data-bs-target="#connection-tab-pane"
129 type="button"
130 role="tab"
131 { "Connection Info" }
132 }
133 li class="nav-item" role="presentation" {
134 button class="nav-link"
135 id="wallet-tab"
136 data-bs-toggle="tab"
137 data-bs-target="#wallet-tab-pane"
138 type="button"
139 role="tab"
140 { "Wallet" }
141 }
142 li class="nav-item" role="presentation" {
143 button class="nav-link"
144 id="channels-tab"
145 data-bs-toggle="tab"
146 data-bs-target="#channels-tab-pane"
147 type="button"
148 role="tab"
149 { "Channels" }
150 }
151 li class="nav-item" role="presentation" {
152 button class="nav-link"
153 id="payments-tab"
154 data-bs-toggle="tab"
155 data-bs-target="#payments-tab-pane"
156 type="button"
157 role="tab"
158 { "Payments" }
159 }
160 li class="nav-item" role="presentation" {
161 button class="nav-link"
162 id="transactions-tab"
163 data-bs-toggle="tab"
164 data-bs-target="#transactions-tab-pane"
165 type="button"
166 role="tab"
167 { "Transactions" }
168 }
169 }
170
171 div class="tab-content mt-3" id="lightningTabsContent" {
172
173 div class="tab-pane fade show active"
177 id="connection-tab-pane"
178 role="tabpanel"
179 aria-labelledby="connection-tab" {
180
181 @match &gateway_info.lightning_mode {
182 LightningMode::Lnd { lnd_rpc_addr, lnd_tls_cert, lnd_macaroon } => {
183 div id="node-type" class="alert alert-info" {
184 "Node Type: " strong { "External LND" }
185 }
186 table class="table table-sm mb-0" {
187 tbody {
188 tr {
189 th { "RPC Address" }
190 td { (lnd_rpc_addr) }
191 }
192 tr {
193 th { "TLS Cert" }
194 td { (lnd_tls_cert) }
195 }
196 tr {
197 th { "Macaroon" }
198 td { (lnd_macaroon) }
199 }
200 tr {
201 th { "Network" }
202 td { (network) }
203 }
204 tr {
205 th { "Block Height" }
206 td { (block_height) }
207 }
208 tr {
209 th { "Status" }
210 td { (status_badge) }
211 }
212 @if let Some(a) = alias {
213 tr {
214 th { "Alias" }
215 td { (a) }
216 }
217 }
218 @if let Some(pk) = pubkey {
219 tr {
220 th { "Public Key" }
221 td { (pk) }
222 }
223 }
224 }
225 }
226 }
227 LightningMode::Ldk { lightning_port, .. } => {
228 div id="node-type" class="alert alert-info" {
229 "Node Type: " strong { "Internal LDK" }
230 }
231 table class="table table-sm mb-0" {
232 tbody {
233 tr {
234 th { "Port" }
235 td { (lightning_port) }
236 }
237 tr {
238 th { "Network" }
239 td { (network) }
240 }
241 tr {
242 th { "Block Height" }
243 td { (block_height) }
244 }
245 tr {
246 th { "Status" }
247 td { (status_badge) }
248 }
249 @if let Some(a) = alias {
250 tr {
251 th { "Alias" }
252 td { (a) }
253 }
254 }
255 @if let Some(pk) = pubkey {
256 tr {
257 th { "Public Key" }
258 td { (pk) }
259 }
260 }
261 }
262 }
263 }
264 }
265 }
266
267 div class="tab-pane fade"
271 id="wallet-tab-pane"
272 role="tabpanel"
273 aria-labelledby="wallet-tab" {
274
275 div class="d-flex justify-content-between align-items-center mb-2" {
276 div { strong { "Wallet" } }
277 button class="btn btn-sm btn-outline-secondary"
278 hx-get=(WALLET_FRAGMENT_ROUTE)
279 hx-target="#wallet-container"
280 hx-swap="outerHTML"
281 type="button"
282 { "Refresh" }
283 }
284
285 (wallet_fragment_markup(&balances_result, None, None))
286 }
287
288 div class="tab-pane fade"
292 id="channels-tab-pane"
293 role="tabpanel"
294 aria-labelledby="channels-tab" {
295
296 div class="d-flex justify-content-between align-items-center mb-2" {
297 div { strong { "Channels" } }
298 button class="btn btn-sm btn-outline-secondary"
299 hx-get=(CHANNEL_FRAGMENT_ROUTE)
300 hx-target="#channels-container"
301 hx-swap="outerHTML"
302 type="button"
303 { "Refresh" }
304 }
305
306 (channels_fragment_markup(channels_result, None, None, is_lnd))
307 }
308
309 div class="tab-pane fade"
313 id="payments-tab-pane"
314 role="tabpanel"
315 aria-labelledby="payments-tab" {
316
317 div class="d-flex justify-content-between align-items-center mb-2" {
318 div { strong { "Payments" } }
319 button class="btn btn-sm btn-outline-secondary"
320 hx-get=(PAYMENTS_FRAGMENT_ROUTE)
321 hx-target="#payments-container"
322 hx-swap="outerHTML"
323 type="button"
324 { "Refresh" }
325 }
326
327 (payments_fragment_markup(&balances_result, None, None, None))
328 }
329
330 div class="tab-pane fade"
334 id="transactions-tab-pane"
335 role="tabpanel"
336 aria-labelledby="transactions-tab" {
337
338 (transactions_fragment_markup(&transactions_result, start_secs, end_secs))
339 }
340 }
341 }
342 }
343 }
344}
345
346pub fn transactions_fragment_markup<E>(
347 transactions_result: &Result<ListTransactionsResponse, E>,
348 start_secs: u64,
349 end_secs: u64,
350) -> Markup
351where
352 E: std::fmt::Display,
353{
354 let start_dt = match Utc.timestamp_opt(start_secs as i64, 0) {
356 LocalResult::Single(dt) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
357 _ => "1970-01-01T00:00:00".to_string(),
358 };
359
360 let end_dt = match Utc.timestamp_opt(end_secs as i64, 0) {
361 LocalResult::Single(dt) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
362 _ => "1970-01-01T00:00:00".to_string(),
363 };
364
365 html!(
366 div id="transactions-container" {
367
368 form class="row g-3 mb-3"
372 hx-get=(TRANSACTIONS_FRAGMENT_ROUTE)
373 hx-target="#transactions-container"
374 hx-swap="outerHTML"
375 {
376 div class="col-auto" {
378 label class="form-label" for="start-secs" { "Start" }
379 input
380 class="form-control"
381 type="datetime-local"
382 id="start-secs"
383 name="start_secs"
384 step="1"
385 value=(start_dt);
386 }
387
388 div class="col-auto" {
390 label class="form-label" for="end-secs" { "End" }
391 input
392 class="form-control"
393 type="datetime-local"
394 id="end-secs"
395 name="end_secs"
396 step="1"
397 value=(end_dt);
398 }
399
400 div class="col-auto align-self-end" {
402 button class="btn btn-outline-secondary" type="submit" { "Refresh" }
403 button class="btn btn-outline-secondary me-2" type="button"
404 id="last-day-btn"
405 { "Last Day" }
406 }
407 }
408
409 script {
410 (PreEscaped(r#"
411 document.getElementById('last-day-btn').addEventListener('click', () => {
412 const now = new Date();
413 const endInput = document.getElementById('end-secs');
414 const startInput = document.getElementById('start-secs');
415
416 const pad = n => n.toString().padStart(2, '0');
417
418 const formatUTC = d =>
419 `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
420
421 endInput.value = formatUTC(now);
422
423 const start = new Date(now.getTime() - 24*60*60*1000); // 24 hours ago UTC
424 startInput.value = formatUTC(start);
425 });
426 "#))
427 }
428
429 @match transactions_result {
433 Err(err) => {
434 div class="alert alert-danger" {
435 "Failed to load lightning transactions: " (err)
436 }
437 }
438 Ok(transactions) => {
439 @if transactions.transactions.is_empty() {
440 div class="alert alert-info mt-3" {
441 "No transactions found in this time range."
442 }
443 } @else {
444 ul class="list-group mt-3" {
445 @for tx in &transactions.transactions {
446 li class="list-group-item p-2 mb-1 transaction-item"
447 style="border-radius: 0.5rem; transition: background-color 0.2s;"
448 {
449 div style="display: flex; justify-content: space-between; align-items: center;" {
451 div {
453 div style="font-weight: bold; font-size: 0.9rem;" {
454 (format!("{:?}", tx.payment_kind))
455 " — "
456 span { (format!("{:?}", tx.direction)) }
457 }
458
459 div style="font-size: 0.75rem; margin-top: 2px;" {
460 @let status_badge = match tx.status {
461 PaymentStatus::Pending => html! { span class="badge bg-warning" { "⏳ Pending" } },
462 PaymentStatus::Succeeded => html! { span class="badge bg-success" { "✅ Succeeded" } },
463 PaymentStatus::Failed => html! { span class="badge bg-danger" { "❌ Failed" } },
464 };
465 (status_badge)
466 }
467 }
468
469 div style="text-align: right;" {
471 div style="font-weight: bold; font-size: 0.9rem;" {
472 (format!("{} sats", tx.amount.msats / 1000))
473 }
474 div style="font-size: 0.7rem; color: #6c757d;" {
475 @let timestamp = match Utc.timestamp_opt(tx.timestamp_secs as i64, 0) {
476 LocalResult::Single(dt) => dt,
477 _ => Utc.timestamp_opt(0, 0).unwrap(),
478 };
479 (timestamp.format("%Y-%m-%d %H:%M:%S").to_string())
480 }
481 }
482 }
483
484 @if let Some(hash) = &tx.payment_hash {
486 div style="font-family: monospace; font-size: 0.7rem; color: #6c757d; margin-top: 2px;" {
487 "Hash: " (hash.to_string())
488 }
489 }
490
491 @if let Some(preimage) = &tx.preimage {
492 div style="font-family: monospace; font-size: 0.7rem; color: #6c757d; margin-top: 1px;" {
493 "Preimage: " (preimage)
494 }
495 }
496
497 script {
499 (PreEscaped(r#"
500 const li = document.currentScript.parentElement;
501 li.addEventListener('mouseenter', () => li.style.backgroundColor = '#f8f9fa');
502 li.addEventListener('mouseleave', () => li.style.backgroundColor = 'white');
503 "#))
504 }
505 }
506 }
507 }
508 }
509 }
510 }
511 }
512 )
513}
514
515pub fn payments_fragment_markup<E>(
516 balances_result: &Result<GatewayBalances, E>,
517 created_invoice: Option<String>,
518 success_msg: Option<String>,
519 error_msg: Option<String>,
520) -> Markup
521where
522 E: std::fmt::Display,
523{
524 html!(
525 div id="payments-container" {
526 @match balances_result {
527 Err(err) => {
528 div class="alert alert-danger" {
530 "Failed to load lightning balance: " (err)
531 }
532 }
533 Ok(bal) => {
534
535 @if let Some(success) = success_msg {
536 div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
537 span { (success) }
538 }
539 }
540
541 @if let Some(error) = error_msg {
542 div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
543 span { (error) }
544 }
545 }
546
547 div id="lightning-balance-banner"
548 class="alert alert-info d-flex justify-content-between align-items-center" {
549
550 @let lightning_balance = format!("{}", fedimint_core::Amount::from_msats(bal.lightning_balance_msats));
551
552 span {
553 "Lightning Balance: "
554 strong id="lightning-balance" { (lightning_balance) }
555 }
556 }
557
558 div class="mt-3" {
560 button class="btn btn-sm btn-outline-primary me-2"
561 type="button"
562 onclick="
563 document.getElementById('receive-form').classList.add('d-none');
564 document.getElementById('pay-invoice-form').classList.toggle('d-none');
565 "
566 { "Send" }
567
568 button class="btn btn-sm btn-outline-success"
569 type="button"
570 onclick="
571 document.getElementById('pay-invoice-form').classList.add('d-none');
572 document.getElementById('receive-form').classList.toggle('d-none');
573 "
574 { "Receive" }
575 }
576
577 div id="pay-invoice-form" class="card card-body mt-3 d-none" {
579 form
580 id="pay-ln-invoice-form"
581 hx-post=(PAY_BOLT11_INVOICE_ROUTE)
582 hx-target="#payments-container"
583 hw-swap="outerHTML"
584 {
585 div class="mb-3" {
586 label class="form-label" for="invoice" { "Bolt11 Invoice" }
587 input type="text"
588 class="form-control"
589 id="invoice"
590 name="invoice"
591 required;
592 }
593
594 button
595 type="submit"
596 class="btn btn-success btn-sm"
597 { "Pay Invoice" }
598 }
599 }
600
601 div id="receive-form" class={
603 @if created_invoice.is_some() { "card card-body mt-3 d-none" }
604 @else { "card card-body mt-3 d-none" }
605 } {
606 form
607 id="create-ln-invoice-form"
608 hx-post=(CREATE_BOLT11_INVOICE_ROUTE)
609 hx-target="#payments-container"
610 hx-swap="outerHTML"
611 {
612 div class="mb-3" {
613 label class="form-label" for="amount_msats" { "Amount (msats)" }
614 input type="number"
615 class="form-control"
616 id="amount_msats"
617 name="amount_msats"
618 min="1"
619 required;
620 }
621
622 button
623 type="submit"
624 class="btn btn-success btn-sm"
625 { "Create Bolt11 Invoice" }
626 }
627 }
628
629 @if let Some(invoice) = created_invoice {
633
634 @let code =
635 QrCode::new(&invoice).expect("Failed to generate QR code");
636 @let qr_svg = code.render::<svg::Color>().build();
637
638 div class="card card-body mt-4" {
639
640 div class="card card-body bg-light d-flex flex-column align-items-center" {
641 span class="fw-bold mb-3" { "Bolt11 Invoice:" }
642
643 div class="d-flex flex-row align-items-center gap-3 flex-wrap" style="width: 100%;" {
645
646 div class="d-flex flex-column flex-grow-1" style="min-width: 300px;" {
648 input type="text"
649 readonly
650 class="form-control mb-2"
651 style="text-align:left; font-family: monospace; font-size:1rem;"
652 value=(invoice)
653 onclick="copyToClipboard(this)"
654 {}
655 small class="text-muted" { "Click to copy" }
656 }
657
658 div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
660 style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
661 {
662 (PreEscaped(format!(
663 r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
664 qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
665 )))
666 }
667 }
668 }
669 }
670 }
671 }
672 }
673 }
674 )
675}
676
677pub fn wallet_fragment_markup<E>(
678 balances_result: &Result<GatewayBalances, E>,
679 success_msg: Option<String>,
680 error_msg: Option<String>,
681) -> Markup
682where
683 E: std::fmt::Display,
684{
685 html!(
686 div id="wallet-container" {
687 @match balances_result {
688 Err(err) => {
689 div class="alert alert-danger" {
691 "Failed to load wallet balance: " (err)
692 }
693 }
694 Ok(bal) => {
695
696 @if let Some(success) = success_msg {
697 div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
698 span { (success) }
699 }
700 }
701
702 @if let Some(error) = error_msg {
703 div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
704 span { (error) }
705 }
706 }
707
708 div id="wallet-balance-banner"
709 class="alert alert-info d-flex justify-content-between align-items-center" {
710
711 @let onchain = format!("{}", bitcoin::Amount::from_sat(bal.onchain_balance_sats));
712
713 span {
714 "Balance: "
715 strong id="wallet-balance" { (onchain) }
716 }
717 }
718
719 div class="mt-3" {
720 button class="btn btn-sm btn-outline-primary me-2"
722 type="button"
723 onclick="
724 document.getElementById('send-form').classList.toggle('d-none');
725 document.getElementById('receive-address-container').innerHTML = '';
726 "
727 { "Send" }
728
729
730 button class="btn btn-sm btn-outline-success"
731 hx-get=(LN_ONCHAIN_ADDRESS_ROUTE)
732 hx-target="#receive-address-container"
733 hx-swap="outerHTML"
734 type="button"
735 onclick="document.getElementById('send-form').classList.add('d-none');"
736 { "Receive" }
737 }
738
739 div id="send-form" class="card card-body mt-3 d-none" {
743
744 form
745 id="send-onchain-form"
746 hx-post=(SEND_ONCHAIN_ROUTE)
747 hx-target="#wallet-container"
748 hx-swap="outerHTML"
749 {
750 div class="mb-3" {
752 label class="form-label" for="address" { "Bitcoin Address" }
753 input
754 type="text"
755 class="form-control"
756 id="address"
757 name="address"
758 required;
759 }
760
761 div class="mb-3" {
763 label class="form-label" for="amount" { "Amount (sats)" }
764 div class="input-group" {
765 input
766 type="text"
767 class="form-control"
768 id="amount"
769 name="amount"
770 placeholder="e.g. 10000 or all"
771 required;
772
773 button
774 class="btn btn-outline-secondary"
775 type="button"
776 onclick="document.getElementById('amount').value = 'all';"
777 { "All" }
778 }
779 }
780
781 div class="mb-3" {
783 label class="form-label" for="fee_rate" { "Sats per vbyte" }
784 input
785 type="number"
786 class="form-control"
787 id="fee_rate"
788 name="fee_rate_sats_per_vbyte"
789 min="1"
790 required;
791 }
792
793 div class="mt-3" {
795 button
796 type="submit"
797 class="btn btn-sm btn-primary"
798 {
799 "Confirm Send"
800 }
801 }
802 }
803 }
804
805 div id="receive-address-container" class="mt-3" {}
806 }
807 }
808 }
809 )
810}
811
812pub fn channels_fragment_markup<E>(
815 channels_result: Result<Vec<ChannelInfo>, E>,
816 success_msg: Option<String>,
817 error_msg: Option<String>,
818 is_lnd: bool,
819) -> Markup
820where
821 E: std::fmt::Display,
822{
823 html! {
824 div id="channels-container" {
826 @match channels_result {
827 Err(err_str) => {
828 div class="alert alert-danger" {
829 "Failed to load channels: " (err_str)
830 }
831 }
832 Ok(channels) => {
833
834 @if let Some(success) = success_msg {
835 div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
836 span { (success) }
837 }
838 }
839
840 @if let Some(error) = error_msg {
841 div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
842 span { (error) }
843 }
844 }
845
846 @if channels.is_empty() {
847 div class="alert alert-info" { "No channels found." }
848 } @else {
849 table class="table table-sm align-middle" {
850 thead {
851 tr {
852 th { "Remote PubKey" }
853 th { "Alias" }
854 th { "Funding OutPoint" }
855 th { "Size (sats)" }
856 th { "Active" }
857 th { "Liquidity" }
858 th { "" }
859 }
860 }
861 tbody {
862 @for ch in channels {
863 @let row_id = format!("close-form-{}", ch.remote_pubkey);
864 @let size = ch.channel_size_sats.max(1);
866 @let outbound_pct = (ch.outbound_liquidity_sats as f64 / size as f64) * 100.0;
867 @let inbound_pct = (ch.inbound_liquidity_sats as f64 / size as f64) * 100.0;
868 @let funding_outpoint = if let Some(funding_outpoint) = ch.funding_outpoint {
869 funding_outpoint.to_string()
870 } else {
871 "".to_string()
872 };
873
874 tr {
875 td { (ch.remote_pubkey.to_string()) }
876 td {
877 @if let Some(alias) = &ch.remote_node_alias {
878 (alias)
879 } @else {
880 span class="text-muted" { "-" }
881 }
882 }
883 td { (funding_outpoint) }
884 td { (ch.channel_size_sats) }
885 td {
886 @if ch.is_active {
887 span class="badge bg-success" { "active" }
888 } @else {
889 span class="badge bg-secondary" { "inactive" }
890 }
891 }
892
893 td {
895 div style="width:240px;" {
896 div style="display:flex;height:10px;width:100%;border-radius:3px;overflow:hidden" {
897 div style=(format!("background:#28a745;width:{:.2}%;", outbound_pct)) {}
898 div style=(format!("background:#0d6efd;width:{:.2}%;", inbound_pct)) {}
899 }
900
901 div style="font-size:0.75rem;display:flex;justify-content:space-between;margin-top:3px;" {
902 span {
903 span style="display:inline-block;width:10px;height:10px;background:#28a745;margin-right:4px;border-radius:2px;" {}
904 (format!("Outbound ({})", ch.outbound_liquidity_sats))
905 }
906 span {
907 span style="display:inline-block;width:10px;height:10px;background:#0d6efd;margin-right:4px;border-radius:2px;" {}
908 (format!("Inbound ({})", ch.inbound_liquidity_sats))
909 }
910 }
911 }
912 }
913
914 td style="width: 70px" {
915 button class="btn btn-sm btn-outline-danger"
917 type="button"
918 data-bs-toggle="collapse"
919 data-bs-target=(format!("#{row_id}"))
920 aria-expanded="false"
921 aria-controls=(row_id)
922 { "X" }
923 }
924 }
925
926 tr class="collapse" id=(row_id) {
927 td colspan="7" {
928 div class="card card-body" {
929 form
930 hx-post=(CLOSE_CHANNEL_ROUTE)
931 hx-target="#channels-container"
932 hx-swap="outerHTML"
933 hx-indicator=(format!("#close-spinner-{}", ch.remote_pubkey))
934 hx-disabled-elt="button[type='submit']"
935 {
936 input type="hidden"
938 name="pubkey"
939 value=(ch.remote_pubkey.to_string()) {}
940
941 div class="form-check mb-3" {
942 input class="form-check-input"
943 type="checkbox"
944 name="force"
945 value="true"
946 id=(format!("force-{}", ch.remote_pubkey))
947 onchange=(format!(
948 "const input = document.getElementById('sats-vb-{}'); \
949 input.disabled = this.checked;",
950 ch.remote_pubkey
951 )) {}
952 label class="form-check-label"
953 for=(format!("force-{}", ch.remote_pubkey)) {
954 "Force Close"
955 }
956 }
957
958 @if is_lnd {
962 div class="mb-3" id=(format!("sats-vb-div-{}", ch.remote_pubkey)) {
963 label class="form-label" for=(format!("sats-vb-{}", ch.remote_pubkey)) {
964 "Sats per vbyte"
965 }
966 input
967 type="number"
968 min="1"
969 step="1"
970 class="form-control"
971 id=(format!("sats-vb-{}", ch.remote_pubkey))
972 name="sats_per_vbyte"
973 required
974 placeholder="Enter fee rate" {}
975
976 small class="text-muted" {
977 "Required for LND fee estimation"
978 }
979 }
980 } @else {
981 input type="hidden"
983 name="sats_per_vbyte"
984 value="1" {}
985 }
986
987 div class="htmx-indicator mt-2"
989 id=(format!("close-spinner-{}", ch.remote_pubkey)) {
990 div class="spinner-border spinner-border-sm text-danger" role="status" {}
991 span { " Closing..." }
992 }
993
994 button type="submit"
995 class="btn btn-danger btn-sm" {
996 "Confirm Close"
997 }
998 }
999 }
1000 }
1001 }
1002 }
1003 }
1004 }
1005 }
1006
1007 div class="mt-3" {
1008 button id="open-channel-btn" class="btn btn-sm btn-primary"
1010 type="button"
1011 data-bs-toggle="collapse"
1012 data-bs-target="#open-channel-form"
1013 aria-expanded="false"
1014 aria-controls="open-channel-form"
1015 { "Open Channel" }
1016
1017 div id="open-channel-form" class="collapse mt-3" {
1019 form hx-post=(OPEN_CHANNEL_ROUTE)
1020 hx-target="#channels-container"
1021 hx-swap="outerHTML"
1022 class="card card-body" {
1023
1024 h5 class="card-title" { "Open New Channel" }
1025
1026 div class="mb-2" {
1027 label class="form-label" { "Remote Node Public Key" }
1028 input type="text" name="pubkey" class="form-control" placeholder="03abcd..." required {}
1029 }
1030
1031 div class="mb-2" {
1032 label class="form-label" { "Host" }
1033 input type="text" name="host" class="form-control" placeholder="1.2.3.4:9735" required {}
1034 }
1035
1036 div class="mb-2" {
1037 label class="form-label" { "Channel Size (sats)" }
1038 input type="number" name="channel_size_sats" class="form-control" placeholder="1000000" required {}
1039 }
1040
1041 input type="hidden" name="push_amount_sats" value="0" {}
1042
1043 button type="submit" class="btn btn-success" { "Confirm Open" }
1044 }
1045 }
1046 }
1047 }
1048 }
1049 }
1050 }
1051}
1052
1053pub async fn channels_fragment_handler<E>(
1054 State(state): State<UiState<DynGatewayApi<E>>>,
1055 _auth: UserAuth,
1056) -> Html<String>
1057where
1058 E: std::fmt::Display,
1059{
1060 let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1061 let channels_result: Result<_, E> = state.api.handle_list_channels_msg().await;
1062
1063 let markup = channels_fragment_markup(channels_result, None, None, is_lnd);
1064 Html(markup.into_string())
1065}
1066
1067pub async fn open_channel_handler<E: Display + Send + Sync>(
1068 State(state): State<UiState<DynGatewayApi<E>>>,
1069 _auth: UserAuth,
1070 Form(payload): Form<OpenChannelRequest>,
1071) -> Html<String> {
1072 let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1073 match state.api.handle_open_channel_msg(payload).await {
1074 Ok(txid) => {
1075 let channels_result = state.api.handle_list_channels_msg().await;
1076 let markup = channels_fragment_markup(
1077 channels_result,
1078 Some(format!("Successfully initiated channel open. TxId: {txid}")),
1079 None,
1080 is_lnd,
1081 );
1082 Html(markup.into_string())
1083 }
1084 Err(err) => {
1085 let channels_result = state.api.handle_list_channels_msg().await;
1086 let markup =
1087 channels_fragment_markup(channels_result, None, Some(err.to_string()), is_lnd);
1088 Html(markup.into_string())
1089 }
1090 }
1091}
1092
1093pub async fn close_channel_handler<E: Display + Send + Sync>(
1094 State(state): State<UiState<DynGatewayApi<E>>>,
1095 _auth: UserAuth,
1096 Form(payload): Form<CloseChannelsWithPeerRequest>,
1097) -> Html<String> {
1098 let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1099 match state.api.handle_close_channels_with_peer_msg(payload).await {
1100 Ok(_) => {
1101 let channels_result = state.api.handle_list_channels_msg().await;
1102 let markup = channels_fragment_markup(
1103 channels_result,
1104 Some("Successfully initiated channel close".to_string()),
1105 None,
1106 is_lnd,
1107 );
1108 Html(markup.into_string())
1109 }
1110 Err(err) => {
1111 let channels_result = state.api.handle_list_channels_msg().await;
1112 let markup =
1113 channels_fragment_markup(channels_result, None, Some(err.to_string()), is_lnd);
1114 Html(markup.into_string())
1115 }
1116 }
1117}
1118
1119pub async fn send_onchain_handler<E: Display + Send + Sync>(
1120 State(state): State<UiState<DynGatewayApi<E>>>,
1121 _auth: UserAuth,
1122 Form(payload): Form<SendOnchainRequest>,
1123) -> Html<String> {
1124 let result = state.api.handle_send_onchain_msg(payload).await;
1125
1126 let balances = state.api.handle_get_balances_msg().await;
1127
1128 let markup = match result {
1129 Ok(txid) => wallet_fragment_markup(
1130 &balances,
1131 Some(format!("Send transaction. TxId: {txid}")),
1132 None,
1133 ),
1134 Err(err) => wallet_fragment_markup(&balances, None, Some(err.to_string())),
1135 };
1136
1137 Html(markup.into_string())
1138}
1139
1140pub async fn wallet_fragment_handler<E>(
1141 State(state): State<UiState<DynGatewayApi<E>>>,
1142 _auth: UserAuth,
1143) -> Html<String>
1144where
1145 E: std::fmt::Display,
1146{
1147 let balances_result = state.api.handle_get_balances_msg().await;
1148 let markup = wallet_fragment_markup(&balances_result, None, None);
1149 Html(markup.into_string())
1150}
1151
1152pub async fn generate_receive_address_handler<E>(
1153 State(state): State<UiState<DynGatewayApi<E>>>,
1154 _auth: UserAuth,
1155) -> Html<String>
1156where
1157 E: std::fmt::Display,
1158{
1159 let address_result = state.api.handle_get_ln_onchain_address_msg().await;
1160
1161 let markup = match address_result {
1162 Ok(address) => {
1163 let code =
1165 QrCode::new(address.to_qr_uri().as_bytes()).expect("Failed to generate QR code");
1166 let qr_svg = code.render::<svg::Color>().build();
1167
1168 html! {
1169 div class="card card-body bg-light d-flex flex-column align-items-center" {
1170 span class="fw-bold mb-3" { "Deposit Address:" }
1171
1172 div class="d-flex flex-row align-items-center gap-3 flex-wrap" style="width: 100%;" {
1174
1175 div class="d-flex flex-column flex-grow-1" style="min-width: 300px;" {
1177 input type="text"
1178 readonly
1179 class="form-control mb-2"
1180 style="text-align:left; font-family: monospace; font-size:1rem;"
1181 value=(address)
1182 onclick="copyToClipboard(this)"
1183 {}
1184 small class="text-muted" { "Click to copy" }
1185 }
1186
1187 div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
1189 style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
1190 {
1191 (PreEscaped(format!(
1192 r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
1193 qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
1194 )))
1195 }
1196 }
1197 }
1198 }
1199 }
1200 Err(err) => {
1201 html! {
1202 div class="alert alert-danger" { "Failed to generate address: " (err) }
1203 }
1204 }
1205 };
1206
1207 Html(markup.into_string())
1208}
1209
1210pub async fn payments_fragment_handler<E>(
1211 State(state): State<UiState<DynGatewayApi<E>>>,
1212 _auth: UserAuth,
1213) -> Html<String>
1214where
1215 E: std::fmt::Display,
1216{
1217 let balances_result = state.api.handle_get_balances_msg().await;
1218 let markup = payments_fragment_markup(&balances_result, None, None, None);
1219 Html(markup.into_string())
1220}
1221
1222pub async fn create_bolt11_invoice_handler<E>(
1223 State(state): State<UiState<DynGatewayApi<E>>>,
1224 _auth: UserAuth,
1225 Form(payload): Form<CreateInvoiceForOperatorPayload>,
1226) -> Html<String>
1227where
1228 E: std::fmt::Display,
1229{
1230 let invoice_result = state
1231 .api
1232 .handle_create_invoice_for_operator_msg(payload)
1233 .await;
1234 let balances_result = state.api.handle_get_balances_msg().await;
1235
1236 match invoice_result {
1237 Ok(invoice) => {
1238 let markup =
1239 payments_fragment_markup(&balances_result, Some(invoice.to_string()), None, None);
1240 Html(markup.into_string())
1241 }
1242 Err(e) => {
1243 let markup = payments_fragment_markup(
1244 &balances_result,
1245 None,
1246 None,
1247 Some(format!("Failed to create invoice: {e}")),
1248 );
1249 Html(markup.into_string())
1250 }
1251 }
1252}
1253
1254pub async fn pay_bolt11_invoice_handler<E>(
1255 State(state): State<UiState<DynGatewayApi<E>>>,
1256 _auth: UserAuth,
1257 Form(payload): Form<PayInvoiceForOperatorPayload>,
1258) -> Html<String>
1259where
1260 E: std::fmt::Display,
1261{
1262 let send_result = state.api.handle_pay_invoice_for_operator_msg(payload).await;
1263 let balances_result = state.api.handle_get_balances_msg().await;
1264
1265 match send_result {
1266 Ok(preimage) => {
1267 let markup = payments_fragment_markup(
1268 &balances_result,
1269 None,
1270 Some(format!("Successfully paid invoice. Preimage: {preimage}")),
1271 None,
1272 );
1273 Html(markup.into_string())
1274 }
1275 Err(e) => {
1276 let markup = payments_fragment_markup(
1277 &balances_result,
1278 None,
1279 None,
1280 Some(format!("Failed to pay invoice: {e}")),
1281 );
1282 Html(markup.into_string())
1283 }
1284 }
1285}
1286
1287pub async fn transactions_fragment_handler<E>(
1288 State(state): State<UiState<DynGatewayApi<E>>>,
1289 _auth: UserAuth,
1290 Query(params): Query<HashMap<String, String>>,
1291) -> Html<String>
1292where
1293 E: std::fmt::Display + std::fmt::Debug,
1294{
1295 let now = fedimint_core::time::now();
1296 let end_secs = now
1297 .duration_since(std::time::UNIX_EPOCH)
1298 .expect("Time went backwards")
1299 .as_secs();
1300
1301 let start_secs = now
1302 .checked_sub(std::time::Duration::from_secs(60 * 60 * 24))
1303 .unwrap_or(now)
1304 .duration_since(std::time::UNIX_EPOCH)
1305 .expect("Time went backwards")
1306 .as_secs();
1307
1308 let parse = |key: &str| -> Option<u64> {
1309 params.get(key).and_then(|s| {
1310 chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
1311 .ok()
1312 .map(|dt| {
1313 let dt_utc: chrono::DateTime<Utc> = Utc.from_utc_datetime(&dt);
1314 dt_utc.timestamp() as u64
1315 })
1316 })
1317 };
1318
1319 let start_secs = parse("start_secs").unwrap_or(start_secs);
1320 let end_secs = parse("end_secs").unwrap_or(end_secs);
1321
1322 let transactions_result = state
1323 .api
1324 .handle_list_transactions_msg(ListTransactionsPayload {
1325 start_secs,
1326 end_secs,
1327 })
1328 .await;
1329
1330 Html(transactions_fragment_markup(&transactions_result, start_secs, end_secs).into_string())
1331}