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 }