/ crates / acdc-diagnostics / src / report.rs
report.rs
  1  //! Diagnostics report generation and debug bundle collection.
  2  
  3  use crate::issues::Issue;
  4  use crate::logs::LogAnalysis;
  5  use crate::system::SystemDiagnostics;
  6  use crate::DebugBundle;
  7  use acdc_core::Result;
  8  use chrono::{DateTime, Utc};
  9  use flate2::write::GzEncoder;
 10  use flate2::Compression;
 11  use serde::{Deserialize, Serialize};
 12  use std::path::PathBuf;
 13  use std::process::Command;
 14  
 15  /// Complete diagnostics report.
 16  #[derive(Debug, Clone, Serialize, Deserialize)]
 17  pub struct DiagnosticsReport {
 18      /// Report generation time
 19      pub timestamp: DateTime<Utc>,
 20      /// AC/DC version
 21      pub version: String,
 22      /// System diagnostics
 23      pub system: Option<SystemDiagnostics>,
 24      /// Log analysis
 25      pub log_analysis: Option<LogAnalysis>,
 26      /// Detected issues
 27      pub issues: Vec<Issue>,
 28      /// Summary
 29      pub summary: ReportSummary,
 30  }
 31  
 32  /// Report summary.
 33  #[derive(Debug, Clone, Serialize, Deserialize)]
 34  pub struct ReportSummary {
 35      /// Total issues
 36      pub total_issues: usize,
 37      /// Critical issues
 38      pub critical_count: usize,
 39      /// Error count
 40      pub error_count: usize,
 41      /// Warning count
 42      pub warning_count: usize,
 43      /// Overall health status
 44      pub health_status: String,
 45  }
 46  
 47  impl DiagnosticsReport {
 48      /// Format the report for display.
 49      pub fn format(&self) -> String {
 50          let mut output = String::new();
 51  
 52          output.push_str("AC/DC Diagnostics Report\n");
 53          output.push_str("========================\n\n");
 54          output.push_str(&format!("Generated: {}\n", self.timestamp));
 55          output.push_str(&format!("Version: {}\n\n", self.version));
 56  
 57          // Summary
 58          output.push_str("Summary\n");
 59          output.push_str("-------\n");
 60          output.push_str(&format!("Health Status: {}\n", self.summary.health_status));
 61          output.push_str(&format!("Total Issues: {}\n", self.summary.total_issues));
 62          if self.summary.critical_count > 0 {
 63              output.push_str(&format!("  Critical: {}\n", self.summary.critical_count));
 64          }
 65          if self.summary.error_count > 0 {
 66              output.push_str(&format!("  Errors: {}\n", self.summary.error_count));
 67          }
 68          if self.summary.warning_count > 0 {
 69              output.push_str(&format!("  Warnings: {}\n", self.summary.warning_count));
 70          }
 71          output.push('\n');
 72  
 73          // System info
 74          if let Some(ref system) = self.system {
 75              output.push_str("System Information\n");
 76              output.push_str("------------------\n");
 77              output.push_str(&format!("Hostname: {}\n", system.system.hostname));
 78              output.push_str(&format!(
 79                  "OS: {} {}\n",
 80                  system.system.os_name, system.system.os_version
 81              ));
 82              output.push_str(&format!("Kernel: {}\n", system.system.kernel_version));
 83              output.push_str(&format!(
 84                  "CPU: {} ({} cores, {:.1}% usage)\n",
 85                  system.cpu.model, system.cpu.cores, system.cpu.usage_percent
 86              ));
 87              output.push_str(&format!(
 88                  "Memory: {:.1}% used ({} / {})\n",
 89                  system.memory.usage_percent,
 90                  format_bytes(system.memory.used_bytes),
 91                  format_bytes(system.memory.total_bytes)
 92              ));
 93              output.push('\n');
 94          }
 95  
 96          // Issues
 97          if !self.issues.is_empty() {
 98              output.push_str("Issues Detected\n");
 99              output.push_str("---------------\n");
100              for issue in &self.issues {
101                  output.push_str(&format!(
102                      "[{}] {} - {}\n",
103                      issue.severity, issue.category, issue.title
104                  ));
105                  output.push_str(&format!("    {}\n", issue.description));
106                  if let Some(ref resolution) = issue.resolution {
107                      output.push_str(&format!("    Resolution: {}\n", resolution));
108                  }
109                  output.push('\n');
110              }
111          } else {
112              output.push_str("No issues detected.\n\n");
113          }
114  
115          // Log analysis
116          if let Some(ref logs) = self.log_analysis {
117              output.push_str("Log Analysis (Last 24h)\n");
118              output.push_str("-----------------------\n");
119              output.push_str(&format!("Total entries: {}\n", logs.total_entries));
120              output.push_str(&format!("Errors: {}\n", logs.error_count));
121              output.push_str(&format!("Warnings: {}\n", logs.warning_count));
122  
123              if !logs.pattern_counts.is_empty() {
124                  output.push_str("\nPattern Matches:\n");
125                  for (pattern, count) in &logs.pattern_counts {
126                      output.push_str(&format!("  {}: {}\n", pattern, count));
127                  }
128              }
129  
130              if !logs.recent_errors.is_empty() {
131                  output.push_str("\nRecent Errors:\n");
132                  for error in &logs.recent_errors {
133                      output.push_str(&format!("  - {}\n", truncate(error, 100)));
134                  }
135              }
136          }
137  
138          output
139      }
140  
141      /// Export as JSON.
142      pub fn to_json(&self) -> Result<String> {
143          serde_json::to_string_pretty(self)
144              .map_err(|e| acdc_core::Error::Config(format!("Failed to serialize report: {}", e)))
145      }
146  }
147  
148  /// Report builder.
149  pub struct ReportBuilder {
150      system: Option<SystemDiagnostics>,
151      log_analysis: Option<LogAnalysis>,
152      issues: Vec<Issue>,
153  }
154  
155  impl ReportBuilder {
156      /// Create a new report builder.
157      pub fn new() -> Self {
158          Self {
159              system: None,
160              log_analysis: None,
161              issues: Vec::new(),
162          }
163      }
164  
165      /// Add system diagnostics.
166      pub fn add_system_diagnostics(&mut self, diag: SystemDiagnostics) {
167          self.system = Some(diag);
168      }
169  
170      /// Add log analysis.
171      pub fn add_log_analysis(&mut self, analysis: LogAnalysis) {
172          self.log_analysis = Some(analysis);
173      }
174  
175      /// Add issues.
176      pub fn add_issues(&mut self, issues: Vec<Issue>) {
177          self.issues.extend(issues);
178      }
179  
180      /// Build the report.
181      pub fn build(self) -> DiagnosticsReport {
182          let critical_count = self
183              .issues
184              .iter()
185              .filter(|i| matches!(i.severity, crate::issues::IssueSeverity::Critical))
186              .count();
187          let error_count = self
188              .issues
189              .iter()
190              .filter(|i| matches!(i.severity, crate::issues::IssueSeverity::Error))
191              .count();
192          let warning_count = self
193              .issues
194              .iter()
195              .filter(|i| matches!(i.severity, crate::issues::IssueSeverity::Warning))
196              .count();
197  
198          let health_status = if critical_count > 0 {
199              "CRITICAL"
200          } else if error_count > 0 {
201              "DEGRADED"
202          } else if warning_count > 0 {
203              "WARNING"
204          } else {
205              "HEALTHY"
206          }
207          .to_string();
208  
209          let summary = ReportSummary {
210              total_issues: self.issues.len(),
211              critical_count,
212              error_count,
213              warning_count,
214              health_status,
215          };
216  
217          DiagnosticsReport {
218              timestamp: Utc::now(),
219              version: env!("CARGO_PKG_VERSION").to_string(),
220              system: self.system,
221              log_analysis: self.log_analysis,
222              issues: self.issues,
223              summary,
224          }
225      }
226  }
227  
228  impl Default for ReportBuilder {
229      fn default() -> Self {
230          Self::new()
231      }
232  }
233  
234  /// Collect debug bundle for support.
235  pub async fn collect_debug_bundle(node_id: Option<&str>) -> Result<DebugBundle> {
236      let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
237      let bundle_name = format!("ac-dc-debug-{}.tar.gz", timestamp);
238      let bundle_path = std::env::temp_dir().join(&bundle_name);
239  
240      let file = std::fs::File::create(&bundle_path)
241          .map_err(|e| acdc_core::Error::Other(format!("Failed to create bundle file: {}", e)))?;
242  
243      let encoder = GzEncoder::new(file, Compression::default());
244      let mut archive = tar::Builder::new(encoder);
245      let mut files = Vec::new();
246  
247      // Create temp directory for bundle contents
248      let temp_dir = std::env::temp_dir().join(format!("ac-dc-debug-{}", timestamp));
249      std::fs::create_dir_all(&temp_dir)
250          .map_err(|e| acdc_core::Error::Other(format!("Failed to create temp dir: {}", e)))?;
251  
252      // Collect diagnostics report
253      let report = crate::run_diagnostics(node_id).await?;
254      let report_path = temp_dir.join("diagnostics.json");
255      std::fs::write(&report_path, report.to_json()?)
256          .map_err(|e| acdc_core::Error::Other(format!("Failed to write diagnostics: {}", e)))?;
257      add_file_to_archive(&mut archive, &report_path, "diagnostics.json")?;
258      files.push("diagnostics.json".to_string());
259  
260      // Collect system info
261      let system_info = collect_system_info_text();
262      let sysinfo_path = temp_dir.join("system-info.txt");
263      std::fs::write(&sysinfo_path, &system_info)
264          .map_err(|e| acdc_core::Error::Other(format!("Failed to write system info: {}", e)))?;
265      add_file_to_archive(&mut archive, &sysinfo_path, "system-info.txt")?;
266      files.push("system-info.txt".to_string());
267  
268      // Collect logs
269      if let Some(id) = node_id {
270          if let Ok(logs) = collect_recent_logs(id).await {
271              let logs_path = temp_dir.join("logs.txt");
272              std::fs::write(&logs_path, &logs)
273                  .map_err(|e| acdc_core::Error::Other(format!("Failed to write logs: {}", e)))?;
274              add_file_to_archive(&mut archive, &logs_path, "logs.txt")?;
275              files.push("logs.txt".to_string());
276          }
277      }
278  
279      // Collect config (sanitized)
280      if let Ok(config) = collect_sanitized_config().await {
281          let config_path = temp_dir.join("config.txt");
282          std::fs::write(&config_path, &config)
283              .map_err(|e| acdc_core::Error::Other(format!("Failed to write config: {}", e)))?;
284          add_file_to_archive(&mut archive, &config_path, "config.txt")?;
285          files.push("config.txt".to_string());
286      }
287  
288      // Collect service status
289      let status = collect_service_status();
290      let status_path = temp_dir.join("services.txt");
291      std::fs::write(&status_path, &status)
292          .map_err(|e| acdc_core::Error::Other(format!("Failed to write service status: {}", e)))?;
293      add_file_to_archive(&mut archive, &status_path, "services.txt")?;
294      files.push("services.txt".to_string());
295  
296      // Collect network info
297      let network_info = collect_network_info();
298      let network_path = temp_dir.join("network.txt");
299      std::fs::write(&network_path, &network_info)
300          .map_err(|e| acdc_core::Error::Other(format!("Failed to write network info: {}", e)))?;
301      add_file_to_archive(&mut archive, &network_path, "network.txt")?;
302      files.push("network.txt".to_string());
303  
304      // Finalize archive
305      archive
306          .finish()
307          .map_err(|e| acdc_core::Error::Other(format!("Failed to finalize archive: {}", e)))?;
308  
309      // Cleanup temp dir
310      let _ = std::fs::remove_dir_all(&temp_dir);
311  
312      let size = std::fs::metadata(&bundle_path)
313          .map(|m| m.len())
314          .unwrap_or(0);
315  
316      Ok(DebugBundle {
317          archive_path: bundle_path,
318          size,
319          files,
320      })
321  }
322  
323  fn add_file_to_archive(
324      archive: &mut tar::Builder<GzEncoder<std::fs::File>>,
325      path: &std::path::Path,
326      name: &str,
327  ) -> Result<()> {
328      let mut file = std::fs::File::open(path)
329          .map_err(|e| acdc_core::Error::Other(format!("Failed to open file: {}", e)))?;
330  
331      archive
332          .append_file(name, &mut file)
333          .map_err(|e| acdc_core::Error::Other(format!("Failed to add file to archive: {}", e)))?;
334  
335      Ok(())
336  }
337  
338  fn collect_system_info_text() -> String {
339      let mut info = String::new();
340  
341      info.push_str("=== System Information ===\n\n");
342  
343      // uname -a
344      if let Ok(output) = Command::new("uname").arg("-a").output() {
345          info.push_str("uname -a:\n");
346          info.push_str(&String::from_utf8_lossy(&output.stdout));
347          info.push('\n');
348      }
349  
350      // lsb_release
351      if let Ok(output) = Command::new("lsb_release").arg("-a").output() {
352          info.push_str("\nlsb_release -a:\n");
353          info.push_str(&String::from_utf8_lossy(&output.stdout));
354          info.push('\n');
355      }
356  
357      // free -h
358      if let Ok(output) = Command::new("free").arg("-h").output() {
359          info.push_str("\nfree -h:\n");
360          info.push_str(&String::from_utf8_lossy(&output.stdout));
361          info.push('\n');
362      }
363  
364      // df -h
365      if let Ok(output) = Command::new("df").arg("-h").output() {
366          info.push_str("\ndf -h:\n");
367          info.push_str(&String::from_utf8_lossy(&output.stdout));
368          info.push('\n');
369      }
370  
371      // uptime
372      if let Ok(output) = Command::new("uptime").output() {
373          info.push_str("\nuptime:\n");
374          info.push_str(&String::from_utf8_lossy(&output.stdout));
375          info.push('\n');
376      }
377  
378      info
379  }
380  
381  async fn collect_recent_logs(node_id: &str) -> Result<String> {
382      let service_name = format!("ac-dc-{}", node_id);
383  
384      let output = Command::new("journalctl")
385          .args([
386              "-u",
387              &service_name,
388              "--since",
389              "24 hours ago",
390              "--no-pager",
391              "-n",
392              "1000",
393          ])
394          .output()
395          .map_err(|e| acdc_core::Error::Other(format!("Failed to get logs: {}", e)))?;
396  
397      Ok(String::from_utf8_lossy(&output.stdout).to_string())
398  }
399  
400  async fn collect_sanitized_config() -> Result<String> {
401      let config_dir = dirs::config_dir()
402          .map(|p| p.join("ac-dc"))
403          .unwrap_or_else(|| PathBuf::from("/etc/ac-dc"));
404  
405      let config_path = config_dir.join("config.toml");
406  
407      if !config_path.exists() {
408          return Ok("Configuration file not found.".to_string());
409      }
410  
411      let content = tokio::fs::read_to_string(&config_path)
412          .await
413          .map_err(|e| acdc_core::Error::Other(format!("Failed to read config: {}", e)))?;
414  
415      // Sanitize sensitive data
416      let sanitized = content
417          .lines()
418          .map(|line| {
419              let lower = line.to_lowercase();
420              if lower.contains("password")
421                  || lower.contains("secret")
422                  || lower.contains("key")
423                  || lower.contains("token")
424              {
425                  let parts: Vec<&str> = line.splitn(2, '=').collect();
426                  if parts.len() == 2 {
427                      format!("{}= [REDACTED]", parts[0])
428                  } else {
429                      "[REDACTED LINE]".to_string()
430                  }
431              } else {
432                  line.to_string()
433              }
434          })
435          .collect::<Vec<_>>()
436          .join("\n");
437  
438      Ok(sanitized)
439  }
440  
441  fn collect_service_status() -> String {
442      let mut status = String::new();
443  
444      status.push_str("=== Service Status ===\n\n");
445  
446      let services = [
447          "ac-dc-validator",
448          "ac-dc-prover",
449          "ac-dc-client",
450          "radicle-node",
451      ];
452  
453      for service in services {
454          if let Ok(output) = Command::new("systemctl")
455              .args(["status", service, "--no-pager"])
456              .output()
457          {
458              status.push_str(&format!("--- {} ---\n", service));
459              status.push_str(&String::from_utf8_lossy(&output.stdout));
460              status.push_str(&String::from_utf8_lossy(&output.stderr));
461              status.push_str("\n\n");
462          }
463      }
464  
465      status
466  }
467  
468  fn collect_network_info() -> String {
469      let mut info = String::new();
470  
471      info.push_str("=== Network Information ===\n\n");
472  
473      // ip addr
474      if let Ok(output) = Command::new("ip").arg("addr").output() {
475          info.push_str("ip addr:\n");
476          info.push_str(&String::from_utf8_lossy(&output.stdout));
477          info.push('\n');
478      }
479  
480      // ss -tlnp
481      if let Ok(output) = Command::new("ss").args(["-tlnp"]).output() {
482          info.push_str("\nss -tlnp (listening ports):\n");
483          info.push_str(&String::from_utf8_lossy(&output.stdout));
484          info.push('\n');
485      }
486  
487      // netstat connections
488      if let Ok(output) = Command::new("ss").args(["-tnp"]).output() {
489          info.push_str("\nss -tnp (connections):\n");
490          info.push_str(&String::from_utf8_lossy(&output.stdout));
491          info.push('\n');
492      }
493  
494      info
495  }
496  
497  fn format_bytes(bytes: u64) -> String {
498      const KB: u64 = 1024;
499      const MB: u64 = KB * 1024;
500      const GB: u64 = MB * 1024;
501  
502      if bytes >= GB {
503          format!("{:.1} GB", bytes as f64 / GB as f64)
504      } else if bytes >= MB {
505          format!("{:.1} MB", bytes as f64 / MB as f64)
506      } else if bytes >= KB {
507          format!("{:.1} KB", bytes as f64 / KB as f64)
508      } else {
509          format!("{} B", bytes)
510      }
511  }
512  
513  fn truncate(s: &str, max_len: usize) -> String {
514      if s.len() <= max_len {
515          s.to_string()
516      } else {
517          format!("{}...", &s[..max_len])
518      }
519  }