fedimint_server_ui/dashboard/
consensus_explorer.rs1use axum::extract::{Path, State};
2use axum::response::{Html, IntoResponse};
3use fedimint_core::hex;
4use fedimint_core::session_outcome::{AcceptedItem, ConsensusItem, SessionStatusV2};
5use fedimint_core::transaction::TransactionSignature;
6use fedimint_server_core::dashboard_ui::DynDashboardApi;
7use fedimint_ui_common::UiState;
8use fedimint_ui_common::auth::UserAuth;
9use maud::{Markup, html};
10
11use crate::dashboard::dashboard_layout;
12
13pub async fn consensus_explorer_view(
15 State(state): State<UiState<DynDashboardApi>>,
16 _auth: UserAuth,
17 session_idx: Option<Path<u64>>,
18) -> impl IntoResponse {
19 let session_count = state.api.session_count().await;
20 let last_sessin_idx = session_count.saturating_sub(1);
21
22 let session_idx = session_idx.map(|p| p.0).unwrap_or(last_sessin_idx);
25
26 let (_sigs, items) = match state.api.get_session_status(session_idx).await {
27 SessionStatusV2::Initial => (None, vec![]),
28 SessionStatusV2::Pending(items) => (None, items),
29 SessionStatusV2::Complete(signed_session_outcome) => (
30 Some(signed_session_outcome.signatures),
31 signed_session_outcome.session_outcome.items,
32 ),
33 };
34
35 let content = html! {
36 div class="row mb-4" {
37 div class="col-12" {
38 div class="d-flex justify-content-between align-items-center" {
39 h2 { "Consensus Explorer" }
40 a href="/" class="btn btn-outline-primary" { "Back to Dashboard" }
41 }
42 }
43 }
44
45 div class="row mb-4" {
46 div class="col-12" {
47 div class="d-flex justify-content-between align-items-center" {
48 div class="btn-group" role="group" aria-label="Session navigation" {
50 @if 0 < session_idx {
51 a href={ "/explorer/" (session_idx - 1) } class="btn btn-outline-secondary" {
52 "← Previous Session"
53 }
54 } @else {
55 button class="btn btn-outline-secondary" disabled { "← Previous Session" }
56 }
57
58 @if session_idx < last_sessin_idx {
59 a href={ "/explorer/" (session_idx + 1) } class="btn btn-outline-secondary" {
60 "Next Session →"
61 }
62 } @else {
63 button class="btn btn-outline-secondary" disabled { "Next Session →" }
64 }
65 }
66
67 form class="d-flex" action="javascript:void(0);" onsubmit="window.location.href='/explorer/' + document.getElementById('session-jump').value" {
69 div class="input-group" {
70 input type="number" class="form-control" id="session-jump" min="0" max=(session_count - 1) placeholder="Session #";
71 button class="btn btn-outline-primary" type="submit" { "Go" }
72 }
73 }
74 }
75 }
76 }
77
78 div class="row" {
79 div class="col-12" {
80 div class="card mb-4" {
81 div class="card-header" {
82 div class="d-flex justify-content-between align-items-center" {
83 h5 class="mb-0" { "Session #" (session_idx) }
84 span class="badge bg-primary" { (items.len()) " items" }
85 }
86 }
87 div class="card-body" {
88 @if items.is_empty() {
89 div class="alert alert-secondary" {
90 "This session contains no consensus items."
91 }
92 } @else {
93 div class="table-responsive" {
94 table class="table table-striped table-hover" {
95 thead {
96 tr {
97 th { "Item #" }
98 th { "Type" }
99 th { "Peer" }
100 th { "Details" }
101 }
102 }
103 tbody {
104 @for (item_idx, item) in items.iter().enumerate() {
105 tr {
106 td { (item_idx) }
107 td { (format_item_type(&item.item)) }
108 td { (item.peer) }
109 td { (format_item_details(item)) }
110 }
111 }
112 }
113 }
114 }
115
116 @if let Some(signatures) = _sigs {
118 div class="mt-4" {
119 h5 { "Session Signatures" }
120 div class="alert alert-info" {
121 p { "This session was signed by the following peers:" }
122 ul class="mb-0" {
123 @for peer_id in signatures.keys() {
124 li { "Guardian " (peer_id.to_string()) }
125 }
126 }
127 }
128 }
129 }
130 }
131 }
132 }
133 }
134 }
135 };
136
137 let version = state.api.fedimintd_version().await;
138 let version_hash = state.api.fedimintd_version_hash().await;
139
140 Html(dashboard_layout(content, &version, version_hash.as_deref()).into_string()).into_response()
141}
142
143fn format_item_type(item: &ConsensusItem) -> String {
145 match item {
146 ConsensusItem::Transaction(_) => "Transaction".to_string(),
147 ConsensusItem::Module(_) => "Module".to_string(),
148 ConsensusItem::Default { variant, .. } => format!("Unknown ({variant})"),
149 }
150}
151
152fn format_item_details(item: &AcceptedItem) -> Markup {
154 match &item.item {
155 ConsensusItem::Transaction(tx) => {
156 html! {
157 div class="consensus-item-details" {
158 div class="mb-2" {
159 "Transaction ID: " code { (tx.tx_hash()) }
160 }
161 div class="mb-2" {
162 "Nonce: " code { (hex::encode(tx.nonce)) }
163 }
164
165 details class="mb-2" {
167 summary { "Inputs: " strong { (tx.inputs.len()) } }
168 @if tx.inputs.is_empty() {
169 div class="alert alert-secondary mt-2" { "No inputs" }
170 } @else {
171 div class="table-responsive mt-2" {
172 table class="table table-sm" {
173 thead {
174 tr {
175 th { "#" }
176 th { "Module ID" }
177 th { "Type" }
178 }
179 }
180 tbody {
181 @for (idx, input) in tx.inputs.iter().enumerate() {
182 tr {
183 td { (idx) }
184 td { (input.module_instance_id()) }
185 td { (input.to_string()) }
186 }
187 }
188 }
189 }
190 }
191 }
192 }
193
194 details class="mb-2" {
196 summary { "Outputs: " strong { (tx.outputs.len()) } }
197 @if tx.outputs.is_empty() {
198 div class="alert alert-secondary mt-2" { "No outputs" }
199 } @else {
200 div class="table-responsive mt-2" {
201 table class="table table-sm" {
202 thead {
203 tr {
204 th { "#" }
205 th { "Module ID" }
206 th { "Type" }
207 }
208 }
209 tbody {
210 @for (idx, output) in tx.outputs.iter().enumerate() {
211 tr {
212 td { (idx) }
213 td { (output.module_instance_id()) }
214 td { (output.to_string()) }
215 }
216 }
217 }
218 }
219 }
220 }
221 }
222
223 details class="mb-2" {
225 summary { "Signature Info" }
226 div class="mt-2" {
227 @match &tx.signatures {
228 TransactionSignature::NaiveMultisig(sigs) => {
229 div { "Type: NaiveMultisig" }
230 div { "Signatures: " (sigs.len()) }
231 }
232 TransactionSignature::Default { variant, bytes } => {
233 div { "Type: Unknown (variant " (variant) ")" }
234 div { "Size: " (bytes.len()) " bytes" }
235 }
236 }
237 }
238 }
239 }
240 }
241 }
242 ConsensusItem::Module(module_item) => {
243 html! {
244 div class="consensus-item-details" {
245 div class="mb-2" {
246 "Module Instance ID: " code { (module_item.module_instance_id()) }
247 }
248
249 @if let Some(kind) = module_item.module_kind() {
250 div class="mb-2" {
251 "Module Kind: " strong { (kind.to_string()) }
252 }
253 } @else {
254 div class="alert alert-warning mb-2" {
255 "Unknown Module Kind"
256 }
257 }
258
259 div class="mb-2" {
260 "Module Item: " code { (module_item.to_string()) }
261 }
262 }
263 }
264 }
265 ConsensusItem::Default { variant, bytes } => {
266 html! {
267 div class="consensus-item-details" {
268 div class="alert alert-warning mb-2" {
269 "Unknown Consensus Item Type (variant " (variant) ")"
270 }
271 div class="mb-2" {
272 "Size: " (bytes.len()) " bytes"
273 }
274 @if !bytes.is_empty() {
275 details {
276 summary { "Raw Data (Hex)" }
277 div class="mt-2" {
278 code class="user-select-all" style="word-break: break-all;" {
279 (hex::encode(bytes))
280 }
281 }
282 }
283 }
284 }
285 }
286 }
287 }
288}