Skip to main content

fedimint_client/
visualize.rs

1//! Visualization data structures and data-fetching for client internals.
2//!
3//! Provides structured data for operations and transactions that can be
4//! formatted by downstream consumers (CLI, GUI, etc.).
5//!
6//! Each data struct implements [`fmt::Display`] for text rendering.
7//! Consumers who want custom formatting can use the public fields directly.
8
9use 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
25/// Visualization data for a single operation and its state machines.
26pub 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
34/// Visualization data for a single state machine entry.
35pub 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
44/// Visualization data for transactions grouped under one operation.
45pub struct OperationTransactionsVisData {
46    pub operation_id: OperationId,
47    pub operation_type: String,
48    pub transactions: Vec<TransactionVisData>,
49}
50
51/// Visualization data for a single transaction.
52pub 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
60/// Status of a transaction for visualization purposes.
61pub enum TransactionVisStatus {
62    Pending,
63    Accepted,
64    Rejected(String),
65    Completed(String),
66}
67
68/// Visualization data for a transaction input or output.
69pub struct TxIoVisData {
70    pub module_id: ModuleInstanceId,
71    pub module_kind: String,
72    pub display: String,
73}
74
75/// Look up the kind name for a module instance ID.
76pub fn module_kind_name(kinds: &BTreeMap<ModuleInstanceId, String>, id: ModuleInstanceId) -> &str {
77    kinds.get(&id).map_or("unknown", String::as_str)
78}
79
80// ─── Formatting helpers ─────────────────────────────────────────────────────
81
82/// Format a `SystemTime` as ISO8601 with second precision.
83pub 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
108/// Format a microsecond Unix timestamp as ISO8601 with second precision.
109pub fn usecs_to_iso8601_secs(ts: u64) -> String {
110    systime_to_iso8601_secs(&(UNIX_EPOCH + Duration::from_micros(ts)))
111}
112
113/// Format a `Duration` for display (e.g. "42ms" or "1.234s").
114pub 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
125// ─── Display impls ──────────────────────────────────────────────────────────
126
127impl 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
243/// Complete operations visualization output, ready for display.
244///
245/// Wraps `Vec<OperationVisData>` and adds numbered listing in the `Display`
246/// impl.
247pub 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
263/// Complete transactions visualization output, ready for display.
264pub 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
275/// Find the final status of a transaction from its state machines.
276fn 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    // Check inactive states first (more likely to have final status)
296    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    /// Build a map from module instance ID to kind name.
311    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    /// Resolve operations: either a single explicit one, or the most recent
323    /// `limit` from the operation log.
324    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    /// Fetch visualization data for operations and their state machines.
349    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    /// Fetch visualization data for transactions grouped by operation.
402    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            // Collect from inactive Created states first (have full tx data and
420            // final status)
421            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            // Active Created states (still pending)
476            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            // Final states without a Created variant (no full tx data)
525            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}