1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::sync::LazyLock;
3
4use axum::extract::{Form, FromRequest, Query, State};
5use axum::http::StatusCode;
6use axum::response::{Html, IntoResponse, Response};
7use chrono::NaiveDateTime;
8use fedimint_core::PeerId;
9use fedimint_core::module::serde_json::{self, Value};
10use fedimint_meta_server::Meta;
11use fedimint_server_core::dashboard_ui::{DashboardApiModuleExt, DynDashboardApi};
12use fedimint_ui_common::UiState;
13use fedimint_ui_common::auth::UserAuth;
14use maud::{Markup, html};
15use serde::Serialize;
16use thiserror::Error;
17use tracing::{debug, warn};
18
19use crate::LOG_UI;
20
21pub const META_SUBMIT_ROUTE: &str = "/meta/submit";
23pub const META_SET_ROUTE: &str = "/meta/set";
24pub const META_RESET_ROUTE: &str = "/meta/reset";
25pub const META_DELETE_ROUTE: &str = "/meta/delete";
26pub const META_VALUE_INPUT_ROUTE: &str = "/meta/value-input";
27pub const META_MERGE_ROUTE: &str = "/meta/merge";
28
29enum KeyType {
31 String,
32 Url,
33 Amount,
34 DateTime,
35 Json,
36}
37
38struct KeySchema {
40 description: &'static str,
41 value_type: KeyType,
42}
43
44static WELL_KNOWN_KEYS: LazyLock<BTreeMap<&'static str, KeySchema>> = LazyLock::new(|| {
47 BTreeMap::from([
48 (
49 "welcome_message",
50 KeySchema {
51 description: "A welcome message for new users joining the federation",
52 value_type: KeyType::String,
53 },
54 ),
55 (
56 "federation_expiry_timestamp",
57 KeySchema {
58 description: "The date and time after which the federation will shut down",
59 value_type: KeyType::DateTime,
60 },
61 ),
62 (
63 "federation_name",
64 KeySchema {
65 description: "The human-readable name of the federation",
66 value_type: KeyType::String,
67 },
68 ),
69 (
70 "federation_successor",
71 KeySchema {
72 description: "An invite code to a successor federation for user migration",
73 value_type: KeyType::String,
74 },
75 ),
76 (
77 "meta_override_url",
78 KeySchema {
79 description: "A URL to a file containing overrides for meta fields",
80 value_type: KeyType::Url,
81 },
82 ),
83 (
84 "vetted_gateways",
85 KeySchema {
86 description: "A list of gateway identifiers vetted by the federation",
87 value_type: KeyType::Json,
88 },
89 ),
90 (
91 "recurringd_api",
92 KeySchema {
93 description: "The API URL of a recurringd instance for creating LNURLs",
94 value_type: KeyType::Url,
95 },
96 ),
97 (
98 "lnaddress_api",
99 KeySchema {
100 description: "The API URL of a Lightning Address Server for serving LNURLs",
101 value_type: KeyType::Url,
102 },
103 ),
104 (
105 "fedi:pinned_message",
106 KeySchema {
107 description: "",
108 value_type: KeyType::String,
109 },
110 ),
111 (
112 "fedi:federation_icon_url",
113 KeySchema {
114 description: "",
115 value_type: KeyType::Url,
116 },
117 ),
118 (
119 "fedi:tos_url",
120 KeySchema {
121 description: "",
122 value_type: KeyType::Url,
123 },
124 ),
125 (
126 "fedi:default_currency",
127 KeySchema {
128 description: "",
129 value_type: KeyType::String,
130 },
131 ),
132 (
133 "fedi:invite_codes_disabled",
134 KeySchema {
135 description: "",
136 value_type: KeyType::String,
137 },
138 ),
139 (
140 "fedi:new_members_disabled",
141 KeySchema {
142 description: "",
143 value_type: KeyType::String,
144 },
145 ),
146 (
147 "fedi:max_invoice_msats",
148 KeySchema {
149 description: "",
150 value_type: KeyType::Amount,
151 },
152 ),
153 (
154 "fedi:max_balance_msats",
155 KeySchema {
156 description: "",
157 value_type: KeyType::Amount,
158 },
159 ),
160 (
161 "fedi:max_stable_balance_msats",
162 KeySchema {
163 description: "",
164 value_type: KeyType::Amount,
165 },
166 ),
167 (
168 "fedi:fedimods",
169 KeySchema {
170 description: "",
171 value_type: KeyType::Json,
172 },
173 ),
174 (
175 "fedi:default_group_chats",
176 KeySchema {
177 description: "",
178 value_type: KeyType::Json,
179 },
180 ),
181 (
182 "fedi:offline_wallet_disabled",
183 KeySchema {
184 description: "",
185 value_type: KeyType::String,
186 },
187 ),
188 ])
189});
190
191pub async fn render(meta: &Meta) -> Markup {
193 let consensus_value = meta.handle_get_consensus_request_ui().await.ok().flatten();
195 let revision = meta
197 .handle_get_consensus_revision_request_ui()
198 .await
199 .ok()
200 .unwrap_or(0);
201 let submissions = meta
203 .handle_get_submissions_request_ui()
204 .await
205 .ok()
206 .unwrap_or_default();
207
208 let consensus_map = consensus_value
209 .as_ref()
210 .and_then(|v| v.as_object().cloned())
211 .unwrap_or_default();
212
213 let current_meta_keys = if let Some(o) = submissions
214 .get(&meta.our_peer_id)
215 .cloned()
216 .or_else(|| consensus_value.clone())
217 .and_then(|v| v.as_object().cloned())
218 {
219 o
220 } else {
221 serde_json::Map::new()
222 };
223
224 html! {
225 div class="card h-100" {
226 div class="card-header dashboard-header" { "Meta Configuration" }
227 div class="card-body" {
228 div class="mb-4" {
229 h5 { "Current Consensus (Revision: " (revision) ")" }
230 @if consensus_value.is_some() {
231 div class="row mb-2" {
232 div class="col-md-6" {
233 strong { "Full document" }
234 pre class="m-0 p-2 bg-light" style="max-height: 40vh; overflow-y: auto;" {
235 code {
236 (serde_json::to_string_pretty(&consensus_map).unwrap_or_else(|_| "Invalid JSON".to_string()))
237 }
238 }
239 }
240 div class="col-md-6" {
241 (render_consensus_summary(&consensus_map))
242 }
243 }
244 } @else {
245 div class="alert alert-secondary" { "No consensus value has been established yet." }
246 }
247 div class="mb-4" {
248 (render_meta_edit_form(&consensus_map, current_meta_keys, false, MetaEditForm::default()))
249 }
250
251 (render_submissions_form(meta.our_peer_id, &consensus_map, &submissions))
252 }
253 }
254 }
255 }
256}
257
258enum MetaChange {
260 Set { key: String, value: String },
261 Deleted { key: String },
262}
263
264fn compute_changes(
266 consensus: &serde_json::Map<String, Value>,
267 proposal: &serde_json::Map<String, Value>,
268) -> Vec<MetaChange> {
269 let mut changes = Vec::new();
270
271 for (key, new_val) in proposal {
273 let changed = consensus.get(key) != Some(new_val);
274 if changed {
275 changes.push(MetaChange::Set {
276 key: key.clone(),
277 value: format_value_for_display(key, new_val),
278 });
279 }
280 }
281
282 for key in consensus.keys() {
284 if !proposal.contains_key(key) {
285 changes.push(MetaChange::Deleted { key: key.clone() });
286 }
287 }
288
289 changes
290}
291
292fn format_value_for_display(key: &str, value: &Value) -> String {
295 if let Some(schema) = WELL_KNOWN_KEYS.get(key) {
296 match schema.value_type {
297 KeyType::DateTime => {
298 if let Some(ts) = value
299 .as_str()
300 .and_then(|s| s.parse::<i64>().ok())
301 .and_then(|t| chrono::DateTime::from_timestamp(t, 0))
302 {
303 return ts.format("%Y-%m-%d %H:%M UTC").to_string();
304 }
305 }
306 KeyType::Amount => {
307 if let Some(s) = value.as_str() {
308 return format!("{s} msats");
309 }
310 }
311 KeyType::Json => {
312 return serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
313 }
314 KeyType::Url | KeyType::String => {}
315 }
316 }
317
318 match value {
319 Value::String(s) => s.clone(),
320 other => other.to_string(),
321 }
322}
323
324fn render_changes_summary(changes: &[MetaChange]) -> Markup {
326 html! {
327 strong { "Proposed changes" }
328 @if changes.is_empty() {
329 p class="text-muted" { "No changes" }
330 } @else {
331 ul class="mb-0 ps-3" {
332 @for change in changes {
333 li {
334 @match change {
335 MetaChange::Set { key, value } => {
336 strong { (key) }
337 " set to "
338 em { (value) }
339 },
340 MetaChange::Deleted { key } => {
341 strong { (key) }
342 " deleted"
343 },
344 }
345 }
346 }
347 }
348 }
349 }
350}
351
352fn render_consensus_summary(map: &serde_json::Map<String, Value>) -> Markup {
355 html! {
356 @if map.is_empty() {
357 span class="text-muted" { "No fields set" }
358 } @else {
359 strong { "Summary" }
360 ul class="mb-0 ps-3" {
361 @for (key, value) in map {
362 li {
363 strong { (key) }
364 " = "
365 em { (format_value_for_display(key, value)) }
366 }
367 }
368 }
369 }
370 }
371}
372
373fn render_submissions_form(
374 our_id: PeerId,
375 consensus: &serde_json::Map<String, Value>,
376 submissions: &BTreeMap<PeerId, Value>,
377) -> Markup {
378 let mut submissions_by_value: HashMap<
379 String,
380 (BTreeSet<PeerId>, serde_json::Map<String, Value>),
381 > = HashMap::new();
382
383 for (peer_id, value) in submissions {
384 let value_str =
385 serde_json::to_string_pretty(value).unwrap_or_else(|_| "Invalid JSON".to_string());
386 let proposal_map = value.as_object().cloned().unwrap_or_default();
387 let entry = submissions_by_value
388 .entry(value_str)
389 .or_insert_with(|| (BTreeSet::new(), proposal_map));
390 entry.0.insert(*peer_id);
391 }
392
393 html! {
394 div #meta-submissions hx-swap-oob=(true) {
395 @if !submissions.is_empty() {
396 h5 { "Current Proposals" }
397 @for (value_str, (peer_ids, proposal_map)) in &submissions_by_value {
398 div class="card mb-3" {
399 div class="card-header py-2" {
400 strong { "Peers: " }
401 (peer_ids.iter()
402 .map(|n| n.to_string())
403 .collect::<Vec<String>>()
404 .join(", "))
405 }
406 div class="card-body py-2" {
407 div class="row" {
408 div class="col-md-6" {
409 strong { "Full proposal" }
410 pre class="m-0 p-2 bg-light" style="max-height: 40vh; overflow-y: auto;" {
411 code { (value_str) }
412 }
413 }
414 div class="col-md-6" {
415 (render_changes_summary(&compute_changes(consensus, proposal_map)))
416 }
417 }
418 }
419 @if !peer_ids.contains(&our_id) {
420 div class="card-footer py-2 d-flex gap-2 justify-content-end" {
421 form method="post"
422 hx-post=(META_SUBMIT_ROUTE)
423 hx-swap="none"
424 {
425 input type="hidden" name="json_content"
426 value=(value_str);
427 button type="submit" class="btn btn-sm btn-success" {
428 "Accept As-Is"
429 }
430 }
431 form method="post"
432 hx-post=(META_MERGE_ROUTE)
433 hx-swap="none"
434 hx-include="#meta-edit-form [name='json_content']"
435 {
436 input type="hidden" name="proposal_json"
437 value=(value_str);
438 button type="submit" class="btn btn-sm btn-primary" {
439 "Add Changes To My Proposal"
440 }
441 }
442 }
443 }
444 }
445 }
446 }
447 }
448 }
449}
450
451#[derive(serde::Deserialize, Default)]
453pub struct MetaEditForm {
454 pub json_content: String,
455 #[serde(default)]
456 pub add_key: String,
457 #[serde(default)]
458 pub add_value: String,
459 #[serde(default)]
460 pub delete_key: String,
461}
462
463impl MetaEditForm {
464 fn top_level_keys(&self) -> RequestResult<serde_json::Map<String, Value>> {
465 Ok(
466 if let Some(serde_json::Value::Object(o)) =
467 serde_json::from_slice(self.json_content.as_bytes())
468 .map_err(|x| RequestError::BadRequest { source: x.into() })?
469 {
470 o
471 } else {
472 serde_json::Map::new()
473 },
474 )
475 }
476}
477
478async fn get_consensus_map(meta: &Meta) -> serde_json::Map<String, Value> {
480 meta.handle_get_consensus_request_ui()
481 .await
482 .ok()
483 .flatten()
484 .and_then(|v| v.as_object().cloned())
485 .unwrap_or_default()
486}
487
488pub async fn post_submit(
489 State(state): State<UiState<DynDashboardApi>>,
490 _auth: UserAuth,
491 Form(form): Form<MetaEditForm>,
492) -> RequestResult<Response> {
493 let meta_module = state.api.get_module::<Meta>().unwrap();
494
495 let top_level_keys = form.top_level_keys()?;
496 let top_level_object = Value::Object(top_level_keys.clone());
497
498 meta_module
499 .handle_submit_request_ui(top_level_object.clone())
500 .await
501 .inspect_err(|msg| warn!(target: LOG_UI, msg= %msg.message, "Request error"))
502 .map_err(|_err| RequestError::InternalError)?;
503
504 let consensus_map = get_consensus_map(meta_module).await;
505
506 let mut submissions = meta_module
507 .handle_get_submissions_request_ui()
508 .await
509 .ok()
510 .unwrap_or_default();
511
512 submissions.insert(meta_module.our_peer_id, top_level_object);
513
514 let content = html! {
515 (render_meta_edit_form(&consensus_map, top_level_keys, false, MetaEditForm::default()))
516
517 (render_submissions_form(meta_module.our_peer_id, &consensus_map, &submissions))
520 };
521 Ok(Html(content.into_string()).into_response())
522}
523
524pub async fn post_reset(
525 State(state): State<UiState<DynDashboardApi>>,
526 _auth: UserAuth,
527 Form(_form): Form<MetaEditForm>,
528) -> RequestResult<Response> {
529 let meta_module = state.api.get_module::<Meta>().unwrap();
530
531 let consensus_map = get_consensus_map(meta_module).await;
532 let top_level_keys = consensus_map.clone();
533 let top_level_object = Value::Object(top_level_keys.clone());
534
535 meta_module
536 .handle_submit_request_ui(top_level_object.clone())
537 .await
538 .inspect_err(|msg| warn!(target: LOG_UI, msg = %msg.message, "Request error"))
539 .map_err(|_err| RequestError::InternalError)?;
540
541 let mut submissions = meta_module
542 .handle_get_submissions_request_ui()
543 .await
544 .ok()
545 .unwrap_or_default();
546
547 submissions.remove(&meta_module.our_peer_id);
548
549 let content = html! {
550 (render_meta_edit_form(&consensus_map, top_level_keys, false, MetaEditForm::default()))
551
552 (render_submissions_form(meta_module.our_peer_id, &consensus_map, &submissions))
555 };
556 Ok(Html(content.into_string()).into_response())
557}
558
559pub async fn post_set(
560 State(state): State<UiState<DynDashboardApi>>,
561 _auth: UserAuth,
562 Form(mut form): Form<MetaEditForm>,
563) -> RequestResult<Response> {
564 let meta_module = state.api.get_module::<Meta>().unwrap();
565 let consensus_map = get_consensus_map(meta_module).await;
566
567 let mut top_level_object = form.top_level_keys()?;
568
569 let key = form.add_key.trim();
570 let raw_value = form.add_value.trim();
571 let key_type = WELL_KNOWN_KEYS
572 .get(key)
573 .map(|s| &s.value_type)
574 .unwrap_or(&KeyType::String);
575 let value = convert_input_value(raw_value, key_type)?;
576
577 top_level_object.insert(key.to_string(), value);
578
579 form.add_key = "".into();
580 form.add_value = "".into();
581 let content = render_meta_edit_form(
582 &consensus_map,
583 top_level_object,
584 true,
585 MetaEditForm::default(),
586 );
587 Ok(Html(content.into_string()).into_response())
588}
589
590pub async fn post_delete(
591 State(state): State<UiState<DynDashboardApi>>,
592 _auth: UserAuth,
593 Form(mut form): Form<MetaEditForm>,
594) -> RequestResult<Response> {
595 let meta_module = state.api.get_module::<Meta>().unwrap();
596 let consensus_map = get_consensus_map(meta_module).await;
597
598 let mut top_level_json = form.top_level_keys()?;
599
600 let key = form.delete_key.trim();
601
602 top_level_json.remove(key);
603 form.delete_key = "".into();
604
605 let content = render_meta_edit_form(&consensus_map, top_level_json, true, form);
606 Ok(Html(content.into_string()).into_response())
607}
608
609#[derive(serde::Deserialize)]
610pub struct MetaMergeForm {
611 pub json_content: String,
612 pub proposal_json: String,
613}
614
615pub async fn post_merge(
616 State(state): State<UiState<DynDashboardApi>>,
617 _auth: UserAuth,
618 Form(form): Form<MetaMergeForm>,
619) -> RequestResult<Response> {
620 let meta_module = state.api.get_module::<Meta>().unwrap();
621 let consensus_map = get_consensus_map(meta_module).await;
622
623 let mut current: serde_json::Map<String, Value> =
624 if let Ok(Value::Object(o)) = serde_json::from_str(&form.json_content) {
625 o
626 } else {
627 serde_json::Map::new()
628 };
629
630 let proposal: serde_json::Map<String, Value> =
631 if let Ok(Value::Object(o)) = serde_json::from_str(&form.proposal_json) {
632 o
633 } else {
634 serde_json::Map::new()
635 };
636
637 for change in &compute_changes(&consensus_map, &proposal) {
639 match change {
640 MetaChange::Set { key, .. } => {
641 if let Some(val) = proposal.get(key) {
642 current.insert(key.clone(), val.clone());
643 }
644 }
645 MetaChange::Deleted { key } => {
646 current.remove(key);
647 }
648 }
649 }
650
651 let content = render_meta_edit_form(&consensus_map, current, true, MetaEditForm::default());
652 Ok(Html(content.into_string()).into_response())
653}
654
655fn render_value_input(key_type: &KeyType, current_value: &str) -> Markup {
661 match key_type {
662 KeyType::Url => html! {
663 input #add-value type="url" name="add_value" class="form-control"
664 placeholder="https://..." aria-label="Value"
665 value=(current_value) {}
666 },
667 KeyType::Amount => html! {
668 input #add-value type="number" name="add_value" class="form-control"
669 placeholder="Amount (msats)" aria-label="Value"
670 value=(current_value) {}
671 },
672 KeyType::DateTime => {
673 let val = if current_value.is_empty() {
675 chrono::Utc::now().format("%Y-%m-%dT00:00").to_string()
676 } else {
677 current_value.to_string()
678 };
679 html! {
680 input #add-value type="datetime-local" name="add_value" class="form-control"
681 aria-label="Value" value=(val) {}
682 }
683 }
684 KeyType::Json => html! {
685 input #add-value type="text" name="add_value" class="form-control"
686 placeholder="{}" aria-label="Value"
687 value=(current_value) {}
688 },
689 KeyType::String => html! {
690 input #add-value type="text" name="add_value" class="form-control"
691 placeholder="Value" aria-label="Value"
692 value=(current_value) {}
693 },
694 }
695}
696
697fn render_value_description(key: &str) -> Markup {
701 let schema = WELL_KNOWN_KEYS.get(key);
702 let description = schema.map(|s| s.description).unwrap_or("");
703 let type_hint = match schema.map(|s| &s.value_type) {
704 Some(KeyType::DateTime) => " (UTC)",
705 Some(KeyType::Amount) => " (msats)",
706 _ => "",
707 };
708
709 html! {
710 @if !description.is_empty() || !type_hint.is_empty() {
711 small class="form-text text-muted" { (description) (type_hint) }
712 }
713 }
714}
715
716fn render_key_picker(extra_keys: &BTreeSet<String>) -> Markup {
720 let all_keys: BTreeSet<&str> = WELL_KNOWN_KEYS
721 .keys()
722 .copied()
723 .chain(extra_keys.iter().map(|s| s.as_str()))
724 .collect();
725
726 html! {
727 select class="form-select"
728 style="flex: 0 0 auto; width: 2em; padding-left: 0.2em;"
729 aria-label="Pick a well-known key"
730 onchange="if(this.value){var k=document.getElementById('add-key');k.value=this.value;this.value='';htmx.trigger(k,'change')}"
731 {
732 option value="" selected {}
733 @for key in &all_keys {
734 option value=(key) { (key) }
735 }
736 }
737 }
738}
739
740fn render_set_button(key_in_proposal: bool, oob: bool) -> Markup {
744 let class = if key_in_proposal {
745 "btn btn-primary btn-min-width"
746 } else {
747 "btn btn-primary btn-min-width rounded-end"
748 };
749 html! {
750 button #button-set class=(class) type="button"
751 hx-swap-oob=[oob.then_some("outerHTML")]
752 title="Set a value in a meta proposal"
753 hx-post=(META_SET_ROUTE)
754 hx-swap="none"
755 hx-trigger="click, keypress[key=='Enter'] from:#add-value, keypress[key=='Enter'] from:#add-key"
756 { "Set" }
757 }
758}
759
760fn render_delete_button(key: &str, visible: bool, oob: bool) -> Markup {
763 if visible {
764 html! {
765 button #button-delete class="btn btn-danger btn-min-width" type="button"
766 hx-swap-oob=[oob.then_some("outerHTML")]
767 title="Delete this key from the proposal"
768 hx-post=(META_DELETE_ROUTE)
769 hx-swap="none"
770 hx-vals=(format!(r#"{{"delete_key":"{}"}}"#, key))
771 { "Delete" }
772 }
773 } else {
774 html! {
775 span #button-delete style="display:none"
776 hx-swap-oob=[oob.then_some("outerHTML")]
777 {}
778 }
779 }
780}
781
782fn parse_proposal(json_content: &str) -> serde_json::Map<String, Value> {
784 serde_json::from_str(json_content)
785 .ok()
786 .and_then(|v: Value| v.as_object().cloned())
787 .unwrap_or_default()
788}
789
790#[derive(serde::Deserialize)]
792pub struct ValueInputQuery {
793 #[serde(default)]
794 pub add_key: String,
795 #[serde(default)]
796 pub json_content: String,
797}
798
799pub async fn get_value_input(
803 _auth: UserAuth,
804 Query(query): Query<ValueInputQuery>,
805) -> impl IntoResponse {
806 let key = query.add_key.trim();
807 let proposal = parse_proposal(&query.json_content);
808 let key_in_proposal = !key.is_empty() && proposal.contains_key(key);
809
810 let key_type = WELL_KNOWN_KEYS
811 .get(key)
812 .map(|s| &s.value_type)
813 .unwrap_or(&KeyType::String);
814
815 let content = html! {
816 (render_value_input(key_type, ""))
818 div #value-description-container hx-swap-oob="innerHTML" {
820 (render_value_description(key))
821 }
822 (render_set_button(key_in_proposal, true))
823 (render_delete_button(key, key_in_proposal, true))
824 };
825 Html(content.into_string())
826}
827
828fn convert_input_value(raw: &str, key_type: &KeyType) -> RequestResult<Value> {
831 match key_type {
832 KeyType::DateTime => {
833 let dt = NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S")
835 .or_else(|_| NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M"))
836 .map_err(|e| RequestError::BadRequest {
837 source: anyhow::anyhow!("Invalid datetime: {e}"),
838 })?;
839 Ok(Value::String(dt.and_utc().timestamp().to_string()))
840 }
841 KeyType::Amount => {
842 let _: u64 = raw.parse().map_err(|e| RequestError::BadRequest {
843 source: anyhow::anyhow!("Invalid amount: {e}"),
844 })?;
845 Ok(Value::String(raw.to_string()))
846 }
847 KeyType::Json => serde_json::from_str(raw).map_err(|e| RequestError::BadRequest {
848 source: anyhow::anyhow!("Invalid JSON: {e}"),
849 }),
850 KeyType::Url | KeyType::String => {
851 Ok(serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string())))
853 }
854 }
855}
856
857pub fn render_meta_edit_form(
858 consensus: &serde_json::Map<String, Value>,
859 mut top_level_json: serde_json::Map<String, Value>,
860 pending: bool,
862 form: MetaEditForm,
863) -> Markup {
864 top_level_json.sort_keys();
865
866 let changes = compute_changes(consensus, &top_level_json);
867 let extra_keys: BTreeSet<String> = top_level_json.keys().cloned().collect();
868 let all_keys: BTreeSet<&str> = WELL_KNOWN_KEYS
869 .keys()
870 .copied()
871 .chain(extra_keys.iter().map(|s| s.as_str()))
872 .collect();
873 let default_input = render_value_input(&KeyType::String, &form.add_value);
874
875 html! {
876 form #meta-edit-form hx-swap-oob=(true) {
877 h5 {
878 "Propose Changes"
879 @if pending {
880 " (Pending - click Submit to Propose)"
881 }
882 }
883 input type="hidden" name="json_content"
884 value=(serde_json::to_string_pretty(&top_level_json).expect("Can't fail")) {}
885 div class="row mb-2" {
886 div class="col-md-6" {
887 strong { "Full proposal" }
888 pre class="m-0 p-2 bg-light" style="min-height: 120px; max-height: 40vh; overflow-y: auto;" {
889 code {
890 (serde_json::to_string_pretty(&top_level_json).expect("Can't fail"))
891 }
892 }
893 }
894 div class="col-md-6" {
895 (render_changes_summary(&changes))
896 }
897 }
898 datalist #keyOptions {
900 @for key in &all_keys {
901 option value=(key) {}
902 }
903 }
904 div class="input-group mb-1" {
907 (render_key_picker(&extra_keys))
908 input #add-key type="text" list="keyOptions" name="add_key" class="form-control"
909 style="max-width: 250px;" placeholder="Key"
910 hx-get=(META_VALUE_INPUT_ROUTE)
911 hx-trigger="change, input changed delay:300ms"
912 hx-target="#add-value"
913 hx-swap="outerHTML"
914 hx-include="#meta-edit-form [name='json_content']"
915 {}
916 span class="input-group-text" { ":" }
917 (default_input)
918 (render_set_button(false, false))
919 (render_delete_button("", false, false))
920 }
921 div #value-description-container {}
922 div class="d-flex justify-content-between btn-min-width" {
923 button type="button" class="btn btn-outline-warning me-5"
924 title="Reset to current consensus"
925 hx-post=(META_RESET_ROUTE)
926 hx-swap="none"
927 hx-confirm="This will clear all the changes in your proposal. Are you sure?"
928 { "Reset" }
929 button type="button" class="btn btn-success btn-min-width"
930 hx-post=(META_SUBMIT_ROUTE)
931 hx-swap="none"
932 title="Submit new meta document for approval of other peers"
933 { "Submit" }
934 }
935 }
936 }
937}
938
939#[derive(FromRequest)]
941#[from_request(via(axum::Json), rejection(RequestError))]
942struct AppJson<T>(pub T);
943
944impl<T> IntoResponse for AppJson<T>
945where
946 axum::Json<T>: IntoResponse,
947{
948 fn into_response(self) -> Response {
949 axum::Json(self.0).into_response()
950 }
951}
952
953#[derive(Debug, Error)]
955pub enum RequestError {
956 #[error("Bad request: {source}")]
957 BadRequest { source: anyhow::Error },
958 #[error("Internal Error")]
959 InternalError,
960}
961
962pub type RequestResult<T> = std::result::Result<T, RequestError>;
963
964impl IntoResponse for RequestError {
965 fn into_response(self) -> Response {
966 debug!(target: LOG_UI, err=%self, "Request Error");
967
968 let (status_code, message) = match self {
969 Self::BadRequest { source } => {
970 (StatusCode::BAD_REQUEST, format!("Bad Request: {source}"))
971 }
972 _ => (
973 StatusCode::INTERNAL_SERVER_ERROR,
974 "Internal Service Error".to_owned(),
975 ),
976 };
977
978 (status_code, AppJson(UserErrorResponse { message })).into_response()
979 }
980}
981
982#[derive(Serialize)]
984pub struct UserErrorResponse {
985 pub message: String,
986}