Skip to main content

ipfrs_cli/commands/
diag.rs

1//! `ipfrs diag` — Node diagnostics command.
2//!
3//! Collects and prints a snapshot of the running node's health, resource
4//! usage, and subsystem statistics.  If the daemon is offline a minimal
5//! offline report is shown instead.
6
7use anyhow::Result;
8
9// ---------------------------------------------------------------------------
10// DiagReport
11// ---------------------------------------------------------------------------
12
13/// Diagnostic snapshot collected from a running (or offline) node.
14#[derive(Debug, serde::Serialize)]
15pub struct DiagReport {
16    /// Whether the daemon process was reachable when the report was generated.
17    pub daemon_running: bool,
18    /// Libp2p peer ID string (e.g. `"12D3KooW…"`).
19    pub peer_id: Option<String>,
20    /// Number of peers currently connected.
21    pub connected_peers: Option<usize>,
22    /// Total number of blocks in storage.
23    pub storage_blocks: Option<u64>,
24    /// Total bytes occupying storage.
25    pub storage_bytes: Option<u64>,
26    /// Approximate in-process memory usage in bytes.
27    pub memory_bytes: Option<u64>,
28    /// Average inference latency across recent queries (milliseconds).
29    pub avg_inference_ms: Option<f64>,
30    /// Semantic cache hit rate in [0, 1].
31    pub cache_hit_rate: Option<f64>,
32    /// Number of TensorLogic rules loaded.
33    pub tensorlogic_rules: Option<usize>,
34    /// Number of TensorLogic facts loaded.
35    pub tensorlogic_facts: Option<usize>,
36    /// Number of HNSW vectors indexed.
37    pub hnsw_vectors: Option<u64>,
38    /// Node uptime in seconds.
39    pub uptime_secs: Option<u64>,
40}
41
42impl DiagReport {
43    /// Build a minimal report for when the daemon is not running.
44    ///
45    /// `daemon_running` is set to `false`; every optional field is `None`.
46    pub fn offline() -> Self {
47        Self {
48            daemon_running: false,
49            peer_id: None,
50            connected_peers: None,
51            storage_blocks: None,
52            storage_bytes: None,
53            memory_bytes: None,
54            avg_inference_ms: None,
55            cache_hit_rate: None,
56            tensorlogic_rules: None,
57            tensorlogic_facts: None,
58            hnsw_vectors: None,
59            uptime_secs: None,
60        }
61    }
62}
63
64// ---------------------------------------------------------------------------
65// Formatting helpers
66// ---------------------------------------------------------------------------
67
68/// Format a byte count into a human-readable string (B / KB / MB / GB).
69fn fmt_bytes(bytes: u64) -> String {
70    const KB: u64 = 1_024;
71    const MB: u64 = KB * 1_024;
72    const GB: u64 = MB * 1_024;
73
74    if bytes >= GB {
75        format!("{:.1} GB", bytes as f64 / GB as f64)
76    } else if bytes >= MB {
77        format!("{:.1} MB", bytes as f64 / MB as f64)
78    } else if bytes >= KB {
79        format!("{:.1} KB", bytes as f64 / KB as f64)
80    } else {
81        format!("{bytes} B")
82    }
83}
84
85/// Format an uptime given in seconds into `Xh Ym Zs`.
86fn fmt_uptime(secs: u64) -> String {
87    let h = secs / 3600;
88    let m = (secs % 3600) / 60;
89    let s = secs % 60;
90    if h > 0 {
91        format!("{h}h {m}m")
92    } else if m > 0 {
93        format!("{m}m {s}s")
94    } else {
95        format!("{s}s")
96    }
97}
98
99/// Format a large integer with thousands separators.
100fn fmt_count(n: u64) -> String {
101    let s = n.to_string();
102    let mut out = String::with_capacity(s.len() + s.len() / 3);
103    for (i, ch) in s.chars().rev().enumerate() {
104        if i > 0 && i % 3 == 0 {
105            out.push(',');
106        }
107        out.push(ch);
108    }
109    out.chars().rev().collect()
110}
111
112/// Print the human-readable text report to stdout.
113fn print_text_report(report: &DiagReport) {
114    println!("=== IPFRS Node Diagnostics ===");
115
116    let daemon_str = if report.daemon_running {
117        "running"
118    } else {
119        "not running"
120    };
121    println!("{:<20} {}", "Daemon:", daemon_str);
122
123    if let Some(ref pid) = report.peer_id {
124        println!("{:<20} {}", "Peer ID:", pid);
125    }
126
127    if let Some(peers) = report.connected_peers {
128        println!("{:<20} {}", "Connected peers:", peers);
129    }
130
131    if let Some(blocks) = report.storage_blocks {
132        println!("{:<20} {}", "Storage blocks:", fmt_count(blocks));
133    }
134
135    if let Some(bytes) = report.storage_bytes {
136        println!("{:<20} {}", "Storage bytes:", fmt_bytes(bytes));
137    }
138
139    if let Some(mem) = report.memory_bytes {
140        println!("{:<20} {}", "Memory:", fmt_bytes(mem));
141    }
142
143    if let Some(ms) = report.avg_inference_ms {
144        println!("{:<20} {:.1} ms", "Avg inference:", ms);
145    }
146
147    if let Some(rate) = report.cache_hit_rate {
148        println!("{:<20} {:.1}%", "Cache hit rate:", rate * 100.0);
149    }
150
151    if let (Some(rules), Some(facts)) = (report.tensorlogic_rules, report.tensorlogic_facts) {
152        println!("{:<20} {} rules, {} facts", "TensorLogic:", rules, facts);
153    }
154
155    if let Some(vecs) = report.hnsw_vectors {
156        println!("{:<20} {}", "HNSW vectors:", fmt_count(vecs));
157    }
158
159    if let Some(secs) = report.uptime_secs {
160        println!("{:<20} {}", "Uptime:", fmt_uptime(secs));
161    }
162}
163
164// ---------------------------------------------------------------------------
165// handle_diag
166// ---------------------------------------------------------------------------
167
168/// Handle the `ipfrs diag` subcommand.
169///
170/// Attempts to start a node locally to collect live diagnostics.  If the
171/// daemon is not running (or the node cannot be initialised) an offline report
172/// is printed instead.
173///
174/// # Arguments
175///
176/// * `json_output` — when `true` the report is serialised as JSON; otherwise
177///   a human-readable table is printed.
178pub async fn handle_diag(json_output: bool) -> Result<()> {
179    // Attempt to bring up a transient node to collect diagnostics.
180    let report = match try_collect_diagnostics().await {
181        Ok(r) => r,
182        Err(_) => {
183            if !json_output {
184                eprintln!("Daemon not running");
185            }
186            DiagReport::offline()
187        }
188    };
189
190    if json_output {
191        let json = serde_json::to_string_pretty(&report)?;
192        println!("{json}");
193    } else {
194        print_text_report(&report);
195    }
196
197    Ok(())
198}
199
200/// Inner helper: start a node, query it, stop it, return the [`DiagReport`].
201async fn try_collect_diagnostics() -> Result<DiagReport> {
202    use ipfrs::{Node, NodeConfig};
203
204    let mut node = Node::new(NodeConfig::default())?;
205    node.start().await?;
206
207    let diag = node.diagnostics().await?;
208    let peer_id = node.peer_id().ok();
209
210    node.stop().await?;
211
212    let storage_blocks = Some(diag.storage.total_blocks);
213    let storage_bytes = Some(diag.storage.total_bytes);
214    let memory_bytes = Some(diag.resources.memory_bytes);
215    let uptime_secs = Some(diag.uptime.as_secs());
216
217    let (avg_inference_ms, tensorlogic_rules, tensorlogic_facts) =
218        if let Some(ref tl) = diag.tensorlogic {
219            (tl.avg_inference_ms, Some(tl.num_rules), Some(tl.num_facts))
220        } else {
221            (None, None, None)
222        };
223
224    let (cache_hit_rate, hnsw_vectors) = if let Some(ref sem) = diag.semantic {
225        (sem.cache_hit_rate, Some(sem.num_vectors as u64))
226    } else {
227        (None, None)
228    };
229
230    let connected_peers = diag.network.as_ref().map(|n| n.connected_peers);
231
232    Ok(DiagReport {
233        daemon_running: true,
234        peer_id,
235        connected_peers,
236        storage_blocks,
237        storage_bytes,
238        memory_bytes,
239        avg_inference_ms,
240        cache_hit_rate,
241        tensorlogic_rules,
242        tensorlogic_facts,
243        hnsw_vectors,
244        uptime_secs,
245    })
246}
247
248// ---------------------------------------------------------------------------
249// Tests
250// ---------------------------------------------------------------------------
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_offline_report_all_none() {
258        let r = DiagReport::offline();
259        assert!(!r.daemon_running);
260        assert!(r.peer_id.is_none());
261        assert!(r.connected_peers.is_none());
262        assert!(r.storage_blocks.is_none());
263        assert!(r.storage_bytes.is_none());
264        assert!(r.memory_bytes.is_none());
265        assert!(r.avg_inference_ms.is_none());
266        assert!(r.cache_hit_rate.is_none());
267        assert!(r.tensorlogic_rules.is_none());
268        assert!(r.tensorlogic_facts.is_none());
269        assert!(r.hnsw_vectors.is_none());
270        assert!(r.uptime_secs.is_none());
271    }
272
273    #[test]
274    fn test_diag_report_json_serialization() {
275        let r = DiagReport {
276            daemon_running: true,
277            peer_id: Some("12D3KooWTest".to_owned()),
278            connected_peers: Some(3),
279            storage_blocks: Some(1_247),
280            storage_bytes: Some(44_369_510),
281            memory_bytes: Some(134_700_032),
282            avg_inference_ms: Some(12.3),
283            cache_hit_rate: Some(0.874),
284            tensorlogic_rules: Some(42),
285            tensorlogic_facts: Some(156),
286            hnsw_vectors: Some(10_000),
287            uptime_secs: Some(12_240),
288        };
289
290        let json = serde_json::to_string(&r).expect("serialization failed");
291        let back: serde_json::Value = serde_json::from_str(&json).expect("deserialization failed");
292
293        assert_eq!(back["daemon_running"], true);
294        assert_eq!(back["peer_id"], "12D3KooWTest");
295        assert_eq!(back["connected_peers"], 3);
296        assert_eq!(back["storage_blocks"], 1_247);
297        assert_eq!(back["hnsw_vectors"], 10_000);
298        assert_eq!(back["tensorlogic_rules"], 42);
299        assert_eq!(back["tensorlogic_facts"], 156);
300    }
301
302    #[test]
303    fn test_diag_report_debug() {
304        let r = DiagReport::offline();
305        let dbg = format!("{r:?}");
306        assert!(dbg.contains("daemon_running: false"));
307    }
308
309    #[test]
310    fn test_fmt_bytes() {
311        assert_eq!(fmt_bytes(512), "512 B");
312        assert_eq!(fmt_bytes(1_536), "1.5 KB");
313        assert_eq!(fmt_bytes(44_369_510), "42.3 MB");
314        assert_eq!(fmt_bytes(2_147_483_648), "2.0 GB");
315    }
316
317    #[test]
318    fn test_fmt_uptime() {
319        assert_eq!(fmt_uptime(30), "30s");
320        assert_eq!(fmt_uptime(90), "1m 30s");
321        assert_eq!(fmt_uptime(12_240), "3h 24m");
322    }
323
324    #[test]
325    fn test_fmt_count() {
326        assert_eq!(fmt_count(0), "0");
327        assert_eq!(fmt_count(1_247), "1,247");
328        assert_eq!(fmt_count(10_000_000), "10,000,000");
329    }
330}