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