fedimint_server_ui/
dashboard.rs

1use std::future::Future;
2use std::net::SocketAddr;
3use std::pin::Pin;
4
5use axum::Router;
6use axum::extract::{Form, State};
7use axum::response::{Html, IntoResponse, Redirect};
8use axum::routing::{get, post};
9use axum_extra::extract::cookie::CookieJar;
10use fedimint_core::task::TaskHandle;
11use fedimint_server_core::dashboard_ui::{DashboardApiModuleExt, DynDashboardApi};
12use maud::{DOCTYPE, Markup, html};
13use tokio::net::TcpListener;
14use {fedimint_lnv2_server, fedimint_meta_server, fedimint_wallet_server};
15
16use crate::assets::WithStaticRoutesExt as _;
17use crate::layout::{self};
18use crate::{
19    AuthState, LoginInput, audit, check_auth, invite_code, latency, lnv2, login_form_response,
20    login_submit_response, meta, wallet,
21};
22
23pub fn dashboard_layout(content: Markup) -> Markup {
24    html! {
25        (DOCTYPE)
26        html {
27            head {
28                (layout::common_head("Dashboard"))
29            }
30            body {
31                div class="container" style="max-width: 66%;" {
32                    header class="text-center" {
33                        h1 class="header-title" { "Fedimint Guardian UI" }
34                    }
35
36                    (content)
37                }
38                script src="/assets/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
39            }
40        }
41    }
42}
43
44// Dashboard login form handler
45async fn login_form(State(_state): State<AuthState<DynDashboardApi>>) -> impl IntoResponse {
46    login_form_response()
47}
48
49// Dashboard login submit handler
50async fn login_submit(
51    State(state): State<AuthState<DynDashboardApi>>,
52    jar: CookieJar,
53    Form(input): Form<LoginInput>,
54) -> impl IntoResponse {
55    login_submit_response(
56        state.api.auth().await,
57        state.auth_cookie_name,
58        state.auth_cookie_value,
59        jar,
60        input,
61    )
62    .into_response()
63}
64
65fn render_session_count(session_count: usize) -> Markup {
66    html! {
67
68        div id="session-count" class="alert alert-info" hx-swap-oob=(true) {
69            "Session Count: " strong { (session_count) }
70        }
71    }
72}
73// Main dashboard view
74async fn dashboard_view(
75    State(state): State<AuthState<DynDashboardApi>>,
76    jar: CookieJar,
77) -> impl IntoResponse {
78    if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
79        return Redirect::to("/login").into_response();
80    }
81
82    let guardian_names = state.api.guardian_names().await;
83    let federation_name = state.api.federation_name().await;
84    let session_count = state.api.session_count().await;
85    let consensus_ord_latency = state.api.consensus_ord_latency().await;
86    let p2p_connection_status = state.api.p2p_connection_status().await;
87    let invite_code = state.api.federation_invite_code().await;
88    let audit_summary = state.api.federation_audit().await;
89
90    // Conditionally add Lightning V2 UI if the module is available
91    let lightning_content = html! {
92        @if let Some(lightning) = state.api.get_module::<fedimint_lnv2_server::Lightning>() {
93            (lnv2::render(lightning).await)
94        }
95    };
96
97    // Conditionally add Wallet UI if the module is available
98    let wallet_content = html! {
99        @if let Some(wallet_module) = state.api.get_module::<fedimint_wallet_server::Wallet>() {
100            (wallet::render(wallet_module).await)
101        }
102    };
103
104    // Conditionally add Meta UI if the module is available
105    let meta_content = html! {
106        @if let Some(meta_module) = state.api.get_module::<fedimint_meta_server::Meta>() {
107            (meta::render(meta_module).await)
108        }
109    };
110
111    let content = html! {
112        div class="row gy-4" {
113            div class="col-md-6" {
114                div class="card h-100" {
115                    div class="card-header dashboard-header" { (federation_name) }
116                    div class="card-body" {
117                        (render_session_count(session_count))
118                        table class="table table-sm mb-0" {
119                            thead {
120                                tr {
121                                    th { "Guardian ID" }
122                                    th { "Guardian Name" }
123                                }
124                            }
125                            tbody {
126                                @for (guardian_id, name) in guardian_names {
127                                    tr {
128                                        td { (guardian_id.to_string()) }
129                                        td { (name) }
130                                    }
131                                }
132                            }
133                        }
134                    }
135                }
136            }
137
138            // Invite Code Column
139            div class="col-md-6" {
140                (invite_code::invite_code_card())
141            }
142        }
143
144        // Render the invite code modal
145        (invite_code::invite_code_modal(&invite_code))
146
147        // Second row: Audit Summary and Peer Status
148        div class="row gy-4 mt-2" {
149            // Audit Information Column
150            div class="col-lg-6" {
151                (audit::render(&audit_summary))
152            }
153
154            // Peer Connection Status Column
155            div class="col-lg-6" {
156                (latency::render(consensus_ord_latency, &p2p_connection_status))
157            }
158        }
159
160        (lightning_content)
161        (wallet_content)
162        (meta_content)
163
164        // Every 15s fetch updates to the page
165        div hx-get="/dashboard/update" hx-trigger="every 15s" hx-swap="none" { }
166    };
167
168    Html(dashboard_layout(content).into_string()).into_response()
169}
170
171/// Periodic updated to the dashboard
172///
173/// We don't just replace the whole page, not to interfere with elements that
174/// might not like it.
175async fn dashboard_update(
176    State(state): State<AuthState<DynDashboardApi>>,
177    jar: CookieJar,
178) -> impl IntoResponse {
179    if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
180        return Redirect::to("/login").into_response();
181    }
182
183    let session_count = state.api.session_count().await;
184    let consensus_ord_latency = state.api.consensus_ord_latency().await;
185    let p2p_connection_status = state.api.p2p_connection_status().await;
186
187    // each element has an `id` and `hx-swap-oob=true` which on htmx requests
188    // make them update themselves.
189    let content = html! {
190        (render_session_count(session_count))
191
192        (latency::render(consensus_ord_latency, &p2p_connection_status))
193
194        @if let Some(lightning) = state.api.get_module::<fedimint_lnv2_server::Lightning>() {
195            (lnv2::render(lightning).await)
196        }
197    };
198
199    Html(content.into_string()).into_response()
200}
201
202pub fn start(
203    api: DynDashboardApi,
204    ui_bind: SocketAddr,
205    task_handle: TaskHandle,
206) -> Pin<Box<dyn Future<Output = ()> + Send>> {
207    // Create a basic router with core routes
208    let mut app = Router::new()
209        .route("/", get(dashboard_view))
210        .route("/dashboard/update", get(dashboard_update))
211        .route("/login", get(login_form).post(login_submit))
212        .with_static_routes();
213
214    // Only add LNv2 gateway routes if the module exists
215    if api
216        .get_module::<fedimint_lnv2_server::Lightning>()
217        .is_some()
218    {
219        app = app
220            .route("/lnv2_gateway_add", post(lnv2::add_gateway))
221            .route("/lnv2_gateway_remove", post(lnv2::remove_gateway));
222    }
223
224    // Only add Meta module routes if the module exists
225    if api.get_module::<fedimint_meta_server::Meta>().is_some() {
226        app = app.route("/meta/submit", post(meta::submit_meta_value))
227    }
228
229    // Finalize the router with state
230    let app = app.with_state(AuthState::new(api));
231
232    Box::pin(async move {
233        let listener = TcpListener::bind(ui_bind)
234            .await
235            .expect("Failed to bind dashboard UI");
236
237        axum::serve(listener, app.into_make_service())
238            .with_graceful_shutdown(task_handle.make_shutdown_rx())
239            .await
240            .expect("Failed to serve dashboard UI");
241    })
242}