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