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
27const 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
41const WALLET_EVENTS: &[(&str, EventKind)] = &[
43 ("Withdraw Request", WithdrawRequest::KIND),
44 ("Deposit Confirmed", DepositConfirmed::KIND),
45];
46
47const ECASH_EVENTS: &[(&str, EventKind)] = &[
49 ("Notes Spent", OOBNotesSpent::KIND),
50 ("Notes Reissued", OOBNotesReissued::KIND),
51];
52
53#[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 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 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 (render_event_category("Lightning", "lightning", LIGHTNING_EVENTS))
273
274 (render_event_category("Wallet", "wallet", WALLET_EVENTS))
276
277 (render_event_category("E-cash", "ecash", ECASH_EVENTS))
279
280 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 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
320fn 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 ¶ms.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 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
412fn 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 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 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}