fedimint_server_ui/dashboard/
mod.rs1pub mod audit;
2pub mod bitcoin;
3pub(crate) mod consensus_explorer;
4pub mod general;
5pub mod invite;
6pub mod latency;
7pub mod modules;
8
9use axum::Router;
10use axum::body::Body;
11use axum::extract::{Form, State};
12use axum::http::{StatusCode, header};
13use axum::response::{Html, IntoResponse, Response};
14use axum::routing::{get, post};
15use axum_extra::extract::cookie::CookieJar;
16use consensus_explorer::consensus_explorer_view;
17use fedimint_metrics::{Encoder, REGISTRY, TextEncoder};
18use fedimint_server_core::dashboard_ui::{DashboardApiModuleExt, DynDashboardApi};
19use fedimint_ui_common::assets::WithStaticRoutesExt;
20use fedimint_ui_common::auth::UserAuth;
21use fedimint_ui_common::{
22 CONNECTIVITY_CHECK_ROUTE, LOGIN_ROUTE, ROOT_ROUTE, UiState, connectivity_check_handler,
23 dashboard_layout, login_form_response,
24};
25use maud::html;
26use {
27 fedimint_lnv2_server, fedimint_meta_server, fedimint_mintv2_server, fedimint_wallet_server,
28 fedimint_walletv2_server,
29};
30
31use crate::dashboard::modules::{lnv2, meta, mintv2, wallet, walletv2};
32use crate::{
33 CHANGE_PASSWORD_ROUTE, DOWNLOAD_BACKUP_ROUTE, EXPLORER_IDX_ROUTE, EXPLORER_ROUTE, LoginInput,
34 METRICS_ROUTE, login_submit_response,
35};
36
37async fn login_form(State(_state): State<UiState<DynDashboardApi>>) -> impl IntoResponse {
39 login_form_response("Fedimint Guardian Login")
40}
41
42async fn login_submit(
44 State(state): State<UiState<DynDashboardApi>>,
45 jar: CookieJar,
46 Form(input): Form<LoginInput>,
47) -> impl IntoResponse {
48 login_submit_response(
49 state.api.auth().await,
50 state.auth_cookie_name,
51 state.auth_cookie_value,
52 jar,
53 input,
54 )
55 .into_response()
56}
57
58async fn download_backup(
60 State(state): State<UiState<DynDashboardApi>>,
61 user_auth: UserAuth,
62) -> impl IntoResponse {
63 let api_auth = state.api.auth().await;
64 let backup = state
65 .api
66 .download_guardian_config_backup(api_auth.as_str(), &user_auth.guardian_auth_token)
67 .await;
68 let filename = "guardian-backup.tar";
69
70 Response::builder()
71 .header(header::CONTENT_TYPE, "application/x-tar")
72 .header(
73 header::CONTENT_DISPOSITION,
74 format!("attachment; filename=\"{filename}\""),
75 )
76 .body(Body::from(backup.tar_archive_bytes))
77 .expect("Failed to build response")
78}
79
80async fn metrics_handler(_user_auth: UserAuth) -> impl IntoResponse {
82 let metric_families = REGISTRY.gather();
83 let result = || -> Result<String, Box<dyn std::error::Error>> {
84 let mut buffer = Vec::new();
85 let encoder = TextEncoder::new();
86 encoder.encode(&metric_families, &mut buffer)?;
87 Ok(String::from_utf8(buffer)?)
88 };
89 match result() {
90 Ok(metrics) => (
91 StatusCode::OK,
92 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
93 metrics,
94 )
95 .into_response(),
96 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")).into_response(),
97 }
98}
99
100async fn change_password(
102 State(state): State<UiState<DynDashboardApi>>,
103 user_auth: UserAuth,
104 Form(input): Form<crate::PasswordChangeInput>,
105) -> impl IntoResponse {
106 let api_auth = state.api.auth().await;
107
108 if !api_auth.verify(&input.current_password) {
110 let content = html! {
111 div class="alert alert-danger" { "Current password is incorrect" }
112 div class="button-container" {
113 a href="/" class="btn btn-primary" { "Return to Dashboard" }
114 }
115 };
116 return Html(dashboard_layout(content, "Password Change Failed", None).into_string())
117 .into_response();
118 }
119
120 if input.new_password != input.confirm_password {
122 let content = html! {
123 div class="alert alert-danger" { "New passwords do not match" }
124 div class="button-container" {
125 a href="/" class="btn btn-primary" { "Return to Dashboard" }
126 }
127 };
128 return Html(dashboard_layout(content, "Password Change Failed", None).into_string())
129 .into_response();
130 }
131
132 if input.new_password.is_empty() {
134 let content = html! {
135 div class="alert alert-danger" { "New password cannot be empty" }
136 div class="button-container" {
137 a href="/" class="btn btn-primary" { "Return to Dashboard" }
138 }
139 };
140 return Html(dashboard_layout(content, "Password Change Failed", None).into_string())
141 .into_response();
142 }
143
144 match state
146 .api
147 .change_password(
148 &input.new_password,
149 &input.current_password,
150 &user_auth.guardian_auth_token,
151 )
152 .await
153 {
154 Ok(()) => {
155 let content = html! {
156 div class="alert alert-success" {
157 "Password changed successfully! The server will restart now."
158 }
159 div class="alert alert-info" {
160 "You will need to log in again with your new password once the server restarts."
161 }
162 div class="button-container" {
163 a href="/login" class="btn btn-primary" { "Go to Login" }
164 }
165 };
166 Html(dashboard_layout(content, "Password Changed", None).into_string()).into_response()
167 }
168 Err(err) => {
169 let content = html! {
170 div class="alert alert-danger" {
171 "Failed to change password: " (err)
172 }
173 div class="button-container" {
174 a href="/" class="btn btn-primary" { "Return to Dashboard" }
175 }
176 };
177 Html(dashboard_layout(content, "Password Change Failed", None).into_string())
178 .into_response()
179 }
180 }
181}
182
183async fn dashboard_view(
185 State(state): State<UiState<DynDashboardApi>>,
186 _auth: UserAuth,
187) -> impl IntoResponse {
188 let guardian_names = state.api.guardian_names().await;
189 let federation_name = state.api.federation_name().await;
190 let session_count = state.api.session_count().await;
191 let fedimintd_version = state.api.fedimintd_version().await;
192 let consensus_ord_latency = state.api.consensus_ord_latency().await;
193 let p2p_connection_status = state.api.p2p_connection_status().await;
194 let invite_code = state.api.federation_invite_code().await;
195 let audit_summary = state.api.federation_audit().await;
196 let bitcoin_rpc_url = state.api.bitcoin_rpc_url().await;
197 let bitcoin_rpc_status = state.api.bitcoin_rpc_status().await;
198
199 let content = html! {
200 div class="row gy-4" {
201 div class="col-md-6" {
202 (general::render(&federation_name, session_count, &guardian_names))
203 }
204
205 div class="col-md-6" {
206 (invite::render(&invite_code, session_count))
207 }
208 }
209
210 div class="row gy-4 mt-2" {
211 div class="col-lg-6" {
212 (audit::render(&audit_summary))
213 }
214
215 div class="col-lg-6" {
216 (latency::render(consensus_ord_latency, &p2p_connection_status))
217 }
218 }
219
220 div class="row gy-4 mt-2" {
221 div class="col-12" {
222 (bitcoin::render(bitcoin_rpc_url, &bitcoin_rpc_status))
223 }
224 }
225
226 @if let Some(lightning) = state.api.get_module::<fedimint_lnv2_server::Lightning>() {
228 div class="row gy-4 mt-2" {
229 div class="col-12" {
230 (lnv2::render(lightning).await)
231 }
232 }
233 }
234
235 @if let Some(walletv2_module) = state.api.get_module::<fedimint_walletv2_server::Wallet>() {
237 (walletv2::render(walletv2_module).await)
238 }
239
240 @if let Some(mint_module) = state.api.get_module::<fedimint_mintv2_server::Mint>() {
242 div class="row gy-4 mt-2" {
243 div class="col-12" {
244 (mintv2::render(mint_module).await)
245 }
246 }
247 }
248
249 @if let Some(wallet_module) = state.api.get_module::<fedimint_wallet_server::Wallet>() {
251 div class="row gy-4 mt-2" {
252 div class="col-12" {
253 (wallet::render(wallet_module).await)
254 }
255 }
256 }
257
258 @if let Some(meta_module) = state.api.get_module::<fedimint_meta_server::Meta>() {
260 div class="row gy-4 mt-2" {
261 div class="col-12" {
262 (meta::render(meta_module).await)
263 }
264 }
265 }
266
267 div class="row gy-4 mt-4" {
269 div class="col-12" {
270 div class="card" {
271 div class="card-header bg-warning text-dark" {
272 h5 class="mb-0" { "Guardian Configuration Backup" }
273 }
274 div class="card-body" {
275 div class="row" {
276 div class="col-lg-6 mb-3 mb-lg-0" {
277 p {
278 "You only need to download this backup once."
279 }
280 p {
281 "Use it to restore your guardian if your server fails."
282 }
283 a href="/download-backup" class="btn btn-outline-warning btn-lg mt-2" {
284 "Download Guardian Backup"
285 }
286 }
287 div class="col-lg-6" {
288 div class="alert alert-warning mb-0" {
289 strong { "Security Warning" }
290 br;
291 "Store this file securely since anyone with it and your password can run your guardian node."
292 }
293 }
294 }
295 }
296 }
297 }
298 }
299
300 div class="row gy-4 mt-4" {
302 div class="col-12" {
303 div class="card" {
304 div class="card-header bg-info text-white" {
305 h5 class="mb-0" { "Change Guardian Password" }
306 }
307 div class="card-body" {
308 form method="post" action="/change-password" {
309 div class="row" {
310 div class="col-lg-6 mb-3" {
311 div class="form-group mb-3" {
312 label for="current_password" class="form-label" { "Current Password" }
313 input type="password" class="form-control" id="current_password" name="current_password" placeholder="Enter current password" required;
314 }
315 div class="form-group mb-3" {
316 label for="new_password" class="form-label" { "New Password" }
317 input type="password" class="form-control" id="new_password" name="new_password" placeholder="Enter new password" required;
318 }
319 div class="form-group mb-3" {
320 label for="confirm_password" class="form-label" { "Confirm New Password" }
321 input type="password" class="form-control" id="confirm_password" name="confirm_password" placeholder="Confirm new password" required;
322 }
323 button type="submit" class="btn btn-info btn-lg mt-2" {
324 "Change Password"
325 }
326 }
327 div class="col-lg-6" {
328 div class="alert alert-info mb-0" {
329 strong { "Important" }
330 br;
331 "After changing your password, you will need to log in again with the new password."
332 }
333 div class="alert alert-info mt-3" {
334 strong { "Just so you know" }
335 br;
336 "Fedimint instance will shut down and might require a manual restart (depending how it's run)"
337 }
338 }
339 }
340 }
341 }
342 }
343 }
344 }
345 };
346
347 Html(dashboard_layout(content, "Fedimint Guardian UI", Some(&fedimintd_version)).into_string())
348 .into_response()
349}
350
351pub fn router(api: DynDashboardApi) -> Router {
352 let mut app = Router::new()
353 .route(ROOT_ROUTE, get(dashboard_view))
354 .route(LOGIN_ROUTE, get(login_form).post(login_submit))
355 .route(EXPLORER_ROUTE, get(consensus_explorer_view))
356 .route(EXPLORER_IDX_ROUTE, get(consensus_explorer_view))
357 .route(DOWNLOAD_BACKUP_ROUTE, get(download_backup))
358 .route(CHANGE_PASSWORD_ROUTE, post(change_password))
359 .route(METRICS_ROUTE, get(metrics_handler))
360 .route(
361 CONNECTIVITY_CHECK_ROUTE,
362 get(connectivity_check_handler::<DynDashboardApi>),
363 )
364 .with_static_routes();
365
366 if api
368 .get_module::<fedimint_lnv2_server::Lightning>()
369 .is_some()
370 {
371 app = app
372 .route(lnv2::LNV2_ADD_ROUTE, post(lnv2::post_add))
373 .route(lnv2::LNV2_REMOVE_ROUTE, post(lnv2::post_remove));
374 }
375
376 if api.get_module::<fedimint_meta_server::Meta>().is_some() {
378 app = app
379 .route(meta::META_SUBMIT_ROUTE, post(meta::post_submit))
380 .route(meta::META_SET_ROUTE, post(meta::post_set))
381 .route(meta::META_RESET_ROUTE, post(meta::post_reset))
382 .route(meta::META_DELETE_ROUTE, post(meta::post_delete))
383 .route(meta::META_MERGE_ROUTE, post(meta::post_merge))
384 .route(meta::META_VALUE_INPUT_ROUTE, get(meta::get_value_input));
385 }
386
387 app.with_state(UiState::new(api))
389}