fedimint_gateway_ui/
payment_summary.rs

1use std::time::{Duration, UNIX_EPOCH};
2
3use axum::extract::{Query, RawQuery, State};
4use axum::response::Html;
5use fedimint_core::config::FederationId;
6use fedimint_core::module::serde_json;
7use fedimint_core::time::now;
8use fedimint_eventlog::{Event, EventKind, EventLogId};
9use fedimint_gateway_common::{
10    FederationInfo, PaymentLogPayload, PaymentLogResponse, PaymentStats, PaymentSummaryPayload,
11    PaymentSummaryResponse,
12};
13use fedimint_gwv2_client::events::{
14    CompleteLightningPaymentSucceeded, IncomingPaymentFailed, IncomingPaymentStarted,
15    IncomingPaymentSucceeded, OutgoingPaymentFailed, OutgoingPaymentStarted,
16    OutgoingPaymentSucceeded,
17};
18use fedimint_mint_client::event::{OOBNotesReissued, OOBNotesSpent};
19use fedimint_ui_common::UiState;
20use fedimint_ui_common::auth::UserAuth;
21use fedimint_wallet_client::events::{DepositConfirmed, WithdrawRequest};
22use maud::{Markup, PreEscaped, html};
23use serde::Deserialize;
24
25use crate::{DynGatewayApi, PAYMENT_LOG_ROUTE};
26
27/// Event categories for UI display - Lightning events
28const LIGHTNING_EVENTS: &[(&str, EventKind)] = &[
29    ("Outgoing Started", OutgoingPaymentStarted::KIND),
30    ("Outgoing Succeeded", OutgoingPaymentSucceeded::KIND),
31    ("Outgoing Failed", OutgoingPaymentFailed::KIND),
32    ("Incoming Started", IncomingPaymentStarted::KIND),
33    ("Incoming Succeeded", IncomingPaymentSucceeded::KIND),
34    ("Incoming Failed", IncomingPaymentFailed::KIND),
35    (
36        "Complete LN Payment",
37        CompleteLightningPaymentSucceeded::KIND,
38    ),
39];
40
41/// Event categories for UI display - Wallet events
42const WALLET_EVENTS: &[(&str, EventKind)] = &[
43    ("Withdraw Request", WithdrawRequest::KIND),
44    ("Deposit Confirmed", DepositConfirmed::KIND),
45];
46
47/// Event categories for UI display - E-cash events
48const ECASH_EVENTS: &[(&str, EventKind)] = &[
49    ("Notes Spent", OOBNotesSpent::KIND),
50    ("Notes Reissued", OOBNotesReissued::KIND),
51];
52
53/// Query parameters for the payment log handler
54/// Note: event_kinds is parsed separately from the raw query string
55/// because serde_urlencoded doesn't handle repeated params well
56#[derive(Debug, Deserialize)]
57pub struct PaymentLogQueryParams {
58    pub federation_id: Option<String>,
59    pub end_position: Option<EventLogId>,
60}
61
62pub async fn render<E>(api: &DynGatewayApi<E>, federations: &[FederationInfo]) -> Markup
63where
64    E: std::fmt::Display,
65{
66    let now = now();
67    let now_millis = now
68        .duration_since(UNIX_EPOCH)
69        .expect("Before unix epoch")
70        .as_millis() as u64;
71
72    let one_day_ago = now
73        .checked_sub(Duration::from_secs(60 * 60 * 24))
74        .expect("Before unix epoch");
75    let one_day_ago_millis = one_day_ago
76        .duration_since(UNIX_EPOCH)
77        .expect("Before unix epoch")
78        .as_millis() as u64;
79
80    // Fetch payment summary safely
81    let payment_summary = api
82        .handle_payment_summary_msg(PaymentSummaryPayload {
83            start_millis: one_day_ago_millis,
84            end_millis: now_millis,
85        })
86        .await;
87
88    render_tabs(payment_summary, federations)
89}
90
91fn render_tabs(
92    summary: Result<PaymentSummaryResponse, impl std::fmt::Display>,
93    federations: &[FederationInfo],
94) -> Markup {
95    html! {
96        div class="card h-100" {
97            div class="card-header dashboard-header" {
98                ul class="nav nav-tabs card-header-tabs w-100" role="tablist" {
99                    li class="nav-item flex-fill text-center" {
100                        button
101                            class="nav-link active w-100"
102                            data-bs-toggle="tab"
103                            data-bs-target="#payment-summary"
104                            type="button"
105                        {
106                            "Summary"
107                        }
108                    }
109                    li class="nav-item flex-fill text-center" {
110                        button
111                            class="nav-link w-100"
112                            data-bs-toggle="tab"
113                            data-bs-target="#payment-log"
114                            type="button"
115                        {
116                            "Payment Events"
117                        }
118                    }
119                }
120            }
121
122            div class="card-body tab-content" {
123                div
124                    class="tab-pane fade show active"
125                    id="payment-summary"
126                {
127                    (render_summary_tab(summary))
128                }
129
130                div
131                    class="tab-pane fade"
132                    id="payment-log"
133                {
134                    (render_payment_log_tab_initial(federations))
135                }
136            }
137        }
138    }
139}
140
141fn render_summary_tab(summary: Result<PaymentSummaryResponse, impl std::fmt::Display>) -> Markup {
142    match summary {
143        Ok(summary) => render_summary_body(&summary),
144        Err(e) => html! {
145            div class="alert alert-danger mb-0" {
146                strong { "Failed to load payment summary: " }
147                (e.to_string())
148            }
149        },
150    }
151}
152
153fn render_summary_body(summary: &PaymentSummaryResponse) -> Markup {
154    html! {
155        div class="card h-100" {
156            div class="card-header dashboard-header" { "Payment Summary (Last 24h)" }
157            div class="card-body" {
158                div class="row" {
159                    div class="col-md-6" {
160                        (render_stats_table("Outgoing Payments", &summary.outgoing, "text-danger"))
161                    }
162                    div class="col-md-6" {
163                        (render_stats_table("Incoming Payments", &summary.incoming, "text-success"))
164                    }
165                }
166            }
167        }
168    }
169}
170
171fn render_stats_table(title: &str, stats: &PaymentStats, title_class: &str) -> Markup {
172    html! {
173        div {
174            h5 class=(format!("{} mb-3", title_class)) { (title) }
175
176            table class="table table-sm mb-0" {
177                tbody {
178                    tr {
179                        th { "✅ Total Success" }
180                        td { (stats.total_success) }
181                    }
182                    tr {
183                        th { "❌ Total Failure" }
184                        td { (stats.total_failure) }
185                    }
186                    tr {
187                        th { "💸 Total Fees" }
188                        td { (format!("{} msats", stats.total_fees.msats)) }
189                    }
190                    tr {
191                        th { "⚡ Average Latency" }
192                        td {
193                            (match stats.average_latency {
194                                Some(d) => format_duration(d),
195                                None => "—".into(),
196                            })
197                        }
198                    }
199                    tr {
200                        th { "📈 Median Latency" }
201                        td {
202                            (match stats.median_latency {
203                                Some(d) => format_duration(d),
204                                None => "—".into(),
205                            })
206                        }
207                    }
208                }
209            }
210        }
211    }
212}
213
214fn render_payment_log_tab_initial(federations: &[FederationInfo]) -> Markup {
215    html! {
216        div {
217            form id="payment-log-form" class="mb-3" {
218                div class="d-flex gap-2 align-items-end mb-2" {
219                    div class="flex-grow-1" {
220                        label class="form-label fw-bold" {
221                            "Federation"
222                        }
223
224                        select
225                            class="form-select form-select-sm"
226                            name="federation_id"
227                            hx-get=(PAYMENT_LOG_ROUTE)
228                            hx-trigger="change"
229                            hx-target="#payment-log-content"
230                            hx-include="#payment-log-form"
231                        {
232                            option value="" selected disabled {
233                                "Select a federation…"
234                            }
235
236                            @for fed in federations {
237                                option value=(fed.federation_id.to_string()) {
238                                    (fed.federation_name.clone().unwrap_or_default())
239                                }
240                            }
241                        }
242                    }
243
244                    button
245                        type="button"
246                        class="btn btn-outline-secondary btn-sm"
247                        title="Refresh payment log"
248                        hx-get=(PAYMENT_LOG_ROUTE)
249                        hx-target="#payment-log-content"
250                        hx-include="#payment-log-form"
251                    {
252                        "↻ Refresh"
253                    }
254                }
255
256                // Collapsible filter section
257                div {
258                    button
259                        type="button"
260                        class="btn btn-sm btn-outline-secondary"
261                        data-bs-toggle="collapse"
262                        data-bs-target="#event-filter-collapse"
263                        aria-expanded="false"
264                        aria-controls="event-filter-collapse"
265                    {
266                        "▼ Filter by Event Type"
267                    }
268
269                    div class="collapse mt-2" id="event-filter-collapse" {
270                        div class="card card-body" {
271                            // Lightning Events
272                            (render_event_category("Lightning", "lightning", LIGHTNING_EVENTS))
273
274                            // Wallet Events
275                            (render_event_category("Wallet", "wallet", WALLET_EVENTS))
276
277                            // E-cash Events
278                            (render_event_category("E-cash", "ecash", ECASH_EVENTS))
279
280                            // Apply Filters button
281                            div class="mt-3" {
282                                button
283                                    type="button"
284                                    class="btn btn-primary btn-sm"
285                                    hx-get=(PAYMENT_LOG_ROUTE)
286                                    hx-target="#payment-log-content"
287                                    hx-include="#payment-log-form"
288                                {
289                                    "Apply Filters"
290                                }
291                            }
292                        }
293                    }
294                }
295            }
296
297            div
298                id="payment-log-content"
299                class="mt-3"
300            {
301                div class="text-muted" {
302                    "Select a federation to view payment events."
303                }
304            }
305
306            // JavaScript for toggle all/none functionality
307            script {
308                (PreEscaped(r#"
309                function toggleEventGroup(group, checked) {
310                    document.querySelectorAll('.' + group + '-event').forEach(function(cb) {
311                        cb.checked = checked;
312                    });
313                }
314                "#))
315            }
316        }
317    }
318}
319
320/// Renders a category of event checkboxes
321fn render_event_category(title: &str, css_class: &str, events: &[(&str, EventKind)]) -> Markup {
322    html! {
323        div class="mb-3" {
324            div class="d-flex align-items-center gap-2 mb-2" {
325                strong { (title) }
326                button
327                    type="button"
328                    class="btn btn-outline-secondary btn-sm py-0 px-1"
329                    onclick=(format!("toggleEventGroup('{}', true)", css_class))
330                {
331                    "All"
332                }
333                button
334                    type="button"
335                    class="btn btn-outline-secondary btn-sm py-0 px-1"
336                    onclick=(format!("toggleEventGroup('{}', false)", css_class))
337                {
338                    "None"
339                }
340            }
341            div class="row" {
342                @for (label, kind) in events {
343                    div class="col-6 col-md-4" {
344                        div class="form-check" {
345                            input
346                                type="checkbox"
347                                class=(format!("form-check-input {}-event", css_class))
348                                name="event_kinds"
349                                value=(kind.to_string())
350                                checked;
351                            label class="form-check-label small" {
352                                (label)
353                            }
354                        }
355                    }
356                }
357            }
358        }
359    }
360}
361
362pub async fn payment_log_fragment_handler<E>(
363    State(state): State<UiState<DynGatewayApi<E>>>,
364    _auth: UserAuth,
365    RawQuery(raw_query): RawQuery,
366    Query(params): Query<PaymentLogQueryParams>,
367) -> Html<String>
368where
369    E: std::fmt::Display + std::fmt::Debug,
370{
371    let federation_id = match &params.federation_id {
372        Some(v) => match v.parse::<FederationId>() {
373            Ok(id) => id,
374            Err(_) => {
375                return Html(
376                    html! {
377                        div class="alert alert-danger mb-0" { "Invalid federation ID." }
378                    }
379                    .into_string(),
380                );
381            }
382        },
383        None => {
384            return Html(
385                html! {
386                    div class="alert alert-warning mb-0" { "No federation selected." }
387                }
388                .into_string(),
389            );
390        }
391    };
392
393    let pagination_size = 10;
394
395    // Parse event_kinds from raw query string to handle repeated params
396    // serde_urlencoded doesn't properly deserialize repeated params into Vec
397    let event_kinds: Vec<EventKind> = parse_event_kinds_from_query(raw_query.as_deref());
398
399    let result = state
400        .api
401        .handle_payment_log_msg(PaymentLogPayload {
402            end_position: params.end_position,
403            pagination_size,
404            federation_id,
405            event_kinds: event_kinds.clone(),
406        })
407        .await;
408
409    Html(render_payment_log_result(&result, federation_id, &event_kinds).into_string())
410}
411
412/// Parse event_kinds from raw query string, handling repeated params
413fn parse_event_kinds_from_query(query: Option<&str>) -> Vec<EventKind> {
414    let Some(query) = query else {
415        return vec![];
416    };
417
418    url::form_urlencoded::parse(query.as_bytes())
419        .filter_map(|(key, value)| {
420            if key == "event_kinds" || key == "event_kinds[]" {
421                Some(EventKind::from(value.into_owned()))
422            } else {
423                None
424            }
425        })
426        .collect()
427}
428
429fn render_payment_log_result<E>(
430    result: &Result<PaymentLogResponse, E>,
431    federation_id: FederationId,
432    event_kinds: &[EventKind],
433) -> Markup
434where
435    E: std::fmt::Display,
436{
437    // Convert event kinds to strings for JSON serialization
438    let event_kinds_strings: Vec<String> = event_kinds.iter().map(ToString::to_string).collect();
439
440    match result {
441        Ok(PaymentLogResponse(entries)) if !entries.is_empty() => {
442            // Compute next end_position as last entry position - 1
443            let next_end_position = entries.last().expect("Cannot be empty").id().checked_sub(1);
444
445            html! {
446                div {
447                    table class="table table-sm table-hover mb-2" {
448                        thead {
449                            tr {
450                                th { "Event Kind" }
451                                th { "Timestamp" }
452                                th { "Details" }
453                            }
454                        }
455                        tbody {
456                            @for (idx, entry) in entries.iter().enumerate() {
457                                tr {
458                                    td { code { (entry.as_raw().kind) } }
459                                    td { (format_timestamp(entry.as_raw().ts_usecs)) }
460                                    td {
461                                        button
462                                            class="btn btn-sm btn-outline-secondary"
463                                            type="button"
464                                            onclick=(format!(
465                                                "document.getElementById('payment-details-{}').classList.toggle('d-none');",
466                                                idx
467                                            ))
468                                        {
469                                            "Details"
470                                        }
471                                    }
472                                }
473
474                                tr id=(format!("payment-details-{}", idx)) class="d-none" {
475                                    td colspan="3" {
476                                        pre class="bg-dark text-light p-3 rounded small mb-0" {
477                                            (serde_json::to_string_pretty(entry).unwrap_or_else(|_| "<invalid json>".to_string()))
478                                        }
479                                    }
480                                }
481                            }
482                        }
483                    }
484
485                    @if let Some(next_pos) = next_end_position {
486                        div class="d-flex justify-content-end" {
487                            button
488                                class="btn btn-sm btn-outline-primary"
489                                type="button"
490                                hx-get=(PAYMENT_LOG_ROUTE)
491                                hx-target="#payment-log-content"
492                                hx-include="closest form"
493                                hx-vals=(serde_json::json!({
494                                    "federation_id": federation_id.to_string(),
495                                    "end_position": next_pos,
496                                    "event_kinds": event_kinds_strings
497                                }))
498                            {
499                                "Next"
500                            }
501                        }
502                    }
503                }
504            }
505        }
506        Ok(_) => html! {
507            div class="text-muted" { "No payment events found for this federation." }
508        },
509        Err(e) => html! {
510            div class="alert alert-danger mb-0" {
511                strong { "Failed to load payment log: " }
512                (e.to_string())
513            }
514        },
515    }
516}
517
518fn format_timestamp(ts_usecs: u64) -> String {
519    let secs = ts_usecs / 1_000_000;
520    let nanos = (ts_usecs % 1_000_000) * 1_000;
521
522    let ts = UNIX_EPOCH + Duration::new(secs, nanos as u32);
523    let dt: chrono::DateTime<chrono::Utc> = ts.into();
524
525    dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()
526}
527
528fn format_duration(d: Duration) -> String {
529    if d.as_secs() > 0 {
530        format!("{:.2}s", d.as_secs_f64())
531    } else {
532        format!("{} ms", d.as_millis())
533    }
534}