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