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