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;
23
24/// Visualization data for a single operation and its state machines.
25pub 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
33/// Visualization data for a single state machine entry.
34pub 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
43/// Visualization data for transactions grouped under one operation.
44pub struct OperationTransactionsVisData {
45    pub operation_id: OperationId,
46    pub operation_type: String,
47    pub transactions: Vec<TransactionVisData>,
48}
49
50/// Visualization data for a single transaction.
51pub 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
59/// Status of a transaction for visualization purposes.
60pub enum TransactionVisStatus {
61    Pending,
62    Accepted,
63    Rejected(String),
64    Completed(String),
65}
66
67/// Visualization data for a transaction input or output.
68pub struct TxIoVisData {
69    pub module_id: ModuleInstanceId,
70    pub module_kind: String,
71    pub display: String,
72}
73
74/// Look up the kind name for a module instance ID.
75pub fn module_kind_name(kinds: &BTreeMap<ModuleInstanceId, String>, id: ModuleInstanceId) -> &str {
76    kinds.get(&id).map_or("unknown", String::as_str)
77}
78
79// ─── Formatting helpers ─────────────────────────────────────────────────────
80
81/// Format a `SystemTime` as ISO8601 with second precision.
82pub 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
107/// Format a microsecond Unix timestamp as ISO8601 with second precision.
108pub fn usecs_to_iso8601_secs(ts: u64) -> String {
109    systime_to_iso8601_secs(&(UNIX_EPOCH + Duration::from_micros(ts)))
110}
111
112/// Format a `Duration` for display (e.g. "42ms" or "1.234s").
113pub 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
124// ─── Display impls ──────────────────────────────────────────────────────────
125
126impl 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
242/// Complete operations visualization output, ready for display.
243///
244/// Wraps `Vec<OperationVisData>` and adds numbered listing in the `Display`
245/// impl.
246pub 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
262/// Complete transactions visualization output, ready for display.
263pub 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
274/// Find the final status of a transaction from its state machines.
275fn 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    // Check inactive states first (more likely to have final status)
295    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    /// Build a map from module instance ID to kind name.
310    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    /// Resolve operations: either a single explicit one, or the most recent
322    /// `limit` from the operation log.
323    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    /// Fetch visualization data for operations and their state machines.
347    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    /// Fetch visualization data for transactions grouped by operation.
400    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            // Collect from inactive Created states first (have full tx data and
418            // final status)
419            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            // Active Created states (still pending)
474            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            // Final states without a Created variant (no full tx data)
523            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}