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}