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,
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() -> impl IntoResponse {
39 Html(single_card_layout("Enter Password", login_form(None)).into_string())
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}
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.as_str(), &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.verify(&input.current_password) {
109 let content = html! {
110 div class="alert alert-danger" { "Current password is incorrect" }
111 a href="/" class="btn btn-primary w-100 py-2" { "Return to Dashboard" }
112 };
113 return Html(single_card_layout("Password Change Failed", content).into_string())
114 .into_response();
115 }
116
117 if input.new_password != input.confirm_password {
119 let content = html! {
120 div class="alert alert-danger" { "New passwords do not match" }
121 a href="/" class="btn btn-primary w-100 py-2" { "Return to Dashboard" }
122 };
123 return Html(single_card_layout("Password Change Failed", content).into_string())
124 .into_response();
125 }
126
127 if input.new_password.is_empty() {
129 let content = html! {
130 div class="alert alert-danger" { "New password cannot be empty" }
131 a href="/" class="btn btn-primary w-100 py-2" { "Return to Dashboard" }
132 };
133 return Html(single_card_layout("Password Change Failed", content).into_string())
134 .into_response();
135 }
136
137 match state
139 .api
140 .change_password(
141 &input.new_password,
142 &input.current_password,
143 &user_auth.guardian_auth_token,
144 )
145 .await
146 {
147 Ok(()) => {
148 let content = html! {
149 div class="alert alert-success" {
150 "Password changed successfully! The server will restart now."
151 }
152 div class="alert alert-info" {
153 "You will need to log in again with your new password once the server restarts."
154 }
155 a href="/login" class="btn btn-primary w-100 py-2" { "Go to Login" }
156 };
157 Html(single_card_layout("Password Changed", content).into_string()).into_response()
158 }
159 Err(err) => {
160 let content = html! {
161 div class="alert alert-danger" {
162 "Failed to change password: " (err)
163 }
164 a href="/" class="btn btn-primary w-100 py-2" { "Return to Dashboard" }
165 };
166 Html(single_card_layout("Password Change Failed", content).into_string())
167 .into_response()
168 }
169 }
170}
171
172async fn dashboard_view(
174 State(state): State<UiState<DynDashboardApi>>,
175 _auth: UserAuth,
176) -> impl IntoResponse {
177 let guardian_names = state.api.guardian_names().await;
178 let federation_name = state.api.federation_name().await;
179 let session_count = state.api.session_count().await;
180 let fedimintd_version = state.api.fedimintd_version().await;
181 let consensus_ord_latency = state.api.consensus_ord_latency().await;
182 let p2p_connection_status = state.api.p2p_connection_status().await;
183 let invite_code = state.api.federation_invite_code().await;
184 let audit_summary = state.api.federation_audit().await;
185 let bitcoin_rpc_url = state.api.bitcoin_rpc_url().await;
186 let bitcoin_rpc_status = state.api.bitcoin_rpc_status().await;
187
188 let content = html! {
189 div class="row gy-4" {
190 div class="col-md-6" {
191 (general::render(&federation_name, session_count, &guardian_names))
192 }
193
194 div class="col-md-6" {
195 (invite::render(&invite_code, session_count))
196 }
197 }
198
199 div class="row gy-4 mt-2" {
200 div class="col-lg-6" {
201 (audit::render(&audit_summary))
202 }
203
204 div class="col-lg-6" {
205 (latency::render(consensus_ord_latency, &p2p_connection_status))
206 }
207 }
208
209 div class="row gy-4 mt-2" {
210 div class="col-12" {
211 (bitcoin::render(bitcoin_rpc_url, &bitcoin_rpc_status))
212 }
213 }
214
215 @if let Some(lightning) = state.api.get_module::<fedimint_lnv2_server::Lightning>() {
217 div class="row gy-4 mt-2" {
218 div class="col-12" {
219 (lnv2::render(lightning).await)
220 }
221 }
222 }
223
224 @if let Some(walletv2_module) = state.api.get_module::<fedimint_walletv2_server::Wallet>() {
226 (walletv2::render(walletv2_module).await)
227 }
228
229 @if let Some(mint_module) = state.api.get_module::<fedimint_mintv2_server::Mint>() {
231 div class="row gy-4 mt-2" {
232 div class="col-12" {
233 (mintv2::render(mint_module).await)
234 }
235 }
236 }
237
238 @if let Some(wallet_module) = state.api.get_module::<fedimint_wallet_server::Wallet>() {
240 div class="row gy-4 mt-2" {
241 div class="col-12" {
242 (wallet::render(wallet_module).await)
243 }
244 }
245 }
246
247 @if let Some(meta_module) = state.api.get_module::<fedimint_meta_server::Meta>() {
249 div class="row gy-4 mt-2" {
250 div class="col-12" {
251 (meta::render(meta_module).await)
252 }
253 }
254 }
255
256 div class="row gy-4 mt-2" {
258 div class="col-lg-6" {
259 div class="card h-100" {
260 div class="card-header dashboard-header" { "Guardian Backup" }
261 div class="card-body" {
262 div class="alert alert-warning mb-3" {
263 "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."
264 }
265 a href="/download-backup" class="btn btn-primary" {
266 "Download"
267 }
268 }
269 }
270 }
271
272 div class="col-lg-6" {
273 div class="card h-100" {
274 div class="card-header dashboard-header" { "Change Password" }
275 div class="card-body" {
276 div class="alert alert-warning mb-3" {
277 "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."
278 }
279 form method="post" action="/change-password" {
280 div class="form-group mb-3" {
281 label for="current_password" class="form-label" { "Current Password" }
282 input type="password" class="form-control" id="current_password" name="current_password" placeholder="Enter current password" required;
283 }
284 div class="form-group mb-3" {
285 label for="new_password" class="form-label" { "New Password" }
286 input type="password" class="form-control" id="new_password" name="new_password" placeholder="Enter new password" required;
287 }
288 div class="form-group mb-3" {
289 label for="confirm_password" class="form-label" { "Confirm New Password" }
290 input type="password" class="form-control" id="confirm_password" name="confirm_password" placeholder="Confirm new password" required;
291 }
292 button type="submit" class="btn btn-primary" {
293 "Change Password"
294 }
295 }
296 }
297 }
298 }
299 }
300 };
301
302 Html(dashboard_layout(content, &fedimintd_version).into_string()).into_response()
303}
304
305pub fn router(api: DynDashboardApi) -> Router {
306 let mut app = Router::new()
307 .route(ROOT_ROUTE, get(dashboard_view))
308 .route(LOGIN_ROUTE, get(login_form_handler).post(login_submit))
309 .route(EXPLORER_ROUTE, get(consensus_explorer_view))
310 .route(EXPLORER_IDX_ROUTE, get(consensus_explorer_view))
311 .route(DOWNLOAD_BACKUP_ROUTE, get(download_backup))
312 .route(CHANGE_PASSWORD_ROUTE, post(change_password))
313 .route(METRICS_ROUTE, get(metrics_handler))
314 .route(
315 CONNECTIVITY_CHECK_ROUTE,
316 get(connectivity_check_handler::<DynDashboardApi>),
317 )
318 .with_static_routes();
319
320 if api
322 .get_module::<fedimint_lnv2_server::Lightning>()
323 .is_some()
324 {
325 app = app
326 .route(lnv2::LNV2_ADD_ROUTE, post(lnv2::post_add))
327 .route(lnv2::LNV2_REMOVE_ROUTE, post(lnv2::post_remove));
328 }
329
330 if api.get_module::<fedimint_meta_server::Meta>().is_some() {
332 app = app
333 .route(meta::META_SUBMIT_ROUTE, post(meta::post_submit))
334 .route(meta::META_SET_ROUTE, post(meta::post_set))
335 .route(meta::META_RESET_ROUTE, post(meta::post_reset))
336 .route(meta::META_DELETE_ROUTE, post(meta::post_delete))
337 .route(meta::META_MERGE_ROUTE, post(meta::post_merge))
338 .route(meta::META_VALUE_INPUT_ROUTE, get(meta::get_value_input));
339 }
340
341 app.with_state(UiState::new(api))
343}