1use std::collections::{BTreeMap, HashSet};
10use std::fmt;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13use fedimint_client_module::oplog::OperationLogEntry;
14use fedimint_client_module::sm::{ActiveStateMeta, DynState, IState, InactiveStateMeta};
15use fedimint_client_module::transaction::{
16 TRANSACTION_SUBMISSION_MODULE_INSTANCE, TxSubmissionStates, TxSubmissionStatesSM,
17};
18use fedimint_core::TransactionId;
19use fedimint_core::core::{ModuleInstanceId, OperationId};
20use time::OffsetDateTime;
21
22use crate::Client;
23use crate::error::OperationNotFoundError;
24
25pub struct OperationVisData {
27 pub operation_id: OperationId,
28 pub creation_time: Option<SystemTime>,
29 pub operation_type: String,
30 pub has_outcome: bool,
31 pub states: Vec<StateVisData>,
32}
33
34pub struct StateVisData {
36 pub is_active: bool,
37 pub module_id: ModuleInstanceId,
38 pub module_kind: String,
39 pub created_at: SystemTime,
40 pub exited_at: Option<SystemTime>,
41 pub visualization: String,
42}
43
44pub struct OperationTransactionsVisData {
46 pub operation_id: OperationId,
47 pub operation_type: String,
48 pub transactions: Vec<TransactionVisData>,
49}
50
51pub struct TransactionVisData {
53 pub txid: TransactionId,
54 pub status: TransactionVisStatus,
55 pub created_at: Option<SystemTime>,
56 pub inputs: Vec<TxIoVisData>,
57 pub outputs: Vec<TxIoVisData>,
58}
59
60pub enum TransactionVisStatus {
62 Pending,
63 Accepted,
64 Rejected(String),
65 Completed(String),
66}
67
68pub struct TxIoVisData {
70 pub module_id: ModuleInstanceId,
71 pub module_kind: String,
72 pub display: String,
73}
74
75pub fn module_kind_name(kinds: &BTreeMap<ModuleInstanceId, String>, id: ModuleInstanceId) -> &str {
77 kinds.get(&id).map_or("unknown", String::as_str)
78}
79
80pub fn systime_to_iso8601_secs(t: &SystemTime) -> String {
84 use time::format_description::well_known::iso8601::{
85 Config, FormattedComponents, TimePrecision,
86 };
87
88 const ISO8601_SECS: time::format_description::well_known::iso8601::EncodedConfig =
89 Config::DEFAULT
90 .set_formatted_components(FormattedComponents::DateTime)
91 .set_time_precision(TimePrecision::Second {
92 decimal_digits: None,
93 })
94 .encode();
95
96 OffsetDateTime::from_unix_timestamp_nanos(
97 t.duration_since(UNIX_EPOCH)
98 .expect("before unix epoch")
99 .as_nanos()
100 .try_into()
101 .expect("time overflowed"),
102 )
103 .expect("couldn't convert SystemTime to OffsetDateTime")
104 .format(&time::format_description::well_known::Iso8601::<ISO8601_SECS>)
105 .expect("couldn't format as ISO8601")
106}
107
108pub fn usecs_to_iso8601_secs(ts: u64) -> String {
110 systime_to_iso8601_secs(&(UNIX_EPOCH + Duration::from_micros(ts)))
111}
112
113pub fn duration_display(d: Duration) -> String {
115 let total_ms = d.as_millis();
116 if total_ms < 1000 {
117 format!("{total_ms}ms")
118 } else {
119 let s = d.as_secs();
120 let ms = d.subsec_millis();
121 format!("{s}.{ms:03}s")
122 }
123}
124
125impl fmt::Display for TransactionVisStatus {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 match self {
130 Self::Pending => write!(f, "pending"),
131 Self::Accepted => write!(f, "accepted"),
132 Self::Rejected(err) => write!(f, "rejected: {err}"),
133 Self::Completed(s) => write!(f, "{s}"),
134 }
135 }
136}
137
138impl fmt::Display for TxIoVisData {
139 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140 write!(
141 f,
142 "mod={} ({}) {}",
143 self.module_id, self.module_kind, self.display
144 )
145 }
146}
147
148impl fmt::Display for TransactionVisData {
149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150 if let Some(created_at) = &self.created_at {
151 let ts = systime_to_iso8601_secs(created_at);
152 writeln!(f, " tx {} [{}] {ts}", self.txid.fmt_short(), self.status)?;
153 } else {
154 writeln!(f, " tx {} [{}]", self.txid.fmt_short(), self.status)?;
155 }
156
157 if self.inputs.is_empty() && self.outputs.is_empty() {
158 writeln!(f, " (transaction data not available)")?;
159 } else {
160 if !self.inputs.is_empty() {
161 writeln!(f, " inputs:")?;
162 for (i, item) in self.inputs.iter().enumerate() {
163 writeln!(f, " [{i}] {item}")?;
164 }
165 }
166 if !self.outputs.is_empty() {
167 writeln!(f, " outputs:")?;
168 for (i, item) in self.outputs.iter().enumerate() {
169 writeln!(f, " [{i}] {item}")?;
170 }
171 }
172 }
173 Ok(())
174 }
175}
176
177impl fmt::Display for OperationTransactionsVisData {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 writeln!(
180 f,
181 "### Transactions for op {} ({})\n",
182 self.operation_id.fmt_full(),
183 self.operation_type
184 )?;
185
186 if self.transactions.is_empty() {
187 writeln!(f, " (no transactions found)")?;
188 }
189
190 for tx in &self.transactions {
191 write!(f, "{tx}")?;
192 }
193 writeln!(f)
194 }
195}
196
197impl fmt::Display for StateVisData {
198 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199 let status = if self.is_active { "active" } else { "done " };
200 let dur = self.exited_at.and_then(|ex| {
201 ex.duration_since(self.created_at)
202 .ok()
203 .map(|d| format!(" ({})", duration_display(d)))
204 });
205
206 write!(
207 f,
208 " [{status}] ({}) {}{}\n {}",
209 self.module_kind,
210 systime_to_iso8601_secs(&self.created_at),
211 dur.unwrap_or_default(),
212 self.visualization,
213 )
214 }
215}
216
217impl fmt::Display for OperationVisData {
218 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219 let ts = self
220 .creation_time
221 .as_ref()
222 .map_or_else(|| "-".to_string(), systime_to_iso8601_secs);
223 let status = if self.has_outcome { "done" } else { "pending" };
224
225 writeln!(
226 f,
227 "### Operation {} ({}) {ts} [{status}]\n",
228 self.operation_id.fmt_short(),
229 self.operation_type
230 )?;
231
232 if self.states.is_empty() {
233 writeln!(f, " (no state machines)")?;
234 }
235
236 for state in &self.states {
237 writeln!(f, "{state}")?;
238 }
239 writeln!(f)
240 }
241}
242
243pub struct OperationsVisOutput(pub Vec<OperationVisData>);
248
249impl fmt::Display for OperationsVisOutput {
250 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251 if self.0.is_empty() {
252 writeln!(f, " (no operations)")?;
253 return Ok(());
254 }
255
256 for op in &self.0 {
257 write!(f, "{op}")?;
258 }
259 Ok(())
260 }
261}
262
263pub struct TransactionsVisOutput(pub Vec<OperationTransactionsVisData>);
265
266impl fmt::Display for TransactionsVisOutput {
267 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268 for op in &self.0 {
269 write!(f, "{op}")?;
270 }
271 Ok(())
272 }
273}
274
275fn find_tx_final_status(
277 active: &[(DynState, ActiveStateMeta)],
278 inactive: &[(DynState, InactiveStateMeta)],
279 target_txid: TransactionId,
280) -> Option<String> {
281 let check_state = |s: &DynState| -> Option<String> {
282 if s.module_instance_id() != TRANSACTION_SUBMISSION_MODULE_INSTANCE {
283 return None;
284 }
285 let sm = s.as_any().downcast_ref::<TxSubmissionStatesSM>()?;
286 match &sm.state {
287 TxSubmissionStates::Accepted(id) if *id == target_txid => Some("accepted".to_string()),
288 TxSubmissionStates::Rejected(id, err) if *id == target_txid => {
289 Some(format!("rejected: {err}"))
290 }
291 _ => None,
292 }
293 };
294
295 for (s, _) in inactive {
297 if let Some(status) = check_state(s) {
298 return Some(status);
299 }
300 }
301 for (s, _) in active {
302 if let Some(status) = check_state(s) {
303 return Some(status);
304 }
305 }
306 None
307}
308
309impl Client {
310 async fn sm_module_to_string_map(&self) -> BTreeMap<ModuleInstanceId, String> {
312 let config = self.config().await;
313 let mut map: BTreeMap<ModuleInstanceId, String> = config
314 .modules
315 .iter()
316 .map(|(id, cfg)| (*id, cfg.kind.to_string()))
317 .collect();
318 map.insert(TRANSACTION_SUBMISSION_MODULE_INSTANCE, "tx".to_string());
319 map
320 }
321
322 async fn resolve_operations(
325 &self,
326 explicit: Option<OperationId>,
327 limit: Option<usize>,
328 ) -> Result<Vec<(OperationId, Option<SystemTime>, OperationLogEntry)>, OperationNotFoundError>
329 {
330 if let Some(id) = explicit {
331 let entry = self
332 .operation_log()
333 .get_operation(id)
334 .await
335 .ok_or(OperationNotFoundError { operation_id: id })?;
336 return Ok(vec![(id, None, entry)]);
337 }
338 let ops = self
339 .operation_log()
340 .paginate_operations_rev(limit.unwrap_or(usize::MAX), None)
341 .await;
342 Ok(ops
343 .into_iter()
344 .map(|(k, entry)| (k.operation_id, Some(k.creation_time), entry))
345 .collect())
346 }
347
348 pub async fn get_operations_vis(
350 &self,
351 operation_id: Option<OperationId>,
352 limit: Option<usize>,
353 ) -> Result<Vec<OperationVisData>, OperationNotFoundError> {
354 let ops: Vec<(OperationId, Option<SystemTime>, OperationLogEntry)> =
355 self.resolve_operations(operation_id, limit).await?;
356 let kinds = self.sm_module_to_string_map().await;
357
358 let mut result = Vec::with_capacity(ops.len());
359
360 for (op_id, creation_time, entry) in ops {
361 let (active, inactive) = self.executor().get_operation_states(op_id).await;
362
363 let mut states: Vec<StateVisData> = Vec::new();
364
365 for (state, meta) in &active {
366 states.push(StateVisData {
367 is_active: true,
368 module_id: state.module_instance_id(),
369 module_kind: module_kind_name(&kinds, state.module_instance_id()).to_string(),
370 created_at: meta.created_at,
371 exited_at: None,
372 visualization: state.visualization(""),
373 });
374 }
375
376 for (state, meta) in &inactive {
377 states.push(StateVisData {
378 is_active: false,
379 module_id: state.module_instance_id(),
380 module_kind: module_kind_name(&kinds, state.module_instance_id()).to_string(),
381 created_at: meta.created_at,
382 exited_at: Some(meta.exited_at),
383 visualization: state.visualization(""),
384 });
385 }
386
387 states.sort_by_key(|e| e.created_at);
388
389 result.push(OperationVisData {
390 operation_id: op_id,
391 creation_time,
392 operation_type: entry.operation_module_kind().to_string(),
393 has_outcome: entry.outcome::<serde_json::Value>().is_some(),
394 states,
395 });
396 }
397
398 Ok(result)
399 }
400
401 pub async fn get_transactions_vis(
403 &self,
404 operation_id: Option<OperationId>,
405 limit: Option<usize>,
406 ) -> Result<Vec<OperationTransactionsVisData>, OperationNotFoundError> {
407 let ops: Vec<(OperationId, Option<SystemTime>, OperationLogEntry)> =
408 self.resolve_operations(operation_id, limit).await?;
409 let kinds = self.sm_module_to_string_map().await;
410
411 let mut result = Vec::with_capacity(ops.len());
412
413 for (op_id, _, entry) in ops {
414 let (active, inactive) = self.executor().get_operation_states(op_id).await;
415
416 let mut transactions = Vec::new();
417 let mut seen_txids = HashSet::new();
418
419 for (state, meta) in &inactive {
422 if state.module_instance_id() != TRANSACTION_SUBMISSION_MODULE_INSTANCE {
423 continue;
424 }
425 let Some(tx_sm) = state.as_any().downcast_ref::<TxSubmissionStatesSM>() else {
426 continue;
427 };
428 let TxSubmissionStates::Created(tx) = &tx_sm.state else {
429 continue;
430 };
431
432 let txid: TransactionId = tx.tx_hash();
433 let final_status = find_tx_final_status(&active, &inactive, txid);
434 let status = match final_status {
435 Some(s) if s == "accepted" => TransactionVisStatus::Accepted,
436 Some(s) if s.starts_with("rejected: ") => {
437 TransactionVisStatus::Rejected(s["rejected: ".len()..].to_string())
438 }
439 Some(s) => TransactionVisStatus::Completed(s),
440 None => TransactionVisStatus::Completed("completed".to_string()),
441 };
442
443 let inputs = tx
444 .inputs
445 .iter()
446 .map(|input| TxIoVisData {
447 module_id: input.module_instance_id(),
448 module_kind: module_kind_name(&kinds, input.module_instance_id())
449 .to_string(),
450 display: input.to_string(),
451 })
452 .collect();
453
454 let outputs = tx
455 .outputs
456 .iter()
457 .map(|output| TxIoVisData {
458 module_id: output.module_instance_id(),
459 module_kind: module_kind_name(&kinds, output.module_instance_id())
460 .to_string(),
461 display: output.to_string(),
462 })
463 .collect();
464
465 transactions.push(TransactionVisData {
466 txid,
467 status,
468 created_at: Some(meta.created_at),
469 inputs,
470 outputs,
471 });
472 seen_txids.insert(txid);
473 }
474
475 for (state, meta) in &active {
477 if state.module_instance_id() != TRANSACTION_SUBMISSION_MODULE_INSTANCE {
478 continue;
479 }
480 let Some(tx_sm) = state.as_any().downcast_ref::<TxSubmissionStatesSM>() else {
481 continue;
482 };
483 let TxSubmissionStates::Created(tx) = &tx_sm.state else {
484 continue;
485 };
486
487 let txid: TransactionId = tx.tx_hash();
488 if seen_txids.contains(&txid) {
489 continue;
490 }
491
492 let inputs = tx
493 .inputs
494 .iter()
495 .map(|input| TxIoVisData {
496 module_id: input.module_instance_id(),
497 module_kind: module_kind_name(&kinds, input.module_instance_id())
498 .to_string(),
499 display: input.to_string(),
500 })
501 .collect();
502
503 let outputs = tx
504 .outputs
505 .iter()
506 .map(|output| TxIoVisData {
507 module_id: output.module_instance_id(),
508 module_kind: module_kind_name(&kinds, output.module_instance_id())
509 .to_string(),
510 display: output.to_string(),
511 })
512 .collect();
513
514 transactions.push(TransactionVisData {
515 txid,
516 status: TransactionVisStatus::Pending,
517 created_at: Some(meta.created_at),
518 inputs,
519 outputs,
520 });
521 seen_txids.insert(txid);
522 }
523
524 let all_for_final = inactive
526 .iter()
527 .map(|(s, _)| s)
528 .chain(active.iter().map(|(s, _)| s));
529
530 for state in all_for_final {
531 if state.module_instance_id() != TRANSACTION_SUBMISSION_MODULE_INSTANCE {
532 continue;
533 }
534 let Some(tx_sm) = state.as_any().downcast_ref::<TxSubmissionStatesSM>() else {
535 continue;
536 };
537 match &tx_sm.state {
538 TxSubmissionStates::Accepted(txid) if !seen_txids.contains(txid) => {
539 transactions.push(TransactionVisData {
540 txid: *txid,
541 status: TransactionVisStatus::Accepted,
542 created_at: None,
543 inputs: vec![],
544 outputs: vec![],
545 });
546 seen_txids.insert(*txid);
547 }
548 TxSubmissionStates::Rejected(txid, err) if !seen_txids.contains(txid) => {
549 transactions.push(TransactionVisData {
550 txid: *txid,
551 status: TransactionVisStatus::Rejected(err.clone()),
552 created_at: None,
553 inputs: vec![],
554 outputs: vec![],
555 });
556 seen_txids.insert(*txid);
557 }
558 _ => {}
559 }
560 }
561
562 result.push(OperationTransactionsVisData {
563 operation_id: op_id,
564 operation_type: entry.operation_module_kind().to_string(),
565 transactions,
566 });
567 }
568
569 Ok(result)
570 }
571}