fedimint_server_ui/dashboard/modules/
meta.rs1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2
3use axum::extract::{Form, FromRequest, State};
4use axum::http::StatusCode;
5use axum::response::{Html, IntoResponse, Response};
6use fedimint_core::PeerId;
7use fedimint_core::module::serde_json::{self, Value};
8use fedimint_meta_server::Meta;
9use fedimint_server_core::dashboard_ui::{DashboardApiModuleExt, DynDashboardApi};
10use fedimint_ui_common::UiState;
11use fedimint_ui_common::auth::UserAuth;
12use maud::{Markup, html};
13use serde::Serialize;
14use thiserror::Error;
15use tracing::{debug, warn};
16
17use crate::LOG_UI;
18
19pub const META_SUBMIT_ROUTE: &str = "/meta/submit";
21pub const META_SET_ROUTE: &str = "/meta/set";
22pub const META_RESET_ROUTE: &str = "/meta/reset";
23pub const META_DELETE_ROUTE: &str = "/meta/delete";
24
25pub async fn render(meta: &Meta) -> Markup {
27 let consensus_value = meta.handle_get_consensus_request_ui().await.ok().flatten();
29 let revision = meta
31 .handle_get_consensus_revision_request_ui()
32 .await
33 .ok()
34 .unwrap_or(0);
35 let submissions = meta
37 .handle_get_submissions_request_ui()
38 .await
39 .ok()
40 .unwrap_or_default();
41
42 let current_meta_keys = if let Some(o) = submissions
43 .get(&meta.our_peer_id)
44 .cloned()
45 .or_else(|| consensus_value.clone())
46 .and_then(|v| v.as_object().cloned())
47 {
48 o
49 } else {
50 serde_json::Map::new()
51 };
52
53 html! {
54 div class="card h-100" {
55 div class="card-header dashboard-header" { "Meta Configuration" }
56 div class="card-body" {
57 div class="mb-4" {
58 h5 { "Current Consensus (Revision: " (revision) ")" }
59 @if let Some(value) = &consensus_value {
60 pre class="bg-light p-3 user-select-all" {
61 code {
62 (serde_json::to_string_pretty(value).unwrap_or_else(|_| "Invalid JSON".to_string()))
63 }
64 }
65 } @else {
66 div class="alert alert-secondary" { "No consensus value has been established yet." }
67 }
68 div class="mb-4" {
69 (render_meta_edit_form(current_meta_keys, false, MetaEditForm::default()))
70 }
71
72 (render_submissions_form(meta.our_peer_id, &submissions))
73 }
74 }
75 }
76 }
77}
78
79fn render_submissions_form(our_id: PeerId, submissions: &BTreeMap<PeerId, Value>) -> Markup {
80 let mut submissions_by_value: HashMap<String, BTreeSet<PeerId>> = HashMap::new();
81
82 for (peer_id, value) in submissions {
83 let value_str =
84 serde_json::to_string_pretty(value).unwrap_or_else(|_| "Invalid JSON".to_string());
85 submissions_by_value
86 .entry(value_str)
87 .or_default()
88 .insert(*peer_id);
89 }
90
91 html! {
92 div #meta-submissions hx-swap-oob=(true) {
93 @if !submissions.is_empty() {
94 h5 { "Current Peer Submissions" }
95 div class="table-responsive" {
96 table class="table table-sm" {
97 thead {
98 tr {
99 th { "Peer IDs" }
100 th { "Submission" }
101 th { "Actions" }
102 }
103 }
104 tbody {
105 @for (value_str, peer_ids) in submissions_by_value {
106 tr {
107 td { (
108 peer_ids.iter()
109 .map(|n| n.to_string())
110 .collect::<Vec<String>>()
111 .join(", "))
112 }
113 td {
114 pre class="m-0 p-2 bg-light" style="max-height: 150px; overflow-y: auto;" {
115 code {
116 (value_str)
117 }
118 }
119 }
120 @if !peer_ids.contains(&our_id) {
121 td {
122 form method="post"
123 hx-post=(META_SUBMIT_ROUTE)
124 hx-swap="none"
125 {
126 input type="hidden" name="json_content"
127 value=(value_str);
128 button type="submit" class="btn btn-sm btn-success" {
129 "Accept This Submission"
130 }
131 }
132 }
133 }
134 }
135 }
136 }
137 }
138 }
139 }
140 }
141 }
142}
143
144#[derive(serde::Deserialize, Default)]
146pub struct MetaEditForm {
147 pub json_content: String,
148 #[serde(default)]
149 pub add_key: String,
150 #[serde(default)]
151 pub add_value: String,
152 #[serde(default)]
153 pub delete_key: String,
154}
155
156impl MetaEditForm {
157 fn top_level_keys(&self) -> RequestResult<serde_json::Map<String, Value>> {
158 Ok(
159 if let Some(serde_json::Value::Object(o)) =
160 serde_json::from_slice(self.json_content.as_bytes())
161 .map_err(|x| RequestError::BadRequest { source: x.into() })?
162 {
163 o
164 } else {
165 serde_json::Map::new()
166 },
167 )
168 }
169}
170
171pub async fn post_submit(
172 State(state): State<UiState<DynDashboardApi>>,
173 _auth: UserAuth,
174 Form(form): Form<MetaEditForm>,
175) -> RequestResult<Response> {
176 let meta_module = state.api.get_module::<Meta>().unwrap();
177
178 let top_level_keys = form.top_level_keys()?;
179 let top_level_object = Value::Object(top_level_keys.clone());
180
181 meta_module
182 .handle_submit_request_ui(top_level_object.clone())
183 .await
184 .inspect_err(|msg| warn!(target: LOG_UI, msg= %msg.message, "Request error"))
185 .map_err(|_err| RequestError::InternalError)?;
186
187 let mut submissions = meta_module
188 .handle_get_submissions_request_ui()
189 .await
190 .ok()
191 .unwrap_or_default();
192
193 submissions.insert(meta_module.our_peer_id, top_level_object);
194
195 let content = html! {
196 (render_meta_edit_form(top_level_keys, false, MetaEditForm::default()))
197
198 (render_submissions_form(meta_module.our_peer_id, &submissions))
201 };
202 Ok(Html(content.into_string()).into_response())
203}
204
205pub async fn post_reset(
206 State(state): State<UiState<DynDashboardApi>>,
207 _auth: UserAuth,
208 Form(_form): Form<MetaEditForm>,
209) -> RequestResult<Response> {
210 let meta_module = state.api.get_module::<Meta>().unwrap();
211
212 let consensus_value = meta_module
213 .handle_get_consensus_request_ui()
214 .await
215 .ok()
216 .flatten();
217
218 let top_level_keys = if let Some(serde_json::Value::Object(o)) = consensus_value {
219 o
220 } else {
221 serde_json::Map::new()
222 };
223 let top_level_object = Value::Object(top_level_keys.clone());
224
225 meta_module
226 .handle_submit_request_ui(top_level_object.clone())
227 .await
228 .inspect_err(|msg| warn!(target: LOG_UI, msg = %msg.message, "Request error"))
229 .map_err(|_err| RequestError::InternalError)?;
230
231 let mut submissions = meta_module
232 .handle_get_submissions_request_ui()
233 .await
234 .ok()
235 .unwrap_or_default();
236
237 submissions.remove(&meta_module.our_peer_id);
238
239 let content = html! {
240 (render_meta_edit_form(top_level_keys, false, MetaEditForm::default()))
241
242 (render_submissions_form(meta_module.our_peer_id, &submissions))
245 };
246 Ok(Html(content.into_string()).into_response())
247}
248
249pub async fn post_set(
250 _auth: UserAuth,
251 Form(mut form): Form<MetaEditForm>,
252) -> RequestResult<Response> {
253 let mut top_level_object = form.top_level_keys()?;
254
255 let key = form.add_key.trim();
256 let value = form.add_value.trim();
257 let value = serde_json::from_str(value)
258 .unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
259
260 top_level_object.insert(key.to_string(), value);
261
262 form.add_key = "".into();
263 form.add_value = "".into();
264 let content = render_meta_edit_form(top_level_object, true, MetaEditForm::default());
265 Ok(Html(content.into_string()).into_response())
266}
267
268pub async fn post_delete(
269 _auth: UserAuth,
270 Form(mut form): Form<MetaEditForm>,
271) -> RequestResult<Response> {
272 let mut top_level_json = form.top_level_keys()?;
273
274 let key = form.delete_key.trim();
275
276 top_level_json.remove(key);
277 form.delete_key = "".into();
278
279 let content = render_meta_edit_form(top_level_json, true, form);
280 Ok(Html(content.into_string()).into_response())
281}
282
283const WELL_KNOWN_KEYS: &[&str] = &[
285 "welcome_message",
286 "fedi:pinned_message",
287 "fedi:federation_icon_url",
288 "fedi:tos_url",
289 "fedi:default_currency",
290 "fedi:popup_end_timestamp",
291 "fedi:invite_codes_disabled",
292 "fedi:new_members_disabled",
293 "fedi:max_invoice_msats",
294 "fedi:max_balance_msats",
295 "fedi:max_stable_balance_msats",
296 "fedi:fedimods",
297 "fedi:default_group_chats",
298 "fedi:offline_wallet_disabled",
299];
300
301pub fn render_meta_edit_form(
302 mut top_level_json: serde_json::Map<String, Value>,
303 pending: bool,
305 form: MetaEditForm,
306) -> Markup {
307 top_level_json.sort_keys();
308
309 let known_keys: HashSet<String> = top_level_json
310 .keys()
311 .cloned()
312 .chain(WELL_KNOWN_KEYS.iter().map(ToString::to_string))
313 .collect();
314 html! {
315 form #meta-edit-form hx-swap-oob=(true) {
316 h5 {
317 "Proposal"
318 @if pending {
319 " (Pending)"
320 }
321 }
322 div class="input-group mb-2" {
323 textarea class="form-control" rows="15" readonly
324 name="json_content"
325 {
326 (serde_json::to_string_pretty(&top_level_json).expect("Can't fail"))
327 }
328 }
329 div class="input-group mb-2" {
330 input #add-key type="text" class="form-control" placeholder="Key" aria-label="Key" list="keyOptions"
331 style="max-width: 250px;"
333 name="add_key"
334 value=(form.add_key)
335 {}
336 span class="input-group-text" { ":" }
337 input #add-value type="text" name="add_value" class="form-control" placeholder="Value" aria-label="Value"
338 value=(form.add_value)
339 {}
340
341 datalist id="keyOptions" {
342 @for key in known_keys {
343 option value=(key) {}
344 }
345 }
346
347 button class="btn btn-primary btn-min-width"
348 type="button" id="button-set"
349 title="Set a value in a meta proposal"
350 hx-post=(META_SET_ROUTE)
351 hx-swap="none"
352 hx-trigger="click, keypress[key=='Enter'] from:#add-value, keypress[key=='Enter'] from:#add-key"
353 { "Set" }
354 }
355 div class="input-group mb-2" {
356 select class="form-select"
357 id="delete-key"
358 name="delete_key"
359 {
360 option value="" {}
361 @for key in top_level_json.keys() {
362 option value=(key) selected[key == &form.delete_key]{ (key) }
363 }
364 }
365 button class="btn btn-primary btn-min-width"
366 hx-post=(META_DELETE_ROUTE)
367 hx-swap="none"
368 hx-trigger="click, keypress[key=='Enter'] from:#delete-key"
369 title="Delete a value in a meta proposal"
370 { "Delete" }
371 }
372 div class="d-flex justify-content-between btn-min-width" {
373 button class="btn btn-outline-warning me-5"
374 title="Reset to current consensus"
375 hx-post=(META_RESET_ROUTE)
376 hx-swap="none"
377 { "Reset" }
378 button class="btn btn-success btn-min-width"
379 hx-post=(META_SUBMIT_ROUTE)
380 hx-swap="none"
381 title="Submit new meta document for approval of other peers"
382 { "Submit" }
383 }
384 }
385 }
386}
387
388#[derive(FromRequest)]
390#[from_request(via(axum::Json), rejection(RequestError))]
391struct AppJson<T>(pub T);
392
393impl<T> IntoResponse for AppJson<T>
394where
395 axum::Json<T>: IntoResponse,
396{
397 fn into_response(self) -> Response {
398 axum::Json(self.0).into_response()
399 }
400}
401
402#[derive(Debug, Error)]
404pub enum RequestError {
405 #[error("Bad request: {source}")]
406 BadRequest { source: anyhow::Error },
407 #[error("Internal Error")]
408 InternalError,
409}
410
411pub type RequestResult<T> = std::result::Result<T, RequestError>;
412
413impl IntoResponse for RequestError {
414 fn into_response(self) -> Response {
415 debug!(target: LOG_UI, err=%self, "Request Error");
416
417 let (status_code, message) = match self {
418 Self::BadRequest { source } => {
419 (StatusCode::BAD_REQUEST, format!("Bad Request: {source}"))
420 }
421 _ => (
422 StatusCode::INTERNAL_SERVER_ERROR,
423 "Internal Service Error".to_owned(),
424 ),
425 };
426
427 (status_code, AppJson(UserErrorResponse { message })).into_response()
428 }
429}
430
431#[derive(Serialize)]
433pub struct UserErrorResponse {
434 pub message: String,
435}