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