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