1use crate::logging::{log_info, log_warn};
2use crate::strategies::ProjectDetector;
3use crate::utils::{collect_listening_port_ownership, command_exists, ListeningPortOwnership};
4use anyhow::Result;
5use colored::Colorize;
6use indicatif::{ProgressBar, ProgressStyle};
7use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, HashMap};
10use std::env;
11use std::fs;
12use std::fs::OpenOptions;
13use std::path::PathBuf;
14use std::time::{Duration, Instant};
15use sysinfo::{Disks, Networks, System};
16use tokio::process::Command;
17
18#[derive(Debug, Serialize, Deserialize)]
19pub struct SystemMetrics {
20 pub cpu_usage: f32,
21 pub memory_total: u64,
22 pub memory_used: u64,
23 pub memory_percent: f32,
24 pub disk_total: u64,
25 pub disk_used: u64,
26 pub disk_percent: f32,
27 pub network_rx: u64,
28 pub network_tx: u64,
29 pub uptime: u64,
30 pub process_count: usize,
31}
32
33#[derive(Debug, Serialize, Deserialize)]
34pub struct PortCheck {
35 pub port: u16,
36 pub is_open: bool,
37 pub is_blocked: bool,
38 pub pids: Vec<u32>,
39 pub xbp_projects: Vec<String>,
40}
41
42#[derive(Debug, Serialize, Deserialize)]
43pub struct InternetSpeed {
44 pub download_mbps: f64,
45 pub upload_mbps: f64,
46 pub ping_ms: f64,
47}
48
49#[derive(Debug, Serialize, Deserialize)]
50pub struct OsInfo {
51 pub name: Option<String>,
52 pub version: Option<String>,
53 pub kernel_version: Option<String>,
54 pub arch: Option<String>,
55}
56
57#[derive(Debug, Serialize, Deserialize)]
58pub struct CpuInfo {
59 pub brand: Option<String>,
60 pub cores: usize,
61 pub usage: f32,
62}
63
64#[derive(Debug, Serialize, Deserialize)]
65pub struct DiskInfo {
66 pub mount: String,
67 pub fs: Option<String>,
68 pub total: u64,
69 pub used: u64,
70 pub percent: f32,
71 pub bar: String,
72 pub is_current: bool,
73}
74
75#[derive(Debug, Serialize, Deserialize)]
76pub struct ShellInfo {
77 pub shell: Option<String>,
78 pub term: Option<String>,
79}
80
81#[derive(Debug, Serialize, Deserialize)]
82pub struct ProxyDetection {
83 pub nginx_sites_available: Option<usize>,
84 pub nginx_active: Option<bool>,
85 pub traefik_active: Option<bool>,
86 pub apache_active: Option<bool>,
87}
88
89#[derive(Debug, Serialize, Deserialize)]
90pub struct ExposureInfo {
91 pub public_ipv4: Option<String>,
92 pub listening_on_all_interfaces: Vec<String>,
93 pub firewall_hint: Option<String>,
94 pub summary: Option<String>,
95}
96
97#[derive(Debug, Serialize, Deserialize)]
98pub struct ToolVersion {
99 pub present: bool,
100 pub version: Option<String>,
101}
102
103#[derive(Debug, Serialize, Deserialize)]
104pub struct PathPermissionCheck {
105 pub path: String,
106 pub purpose: String,
107 pub required: String,
108 pub exists: bool,
109 pub readable: bool,
110 pub writable: bool,
111 pub creatable: bool,
112 pub ok: bool,
113 pub message: Option<String>,
114}
115
116#[derive(Debug, Serialize, Deserialize)]
117pub struct DiagnosticReport {
118 pub system_metrics: SystemMetrics,
119 pub os: Option<OsInfo>,
120 pub cpu: Option<CpuInfo>,
121 pub gpu_candidates: Vec<String>,
122 pub disks: Vec<DiskInfo>,
123 pub shell: Option<ShellInfo>,
124 pub proxy_detection: Option<ProxyDetection>,
125 pub clipboard_tools: HashMap<String, bool>,
126 pub exposure: Option<ExposureInfo>,
127 pub pm2_process_count: Option<usize>,
128 pub tool_versions: HashMap<String, ToolVersion>,
129 pub service_statuses: HashMap<String, String>,
130 pub feature_flags: HashMap<String, bool>,
131 pub xbp_cli_version: String,
132 pub provider_manifests: Vec<String>,
133 pub nginx_status: Option<NginxStatus>,
134 pub port_checks: Vec<PortCheck>,
135 pub internet_speed: Option<InternetSpeed>,
136 pub connectivity: bool,
137 pub installed_programs: HashMap<String, bool>,
138 pub path_permission_checks: Vec<PathPermissionCheck>,
139}
140
141#[derive(Debug, Serialize, Deserialize)]
142pub struct NginxStatus {
143 pub is_running: bool,
144 pub is_enabled: bool,
145 pub config_valid: bool,
146 pub error: Option<String>,
147}
148
149const PM2_DIAG_TIMEOUT: Duration = Duration::from_secs(12);
150const PORT_OWNERSHIP_TIMEOUT: Duration = Duration::from_secs(20);
151const PER_PORT_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
152const TOOL_VERSION_TIMEOUT: Duration = Duration::from_secs(10);
153const PROGRAM_CHECK_TIMEOUT: Duration = Duration::from_secs(3);
154const COMMAND_CAPTURE_TIMEOUT: Duration = Duration::from_secs(5);
155const INTERNET_SPEED_TIMEOUT: Duration = Duration::from_secs(20);
156const NGINX_CHECK_TIMEOUT: Duration = Duration::from_secs(4);
157const DOWNLOAD_PROBE_REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
158const DOWNLOAD_PROBE_MAX_DURATION: Duration = Duration::from_secs(8);
159const DOWNLOAD_PROBE_TARGET_BYTES: u64 = 8_000_000;
160const DOWNLOAD_PROBE_MIN_BYTES: u64 = 1_000_000;
161const DOWNLOAD_PROBE_URLS: &[&str] = &[
162 "https://speed.cloudflare.com/__down?bytes=20000000",
163 "https://speed.hetzner.de/10MB.bin",
164 "https://proof.ovh.net/files/10Mb.dat",
165];
166
167#[derive(Debug, Clone, Copy, Default)]
168pub struct DiagnosticRunOptions {
169 pub skip_speed_test: bool,
170}
171
172pub async fn get_system_metrics() -> Result<SystemMetrics> {
173 let mut sys = System::new_all();
174 sys.refresh_all();
175
176 let cpu_usage =
177 sys.cpus().iter().map(|cpu| cpu.cpu_usage()).sum::<f32>() / sys.cpus().len() as f32;
178 let memory_total = sys.total_memory();
179 let memory_used = sys.used_memory();
180 let memory_percent = (memory_used as f32 / memory_total as f32) * 100.0;
181
182 let mut disk_total = 0;
183 let mut disk_used = 0;
184 for disk in Disks::new_with_refreshed_list().iter() {
185 disk_total += disk.total_space();
186 disk_used += disk.total_space() - disk.available_space();
187 }
188 let disk_percent = if disk_total > 0 {
189 (disk_used as f32 / disk_total as f32) * 100.0
190 } else {
191 0.0
192 };
193
194 let networks = Networks::new_with_refreshed_list();
195 let mut network_rx = 0;
196 let mut network_tx = 0;
197 for (_interface_name, network) in &networks {
198 network_rx += network.total_received();
199 network_tx += network.total_transmitted();
200 }
201
202 let uptime = System::uptime();
203 let process_count = sys.processes().len();
204
205 Ok(SystemMetrics {
206 cpu_usage,
207 memory_total,
208 memory_used,
209 memory_percent,
210 disk_total,
211 disk_used,
212 disk_percent,
213 network_rx,
214 network_tx,
215 uptime,
216 process_count,
217 })
218}
219
220pub async fn check_nginx_status() -> Result<NginxStatus> {
221 let is_running: bool = check_systemctl_status("nginx").await?;
222 let is_enabled: bool = check_systemctl_enabled("nginx").await?;
223
224 let config_valid: bool = if is_running {
225 match Command::new("nginx").arg("-t").output().await {
226 Ok(output) => output.status.success(),
227 Err(_) => false,
228 }
229 } else {
230 false
231 };
232
233 Ok(NginxStatus {
234 is_running,
235 is_enabled,
236 config_valid,
237 error: None,
238 })
239}
240
241async fn check_systemctl_status(service: &str) -> Result<bool> {
242 if !cfg!(target_os = "linux") || !command_exists("systemctl") {
243 return Ok(false);
244 }
245
246 let output = Command::new("systemctl")
247 .arg("is-active")
248 .arg(service)
249 .output()
250 .await?;
251
252 Ok(output.status.success())
253}
254
255async fn check_systemctl_enabled(service: &str) -> Result<bool> {
256 if !cfg!(target_os = "linux") || !command_exists("systemctl") {
257 return Ok(false);
258 }
259
260 let output = Command::new("systemctl")
261 .arg("is-enabled")
262 .arg(service)
263 .output()
264 .await?;
265
266 Ok(output.status.success())
267}
268
269pub async fn check_port_availability(
270 port: u16,
271 ownership: Option<&ListeningPortOwnership>,
272) -> Result<PortCheck> {
273 use std::net::TcpListener;
274
275 let is_open: bool = TcpListener::bind(format!("127.0.0.1:{}", port)).is_ok();
276
277 let is_blocked: bool = if cfg!(target_os = "linux") {
278 check_firewall_blocked(port).await.unwrap_or(false)
279 } else {
280 false
281 };
282
283 Ok(PortCheck {
284 port,
285 is_open,
286 is_blocked,
287 pids: ownership
288 .map(|value| value.pids.clone())
289 .unwrap_or_default(),
290 xbp_projects: ownership
291 .map(|value| value.xbp_projects.clone())
292 .unwrap_or_default(),
293 })
294}
295
296async fn check_firewall_blocked(port: u16) -> Result<bool> {
297 let output: std::process::Output = Command::new("iptables")
298 .arg("-L")
299 .arg("-n")
300 .output()
301 .await?;
302
303 if !output.status.success() {
304 return Ok(false);
305 }
306
307 let stdout = String::from_utf8_lossy(&output.stdout);
308 let port_str = port.to_string();
309 Ok(stdout.contains(&format!("dpt:{}", port_str)) && stdout.contains("DROP"))
310}
311
312pub async fn check_internet_connectivity() -> Result<bool> {
313 let result: std::process::Output = if cfg!(target_os = "windows") {
314 Command::new("ping")
315 .arg("-n")
316 .arg("1")
317 .arg("-w")
318 .arg("2000")
319 .arg("8.8.8.8")
320 .output()
321 .await?
322 } else {
323 Command::new("ping")
324 .arg("-c")
325 .arg("1")
326 .arg("-W")
327 .arg("2")
328 .arg("8.8.8.8")
329 .output()
330 .await?
331 };
332
333 Ok(result.status.success())
334}
335
336pub async fn measure_internet_speed() -> Result<InternetSpeed> {
337 if let Ok(speed) = measure_speed_with_speedtest_cli().await {
338 if speed.download_mbps >= 1.0 || speed.upload_mbps >= 0.5 {
339 return Ok(speed);
340 }
341 }
342
343 let ping: f64 = measure_ping().await?;
344 let download_mbps = measure_download_speed().await.unwrap_or(0.0);
345 let upload_mbps = 0.0;
346
347 Ok(InternetSpeed {
348 download_mbps,
349 upload_mbps,
350 ping_ms: ping,
351 })
352}
353
354fn parse_speed_value(token: &str) -> Option<f64> {
355 token.trim().replace(',', ".").parse::<f64>().ok()
356}
357
358async fn measure_speed_with_speedtest_cli() -> Result<InternetSpeed> {
359 let output: std::process::Output = Command::new("speedtest-cli")
360 .arg("--simple")
361 .output()
362 .await?;
363
364 if !output.status.success() {
365 return Err(anyhow::anyhow!("speedtest-cli failed"));
366 }
367
368 let stdout: std::borrow::Cow<'_, str> = String::from_utf8_lossy(&output.stdout);
369 let mut ping_ms: f64 = 0.0;
370 let mut download_mbps: f64 = 0.0;
371 let mut upload_mbps: f64 = 0.0;
372
373 for line in stdout.lines() {
374 if line.starts_with("Ping:") {
375 if let Some(val) = line.split_whitespace().nth(1) {
376 ping_ms = parse_speed_value(val).unwrap_or(0.0);
377 }
378 } else if line.starts_with("Download:") {
379 if let Some(val) = line.split_whitespace().nth(1) {
380 download_mbps = parse_speed_value(val).unwrap_or(0.0);
381 }
382 } else if line.starts_with("Upload:") {
383 if let Some(val) = line.split_whitespace().nth(1) {
384 upload_mbps = parse_speed_value(val).unwrap_or(0.0);
385 }
386 }
387 }
388
389 Ok(InternetSpeed {
390 download_mbps,
391 upload_mbps,
392 ping_ms,
393 })
394}
395
396async fn measure_ping() -> Result<f64> {
397 let output: std::process::Output = if cfg!(target_os = "windows") {
398 Command::new("ping")
399 .arg("-n")
400 .arg("4")
401 .arg("8.8.8.8")
402 .output()
403 .await?
404 } else {
405 Command::new("ping")
406 .arg("-c")
407 .arg("4")
408 .arg("8.8.8.8")
409 .output()
410 .await?
411 };
412
413 if !output.status.success() {
414 return Ok(0.0);
415 }
416
417 let stdout = String::from_utf8_lossy(&output.stdout);
418
419 if cfg!(target_os = "windows") {
420 for line in stdout.lines() {
421 if line.contains("Average") {
422 if let Some(avg_part) = line.split('=').next_back() {
423 let avg_str = avg_part.trim().trim_end_matches("ms");
424 if let Ok(avg) = avg_str.parse::<f64>() {
425 return Ok(avg);
426 }
427 }
428 }
429 }
430 } else {
431 for line in stdout.lines() {
432 if line.contains("avg") || line.contains("rtt") {
433 if let Some(avg_str) = line.split('/').nth(4) {
434 if let Ok(avg) = avg_str.trim().parse::<f64>() {
435 return Ok(avg);
436 }
437 }
438 }
439 }
440 }
441
442 Ok(0.0)
443}
444
445async fn measure_download_speed() -> Result<f64> {
446 let client: reqwest::Client = reqwest::Client::builder()
447 .timeout(DOWNLOAD_PROBE_REQUEST_TIMEOUT)
448 .build()?;
449 let mut last_error: Option<String> = None;
450
451 for url in DOWNLOAD_PROBE_URLS {
452 match measure_download_speed_probe(&client, url).await {
453 Ok(mbps) => return Ok(mbps),
454 Err(err) => last_error = Some(err.to_string()),
455 }
456 }
457
458 Err(anyhow::anyhow!(
459 "all download probes failed{}",
460 last_error
461 .map(|value| format!(" (last error: {})", value))
462 .unwrap_or_default()
463 ))
464}
465
466async fn measure_download_speed_probe(client: &reqwest::Client, url: &str) -> Result<f64> {
467 let start = Instant::now();
468 let mut response: reqwest::Response = client
469 .get(url)
470 .header("Cache-Control", "no-cache")
471 .header("Pragma", "no-cache")
472 .send()
473 .await?;
474
475 if !response.status().is_success() {
476 return Err(anyhow::anyhow!(
477 "download probe {} returned status {}",
478 url,
479 response.status()
480 ));
481 }
482
483 let mut total_bytes: u64 = 0;
484
485 loop {
486 if total_bytes >= DOWNLOAD_PROBE_TARGET_BYTES
487 || start.elapsed() >= DOWNLOAD_PROBE_MAX_DURATION
488 {
489 break;
490 }
491
492 let chunk = response.chunk().await?;
493 let Some(bytes) = chunk else {
494 break;
495 };
496
497 total_bytes = total_bytes.saturating_add(bytes.len() as u64);
498 }
499
500 if total_bytes < DOWNLOAD_PROBE_MIN_BYTES {
501 return Err(anyhow::anyhow!(
502 "insufficient sample from {}: {} bytes",
503 url,
504 total_bytes
505 ));
506 }
507
508 let elapsed_seconds = start.elapsed().as_secs_f64().max(0.001);
509 let megabits = (total_bytes as f64 * 8.0) / 1_000_000.0;
510 Ok(megabits / elapsed_seconds)
511}
512
513pub async fn check_installed_programs() -> HashMap<String, bool> {
514 let programs = vec![
515 "jq",
516 "curl",
517 "nginx",
518 "git",
519 "docker",
520 "kubectl",
521 "microk8s",
522 "node",
523 "npm",
524 "python",
525 "python3",
526 "pip",
527 "pip3",
528 "cargo",
529 "rustc",
530 "pm2",
531 "speedtest-cli",
532 "systemctl",
533 "launchctl",
534 ];
535
536 let mut results = HashMap::new();
537
538 for program in programs {
539 let is_installed = check_program_installed(program).await;
540 results.insert(program.to_string(), is_installed);
541 }
542
543 results
544}
545
546fn can_create_file_in_dir(dir: &std::path::Path) -> bool {
547 use std::time::{SystemTime, UNIX_EPOCH};
548
549 if !dir.is_dir() {
550 return false;
551 }
552
553 let nonce = SystemTime::now()
554 .duration_since(UNIX_EPOCH)
555 .map(|d| d.as_nanos())
556 .unwrap_or_default();
557 let probe = dir.join(format!(
558 ".xbp_diag_perm_probe_{}_{}",
559 std::process::id(),
560 nonce
561 ));
562
563 match OpenOptions::new().write(true).create_new(true).open(&probe) {
564 Ok(_) => {
565 let _ = fs::remove_file(&probe);
566 true
567 }
568 Err(_) => false,
569 }
570}
571
572fn check_path_permission_entry(
573 path: PathBuf,
574 purpose: &str,
575 require_read: bool,
576 require_write: bool,
577 require_create: bool,
578 optional: bool,
579) -> PathPermissionCheck {
580 let exists = path.exists();
581 let mut readable = false;
582 let mut writable = false;
583 let mut creatable = false;
584 let mut message = None;
585
586 if exists {
587 if path.is_dir() {
588 readable = fs::read_dir(&path).is_ok();
589 writable = can_create_file_in_dir(&path);
590 creatable = writable;
591 } else if path.is_file() {
592 readable = fs::File::open(&path).is_ok();
593 writable = OpenOptions::new().write(true).open(&path).is_ok();
594 if let Some(parent) = path.parent() {
595 creatable = can_create_file_in_dir(parent);
596 }
597 } else {
598 message = Some("Path exists but is not a regular file/directory".to_string());
599 }
600 } else if let Some(parent) = path.parent() {
601 creatable = can_create_file_in_dir(parent);
603 readable = parent.exists() && fs::read_dir(parent).is_ok();
604 writable = parent.exists() && can_create_file_in_dir(parent);
605 if optional {
606 message = Some("Path missing (optional)".to_string());
607 } else if require_create && !creatable {
608 message =
609 Some("Path missing and cannot be created with current permissions".to_string());
610 } else {
611 message = Some("Path missing".to_string());
612 }
613 } else if optional {
614 message = Some("Path missing (optional)".to_string());
615 } else {
616 message = Some("Path missing".to_string());
617 }
618
619 let mut missing = Vec::new();
620 if require_read && !readable {
621 missing.push("read");
622 }
623 if require_write && !writable {
624 missing.push("write");
625 }
626 if require_create && !creatable {
627 missing.push("create");
628 }
629
630 let mut ok = missing.is_empty();
631 if optional && !exists {
632 ok = true;
633 }
634 if !ok {
635 let detail = format!("Missing required access: {}", missing.join(", "));
636 message = match message {
637 Some(existing) => Some(format!("{}; {}", existing, detail)),
638 None => Some(detail),
639 };
640 }
641
642 let required = match (require_read, require_write, require_create) {
643 (true, true, true) => "read/write/create",
644 (true, true, false) => "read/write",
645 (true, false, true) => "read/create",
646 (false, true, true) => "write/create",
647 (true, false, false) => "read",
648 (false, true, false) => "write",
649 (false, false, true) => "create",
650 (false, false, false) => "none",
651 }
652 .to_string();
653
654 PathPermissionCheck {
655 path: path.display().to_string(),
656 purpose: purpose.to_string(),
657 required,
658 exists,
659 readable,
660 writable,
661 creatable,
662 ok,
663 message,
664 }
665}
666
667async fn get_path_permission_checks() -> Vec<PathPermissionCheck> {
668 let mut checks = Vec::new();
669
670 if cfg!(target_os = "linux") {
671 checks.push(check_path_permission_entry(
672 PathBuf::from("/etc/systemd/system"),
673 "Install/update systemd unit files",
674 true,
675 true,
676 true,
677 false,
678 ));
679 checks.push(check_path_permission_entry(
680 PathBuf::from("/etc/default/xbp"),
681 "Optional runtime environment file for xbp-api.service",
682 true,
683 true,
684 true,
685 true,
686 ));
687 checks.push(check_path_permission_entry(
688 PathBuf::from("/etc/nginx"),
689 "Read/write Nginx configuration root",
690 true,
691 true,
692 false,
693 false,
694 ));
695 checks.push(check_path_permission_entry(
696 PathBuf::from("/etc/nginx/sites-available"),
697 "Manage Nginx site configurations",
698 true,
699 true,
700 true,
701 false,
702 ));
703 checks.push(check_path_permission_entry(
704 PathBuf::from("/etc/nginx/sites-enabled"),
705 "Enable/disable Nginx sites",
706 true,
707 true,
708 true,
709 false,
710 ));
711 checks.push(check_path_permission_entry(
712 PathBuf::from("/var/log"),
713 "Write XBP and service log files",
714 true,
715 true,
716 true,
717 false,
718 ));
719 checks.push(check_path_permission_entry(
720 PathBuf::from("/var/lib/xbp"),
721 "State directory used by generated systemd services",
722 true,
723 true,
724 true,
725 true,
726 ));
727 checks.push(check_path_permission_entry(
728 PathBuf::from("/var/log/nginx"),
729 "Read Nginx access/error logs for diagnostics and metrics",
730 true,
731 true,
732 true,
733 true,
734 ));
735 checks.push(check_path_permission_entry(
736 PathBuf::from("/run"),
737 "Runtime sockets/state files used by services",
738 true,
739 true,
740 true,
741 false,
742 ));
743 checks.push(check_path_permission_entry(
744 PathBuf::from("/usr/local/bin/xbp"),
745 "Installed XBP CLI binary path",
746 true,
747 true,
748 true,
749 true,
750 ));
751 } else if let Ok(current) = env::current_dir() {
752 checks.push(check_path_permission_entry(
753 current,
754 "Current project directory",
755 true,
756 true,
757 true,
758 false,
759 ));
760 }
761
762 if let Ok(current) = env::current_dir() {
763 checks.push(check_path_permission_entry(
764 current.join(".xbp"),
765 "Local XBP project metadata directory",
766 true,
767 true,
768 true,
769 true,
770 ));
771 }
772
773 checks
774}
775
776async fn run_command_with_timeout(
777 program: &str,
778 args: &[&str],
779 timeout: Duration,
780) -> Option<std::process::Output> {
781 let mut cmd = Command::new(program);
782 cmd.args(args);
783 cmd.kill_on_drop(true);
784 match tokio::time::timeout(timeout, cmd.output()).await {
785 Ok(Ok(output)) => Some(output),
786 _ => None,
787 }
788}
789
790async fn check_program_installed(program: &str) -> bool {
791 let output = if cfg!(target_os = "windows") {
792 run_command_with_timeout("where", &[program], PROGRAM_CHECK_TIMEOUT).await
793 } else {
794 run_command_with_timeout("which", &[program], PROGRAM_CHECK_TIMEOUT).await
795 };
796
797 output.map(|value| value.status.success()).unwrap_or(false)
798}
799
800fn render_percent_bar(percent: f32, width: usize) -> String {
801 let pct = percent.clamp(0.0, 100.0);
802 let filled = ((pct / 100.0) * width as f32).round() as usize;
803 let filled = filled.min(width);
804 let empty = width.saturating_sub(filled);
805 format!("{}{}", "█".repeat(filled), "░".repeat(empty))
806}
807
808async fn get_os_info() -> Option<OsInfo> {
809 Some(OsInfo {
810 name: System::name(),
811 version: System::os_version(),
812 kernel_version: System::kernel_version(),
813 arch: Some(std::env::consts::ARCH.to_string()),
814 })
815}
816
817async fn get_cpu_info() -> Option<CpuInfo> {
818 let mut sys = System::new_all();
819 sys.refresh_cpu_all();
820 let cores = sys.cpus().len();
821 let usage = if cores > 0 {
822 sys.cpus().iter().map(|c| c.cpu_usage()).sum::<f32>() / cores as f32
823 } else {
824 0.0
825 };
826 let brand = sys.cpus().first().map(|c| c.brand().to_string());
827 Some(CpuInfo {
828 brand,
829 cores,
830 usage,
831 })
832}
833
834async fn run_command_capture(program: &str, args: &[&str]) -> Option<String> {
835 let output = run_command_with_timeout(program, args, COMMAND_CAPTURE_TIMEOUT).await?;
836 let stdout: String = String::from_utf8_lossy(&output.stdout).trim().to_string();
837 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
838 let combined: String = if !stdout.is_empty() { stdout } else { stderr };
839 if combined.is_empty() {
840 None
841 } else {
842 Some(combined)
843 }
844}
845
846async fn get_gpu_candidates() -> Vec<String> {
847 let mut out: Vec<String> = Vec::new();
848
849 if cfg!(target_os = "windows") {
850 if let Some(s) = run_command_capture(
851 "powershell",
852 &[
853 "-Command",
854 "Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name",
855 ],
856 )
857 .await
858 {
859 for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
860 out.push(line.to_string());
861 }
862 }
863 return out;
864 }
865
866 if let Some(s) = run_command_capture("nvidia-smi", &["-L"]).await {
867 for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
868 out.push(line.to_string());
869 }
870 }
871
872 if out.is_empty() {
873 if let Some(s) =
874 run_command_capture("sh", &["-c", "lspci | grep -Ei 'vga|3d|display'"]).await
875 {
876 for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
877 out.push(line.to_string());
878 }
879 }
880 }
881
882 if out.is_empty() {
883 if let Some(s) =
884 run_command_capture("sh", &["-c", "lshw -C display 2>/dev/null | head"]).await
885 {
886 for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
887 out.push(line.to_string());
888 }
889 }
890 }
891
892 out
893}
894
895async fn get_disk_infos() -> Vec<DiskInfo> {
896 let disks = Disks::new_with_refreshed_list();
897 let cwd = env::current_dir().ok();
898
899 let mut candidates: Vec<(usize, usize)> = Vec::new();
900 if let Some(cwd) = &cwd {
901 for (i, d) in disks.iter().enumerate() {
902 let mp = d.mount_point();
903 if cwd.starts_with(mp) {
904 candidates.push((mp.as_os_str().len(), i));
905 }
906 }
907 }
908 candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.0));
909 let current_idx = candidates.first().map(|(_, idx)| *idx);
910
911 disks
912 .iter()
913 .enumerate()
914 .map(|(i, d)| {
915 let total = d.total_space();
916 let used = total.saturating_sub(d.available_space());
917 let percent = if total > 0 {
918 (used as f32 / total as f32) * 100.0
919 } else {
920 0.0
921 };
922 let mount = d.mount_point().to_string_lossy().to_string();
923 let fs = if d.file_system().is_empty() {
924 None
925 } else {
926 Some(d.file_system().to_string_lossy().to_string())
927 };
928 DiskInfo {
929 mount,
930 fs,
931 total,
932 used,
933 percent,
934 bar: render_percent_bar(percent, 20),
935 is_current: current_idx == Some(i),
936 }
937 })
938 .collect()
939}
940
941fn get_shell_info() -> Option<ShellInfo> {
942 let shell = env::var("SHELL")
943 .ok()
944 .or_else(|| env::var("ComSpec").ok())
945 .or_else(|| env::var("0").ok());
946 let term = env::var("TERM").ok();
947 if shell.is_none() && term.is_none() {
948 None
949 } else {
950 Some(ShellInfo { shell, term })
951 }
952}
953
954async fn systemctl_active(service: &str) -> Option<bool> {
955 if !cfg!(target_os = "linux") || !command_exists("systemctl") {
956 return None;
957 }
958 let output = Command::new("systemctl")
959 .arg("is-active")
960 .arg(service)
961 .output()
962 .await
963 .ok()?;
964 Some(output.status.success())
965}
966
967async fn get_service_statuses() -> HashMap<String, String> {
968 let mut out = HashMap::new();
969 let services = [
970 "nginx",
971 "traefik",
972 "apache2",
973 "httpd",
974 "postgresql",
975 "postgrest",
976 "prometheus",
977 "grafana-server",
978 "kafka",
979 "xbp",
980 ];
981
982 for s in services {
983 let v = systemctl_active(s).await;
984 let status = match v {
985 Some(true) => "active",
986 Some(false) => "inactive",
987 None => "unknown",
988 };
989 out.insert(s.to_string(), status.to_string());
990 }
991
992 out
993}
994
995async fn get_proxy_detection() -> Option<ProxyDetection> {
996 let nginx_sites_available = if cfg!(target_os = "linux") {
997 let p = PathBuf::from("/etc/nginx/sites-available");
998 if p.exists() {
999 fs::read_dir(&p)
1000 .ok()
1001 .map(|rd| rd.filter_map(|e| e.ok()).count())
1002 } else {
1003 None
1004 }
1005 } else {
1006 None
1007 };
1008
1009 let nginx_active = systemctl_active("nginx").await;
1010 let traefik_active = systemctl_active("traefik").await;
1011 let apache_active = match systemctl_active("apache2").await {
1012 Some(v) => Some(v),
1013 None => systemctl_active("httpd").await,
1014 };
1015
1016 if nginx_sites_available.is_none()
1017 && nginx_active.is_none()
1018 && traefik_active.is_none()
1019 && apache_active.is_none()
1020 {
1021 None
1022 } else {
1023 Some(ProxyDetection {
1024 nginx_sites_available,
1025 nginx_active,
1026 traefik_active,
1027 apache_active,
1028 })
1029 }
1030}
1031
1032async fn get_clipboard_tools() -> HashMap<String, bool> {
1033 let mut tools = vec!["xclip", "xsel", "wl-copy", "pbcopy"];
1034 if cfg!(target_os = "windows") {
1035 tools.push("clip");
1036 }
1037 let mut out = HashMap::new();
1038 for t in tools {
1039 out.insert(t.to_string(), check_program_installed(t).await);
1040 }
1041 out
1042}
1043
1044fn get_listeners_on_all_interfaces() -> Vec<String> {
1045 let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
1046 let proto_flags = ProtocolFlags::TCP;
1047 let sockets = get_sockets_info(af_flags, proto_flags).unwrap_or_default();
1048
1049 let mut out = Vec::new();
1050 for socket in sockets {
1051 if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
1052 let state = format!("{:?}", tcp.state);
1053 if state != "Listen" && state != "LISTEN" {
1054 continue;
1055 }
1056 let addr = tcp.local_addr.to_string();
1057 if addr == "0.0.0.0" || addr == "::" {
1058 out.push(format!("{}:{}", addr, tcp.local_port));
1059 }
1060 }
1061 }
1062 out.sort();
1063 out.dedup();
1064 out
1065}
1066
1067async fn get_firewall_hint() -> Option<String> {
1068 if !cfg!(target_os = "linux") {
1069 return None;
1070 }
1071
1072 if let Some(s) = run_command_capture("sh", &["-c", "ufw status 2>/dev/null | head -n 1"]).await
1073 {
1074 if !s.trim().is_empty() {
1075 return Some(s.trim().to_string());
1076 }
1077 }
1078
1079 if let Some(s) =
1080 run_command_capture("sh", &["-c", "nft list ruleset 2>/dev/null | head -n 1"]).await
1081 {
1082 if !s.trim().is_empty() {
1083 return Some("nftables detected".to_string());
1084 }
1085 }
1086
1087 if let Some(s) = run_command_capture("sh", &["-c", "iptables -S 2>/dev/null | head -n 1"]).await
1088 {
1089 if !s.trim().is_empty() {
1090 return Some("iptables detected".to_string());
1091 }
1092 }
1093
1094 None
1095}
1096
1097async fn get_public_ipv4(connectivity: bool) -> Option<String> {
1098 if !connectivity {
1099 return None;
1100 }
1101 let client = reqwest::Client::builder()
1102 .timeout(Duration::from_secs(3))
1103 .build()
1104 .ok()?;
1105 let text = client
1106 .get("https://api.ipify.org")
1107 .send()
1108 .await
1109 .ok()?
1110 .text()
1111 .await
1112 .ok()?;
1113 let ip = text.trim().to_string();
1114 if ip.is_empty() {
1115 None
1116 } else {
1117 Some(ip)
1118 }
1119}
1120
1121async fn get_exposure_info(connectivity: bool) -> Option<ExposureInfo> {
1122 let public_ipv4 = get_public_ipv4(connectivity).await;
1123 let listening_on_all_interfaces = get_listeners_on_all_interfaces();
1124 let firewall_hint = get_firewall_hint().await;
1125 let summary = Some(if listening_on_all_interfaces.is_empty() {
1126 "No listeners on 0.0.0.0/:: detected".to_string()
1127 } else {
1128 "Listeners on 0.0.0.0/:: detected (may be publicly reachable)".to_string()
1129 });
1130 Some(ExposureInfo {
1131 public_ipv4,
1132 listening_on_all_interfaces,
1133 firewall_hint,
1134 summary,
1135 })
1136}
1137
1138async fn get_pm2_process_count() -> Option<usize> {
1139 let mut cmd = if cfg!(target_os = "windows") {
1140 let mut cmd = Command::new("powershell");
1141 cmd.arg("-NoProfile").arg("-Command").arg("pm2 jlist");
1142 cmd
1143 } else {
1144 let mut cmd = Command::new("pm2");
1145 cmd.arg("jlist");
1146 cmd
1147 };
1148 cmd.kill_on_drop(true);
1149
1150 let output = match tokio::time::timeout(PM2_DIAG_TIMEOUT, cmd.output()).await {
1151 Ok(Ok(output)) => output,
1152 Ok(Err(_)) | Err(_) => return None,
1153 };
1154
1155 if !output.status.success() {
1156 return None;
1157 }
1158 let stdout = String::from_utf8_lossy(&output.stdout);
1159 let value: serde_json::Value = serde_json::from_str(&stdout).ok()?;
1160 value.as_array().map(|arr| arr.len())
1161}
1162
1163async fn get_version(cmd: &str, args: &[&str]) -> ToolVersion {
1164 match run_command_with_timeout(cmd, args, TOOL_VERSION_TIMEOUT).await {
1165 Some(o) => {
1166 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
1167 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
1168 let line = stdout
1169 .lines()
1170 .next()
1171 .filter(|l| !l.trim().is_empty())
1172 .map(|l| l.trim().to_string())
1173 .or_else(|| {
1174 stderr
1175 .lines()
1176 .next()
1177 .filter(|l| !l.trim().is_empty())
1178 .map(|l| l.trim().to_string())
1179 });
1180 ToolVersion {
1181 present: o.status.success() || line.is_some(),
1182 version: line,
1183 }
1184 }
1185 None => ToolVersion {
1186 present: false,
1187 version: None,
1188 },
1189 }
1190}
1191
1192async fn get_tool_versions() -> HashMap<String, ToolVersion> {
1193 let mut out = HashMap::new();
1194
1195 out.insert(
1196 "python3".to_string(),
1197 get_version("python3", &["--version"]).await,
1198 );
1199 out.insert(
1200 "python".to_string(),
1201 get_version("python", &["--version"]).await,
1202 );
1203 out.insert(
1204 "node".to_string(),
1205 get_version("node", &["--version"]).await,
1206 );
1207 out.insert("npm".to_string(), get_version("npm", &["--version"]).await);
1208 out.insert(
1209 "pnpm".to_string(),
1210 get_version("pnpm", &["--version"]).await,
1211 );
1212 out.insert("pm2".to_string(), get_version("pm2", &["--version"]).await);
1213 out.insert(
1214 "docker".to_string(),
1215 get_version("docker", &["--version"]).await,
1216 );
1217 out.insert(
1218 "docker-compose".to_string(),
1219 get_version("docker-compose", &["--version"]).await,
1220 );
1221 out.insert(
1222 "kubectl".to_string(),
1223 get_version("kubectl", &["version", "--client"]).await,
1224 );
1225 out.insert(
1226 "microk8s".to_string(),
1227 get_version("microk8s", &["version"]).await,
1228 );
1229 out.insert(
1230 "rustc".to_string(),
1231 get_version("rustc", &["--version"]).await,
1232 );
1233 out.insert(
1234 "cargo".to_string(),
1235 get_version("cargo", &["--version"]).await,
1236 );
1237 out.insert(
1238 "rustup".to_string(),
1239 get_version("rustup", &["--version"]).await,
1240 );
1241
1242 out
1243}
1244
1245pub async fn print_diagnostic_report(report: &DiagnosticReport) {
1246 println!(
1247 "\n{}",
1248 "═══════════════════════════════════════════════════".bright_cyan()
1249 );
1250 println!(
1251 "{}",
1252 " XBP SYSTEM DIAGNOSTICS REPORT"
1253 .bright_cyan()
1254 .bold()
1255 );
1256 println!(
1257 "{}",
1258 "═══════════════════════════════════════════════════".bright_cyan()
1259 );
1260
1261 println!(
1262 "\n{} {}",
1263 "XBP:".bright_white(),
1264 format!(
1265 "v{} (features: monitoring={}, kafka={}, kubernetes={}, docker={})",
1266 report.xbp_cli_version,
1267 report
1268 .feature_flags
1269 .get("monitoring")
1270 .copied()
1271 .unwrap_or(false),
1272 report.feature_flags.get("kafka").copied().unwrap_or(false),
1273 report
1274 .feature_flags
1275 .get("kubernetes")
1276 .copied()
1277 .unwrap_or(false),
1278 report.feature_flags.get("docker").copied().unwrap_or(false),
1279 )
1280 .bright_cyan()
1281 );
1282
1283 if let Some(os) = &report.os {
1284 let os_line = [
1285 os.name.clone().unwrap_or_else(|| "Unknown OS".to_string()),
1286 os.version.clone().unwrap_or_default(),
1287 ]
1288 .into_iter()
1289 .filter(|s| !s.is_empty())
1290 .collect::<Vec<_>>()
1291 .join(" ");
1292 println!(" {} {}", "OS:".bright_white(), os_line);
1293 if let Some(k) = &os.kernel_version {
1294 println!(" {} {}", "Kernel:".bright_white(), k);
1295 }
1296 if let Some(arch) = &os.arch {
1297 println!(" {} {}", "Arch:".bright_white(), arch);
1298 }
1299 }
1300
1301 if let Some(cpu) = &report.cpu {
1302 println!(
1303 " {} {} ({} cores, {:.1}% avg)",
1304 "CPU:".bright_white(),
1305 cpu.brand.clone().unwrap_or_else(|| "Unknown".into()),
1306 cpu.cores,
1307 cpu.usage
1308 );
1309 }
1310
1311 if let Some(shell) = &report.shell {
1312 if let Some(s) = &shell.shell {
1313 println!(" {} {}", "Shell:".bright_white(), s);
1314 }
1315 if let Some(t) = &shell.term {
1316 println!(" {} {}", "TERM:".bright_white(), t);
1317 }
1318 }
1319
1320 if !report.gpu_candidates.is_empty() {
1321 println!("\n{}", "🖥️ GPU CANDIDATES".bright_yellow().bold());
1322 println!(
1323 "{}",
1324 "─────────────────────────────────────────────────".bright_black()
1325 );
1326 for line in &report.gpu_candidates {
1327 println!(" {}", line);
1328 }
1329 }
1330
1331 if !report.disks.is_empty() {
1332 println!("\n{}", "💾 DISKS".bright_yellow().bold());
1333 println!(
1334 "{}",
1335 "─────────────────────────────────────────────────".bright_black()
1336 );
1337 for d in &report.disks {
1338 let current = if d.is_current { " *" } else { "" };
1339 let fs = d.fs.clone().unwrap_or_else(|| "-".to_string());
1340 println!(
1341 " {}{} {} {:>5.1}% {} ({} / {} GB)",
1342 d.mount,
1343 current,
1344 fs,
1345 d.percent,
1346 d.bar,
1347 d.used / 1024 / 1024 / 1024,
1348 d.total / 1024 / 1024 / 1024
1349 );
1350 }
1351 println!(" {} current mount", "*".bright_cyan());
1352 }
1353
1354 if !report.provider_manifests.is_empty() {
1355 println!("\n{}", "📦 DETECTED MANIFESTS".bright_yellow().bold());
1356 println!(
1357 "{}",
1358 "─────────────────────────────────────────────────".bright_black()
1359 );
1360 for m in &report.provider_manifests {
1361 println!(" - {}", m);
1362 }
1363 }
1364
1365 if let Some(proxy) = &report.proxy_detection {
1366 println!("\n{}", "🧩 PROXY STACK".bright_yellow().bold());
1367 println!(
1368 "{}",
1369 "─────────────────────────────────────────────────".bright_black()
1370 );
1371 if let Some(n) = proxy.nginx_sites_available {
1372 println!(" {} {}", "nginx sites-available:".bright_white(), n);
1373 }
1374 if let Some(v) = proxy.nginx_active {
1375 println!(
1376 " {} {}",
1377 "nginx active:".bright_white(),
1378 if v { "yes" } else { "no" }
1379 );
1380 }
1381 if let Some(v) = proxy.traefik_active {
1382 println!(
1383 " {} {}",
1384 "traefik active:".bright_white(),
1385 if v { "yes" } else { "no" }
1386 );
1387 }
1388 if let Some(v) = proxy.apache_active {
1389 println!(
1390 " {} {}",
1391 "apache active:".bright_white(),
1392 if v { "yes" } else { "no" }
1393 );
1394 }
1395 }
1396
1397 if !report.clipboard_tools.is_empty() {
1398 println!("\n{}", "📋 CLIPBOARD TOOLS".bright_yellow().bold());
1399 println!(
1400 "{}",
1401 "─────────────────────────────────────────────────".bright_black()
1402 );
1403 let mut items: Vec<_> = report.clipboard_tools.iter().collect();
1404 items.sort_by_key(|(k, _)| *k);
1405 for (k, v) in items {
1406 println!(" {:10} {}", k, if *v { "yes" } else { "no" });
1407 }
1408 }
1409
1410 if let Some(exposure) = &report.exposure {
1411 println!(
1412 "\n{}",
1413 "🌍 NETWORK EXPOSURE (HEURISTIC)".bright_yellow().bold()
1414 );
1415 println!(
1416 "{}",
1417 "─────────────────────────────────────────────────".bright_black()
1418 );
1419 if let Some(ip) = &exposure.public_ipv4 {
1420 println!(" {} {}", "Public IPv4:".bright_white(), ip);
1421 }
1422 if let Some(s) = &exposure.summary {
1423 println!(" {} {}", "Summary:".bright_white(), s);
1424 }
1425 if let Some(h) = &exposure.firewall_hint {
1426 println!(" {} {}", "Firewall:".bright_white(), h);
1427 }
1428 if !exposure.listening_on_all_interfaces.is_empty() {
1429 println!(" {}:", "Listeners on 0.0.0.0/::".bright_white());
1430 for l in &exposure.listening_on_all_interfaces {
1431 println!(" - {}", l);
1432 }
1433 }
1434 }
1435
1436 if let Some(count) = report.pm2_process_count {
1437 println!("\n{}", "🧵 PM2".bright_yellow().bold());
1438 println!(
1439 "{}",
1440 "─────────────────────────────────────────────────".bright_black()
1441 );
1442 println!(" {} {}", "Process count:".bright_white(), count);
1443 }
1444
1445 if !report.tool_versions.is_empty() {
1446 println!("\n{}", "🧰 TOOL VERSIONS".bright_yellow().bold());
1447 println!(
1448 "{}",
1449 "─────────────────────────────────────────────────".bright_black()
1450 );
1451 let mut items: Vec<_> = report.tool_versions.iter().collect();
1452 items.sort_by_key(|(k, _)| *k);
1453 for (name, v) in items {
1454 if v.present {
1455 println!(
1456 " {:12} {}",
1457 name,
1458 v.version.clone().unwrap_or_else(|| "present".to_string())
1459 );
1460 } else {
1461 println!(" {:12} {}", name, "not found".dimmed());
1462 }
1463 }
1464 }
1465
1466 if !report.service_statuses.is_empty() {
1467 println!("\n{}", "🧩 SERVICES".bright_yellow().bold());
1468 println!(
1469 "{}",
1470 "─────────────────────────────────────────────────".bright_black()
1471 );
1472 let mut items: Vec<_> = report.service_statuses.iter().collect();
1473 items.sort_by_key(|(k, _)| *k);
1474 for (name, status) in items {
1475 println!(" {:15} {}", name, status);
1476 }
1477 }
1478
1479 println!("\n{}", "📊 SYSTEM METRICS".bright_yellow().bold());
1480 println!(
1481 "{}",
1482 "─────────────────────────────────────────────────".bright_black()
1483 );
1484
1485 let metrics = &report.system_metrics;
1486
1487 let cpu_color = if metrics.cpu_usage > 80.0 {
1488 "red"
1489 } else if metrics.cpu_usage > 50.0 {
1490 "yellow"
1491 } else {
1492 "green"
1493 };
1494 println!(
1495 " {} {:.1}%",
1496 "CPU Usage:".bright_white(),
1497 format!("{}", metrics.cpu_usage).color(cpu_color)
1498 );
1499
1500 let mem_color = if metrics.memory_percent > 80.0 {
1501 "red"
1502 } else if metrics.memory_percent > 50.0 {
1503 "yellow"
1504 } else {
1505 "green"
1506 };
1507 println!(
1508 " {} {:.1}% ({} MB / {} MB)",
1509 "Memory:".bright_white(),
1510 format!("{}", metrics.memory_percent).color(mem_color),
1511 metrics.memory_used / 1024 / 1024,
1512 metrics.memory_total / 1024 / 1024
1513 );
1514
1515 let disk_color = if metrics.disk_percent > 80.0 {
1516 "red"
1517 } else if metrics.disk_percent > 50.0 {
1518 "yellow"
1519 } else {
1520 "green"
1521 };
1522 println!(
1523 " {} {:.1}% ({} GB / {} GB)",
1524 "Disk:".bright_white(),
1525 format!("{}", metrics.disk_percent).color(disk_color),
1526 metrics.disk_used / 1024 / 1024 / 1024,
1527 metrics.disk_total / 1024 / 1024 / 1024
1528 );
1529
1530 println!(
1531 " {} {} MB ↓ / {} MB ↑",
1532 "Network:".bright_white(),
1533 metrics.network_rx / 1024 / 1024,
1534 metrics.network_tx / 1024 / 1024
1535 );
1536
1537 let uptime_hours = metrics.uptime / 3600;
1538 let uptime_minutes = (metrics.uptime % 3600) / 60;
1539 println!(
1540 " {} {}h {}m",
1541 "Uptime:".bright_white(),
1542 uptime_hours,
1543 uptime_minutes
1544 );
1545 println!(
1546 " {} {}",
1547 "Processes:".bright_white(),
1548 metrics.process_count
1549 );
1550
1551 println!("\n{}", "🔧 INSTALLED PROGRAMS".bright_yellow().bold());
1552 println!(
1553 "{}",
1554 "─────────────────────────────────────────────────".bright_black()
1555 );
1556
1557 let mut programs: Vec<_> = report.installed_programs.iter().collect();
1558 programs.sort_by_key(|(name, _)| *name);
1559
1560 for (program, installed) in programs {
1561 let status_icon = if *installed {
1562 "✓".green()
1563 } else {
1564 "✗".red()
1565 };
1566 let status_text = if *installed {
1567 "Installed".green()
1568 } else {
1569 "Not Found".red()
1570 };
1571 println!(" {} {:15} {}", status_icon, program, status_text);
1572 }
1573
1574 if !report.path_permission_checks.is_empty() {
1575 println!(
1576 "\n{}",
1577 "🗂️ PATH PERMISSIONS & INSTALL READINESS"
1578 .bright_yellow()
1579 .bold()
1580 );
1581 println!(
1582 "{}",
1583 "─────────────────────────────────────────────────".bright_black()
1584 );
1585
1586 let failing = report
1587 .path_permission_checks
1588 .iter()
1589 .filter(|item| !item.ok)
1590 .count();
1591 println!(
1592 " {} {}/{}",
1593 "Checks passing:".bright_white(),
1594 report.path_permission_checks.len().saturating_sub(failing),
1595 report.path_permission_checks.len()
1596 );
1597
1598 for item in &report.path_permission_checks {
1599 let icon = if item.ok { "✓".green() } else { "✗".red() };
1600 println!(
1601 " {} {} [{}]",
1602 icon,
1603 item.path.bright_white(),
1604 item.required
1605 );
1606 println!(" purpose: {}", item.purpose);
1607 println!(
1608 " status: exists={} read={} write={} create={}",
1609 item.exists, item.readable, item.writable, item.creatable
1610 );
1611 if let Some(msg) = &item.message {
1612 println!(" note: {}", msg);
1613 }
1614 }
1615 }
1616
1617 if let Some(nginx) = &report.nginx_status {
1618 println!("\n{}", "🔧 NGINX STATUS".bright_yellow().bold());
1619 println!(
1620 "{}",
1621 "─────────────────────────────────────────────────".bright_black()
1622 );
1623
1624 let status_icon = if nginx.is_running {
1625 "✓".green()
1626 } else {
1627 "✗".red()
1628 };
1629 println!(
1630 " {} {}",
1631 status_icon,
1632 if nginx.is_running {
1633 "Running".green()
1634 } else {
1635 "Stopped".red()
1636 }
1637 );
1638
1639 let enabled_icon = if nginx.is_enabled {
1640 "✓".green()
1641 } else {
1642 "✗".red()
1643 };
1644 println!(
1645 " {} {}",
1646 enabled_icon,
1647 if nginx.is_enabled {
1648 "Enabled".green()
1649 } else {
1650 "Disabled".red()
1651 }
1652 );
1653
1654 let config_icon = if nginx.config_valid {
1655 "✓".green()
1656 } else {
1657 "✗".red()
1658 };
1659 println!(
1660 " {} {}",
1661 config_icon,
1662 if nginx.config_valid {
1663 "Config Valid".green()
1664 } else {
1665 "Config Invalid".red()
1666 }
1667 );
1668 }
1669
1670 if !report.port_checks.is_empty() {
1671 println!("\n{}", "🔌 PORT STATUS".bright_yellow().bold());
1672 println!(
1673 "{}",
1674 "─────────────────────────────────────────────────".bright_black()
1675 );
1676
1677 let (xbp_ports, other_ports): (Vec<_>, Vec<_>) = report
1678 .port_checks
1679 .iter()
1680 .partition(|port_check| !port_check.xbp_projects.is_empty());
1681
1682 for port_check in xbp_ports.iter().chain(other_ports.iter()) {
1683 let status_icon = if port_check.is_open {
1684 "✓".green()
1685 } else {
1686 "✗".red()
1687 };
1688 let blocked_text = if port_check.is_blocked {
1689 " (BLOCKED)".red().to_string()
1690 } else {
1691 String::new()
1692 };
1693 let xbp_suffix = if port_check.xbp_projects.is_empty() {
1694 String::new()
1695 } else {
1696 format!(" [XBP: {}]", port_check.xbp_projects.join(", "))
1697 };
1698 let line = format!(
1699 " {} Port {}: {}{}{}",
1700 status_icon,
1701 port_check.port,
1702 if port_check.is_open {
1703 "Available".green().to_string()
1704 } else {
1705 "In Use".red().to_string()
1706 },
1707 blocked_text,
1708 xbp_suffix
1709 );
1710
1711 if port_check.xbp_projects.is_empty() {
1712 println!("{}", line);
1713 } else {
1714 println!("{}", line.bright_magenta());
1715 }
1716 }
1717 }
1718
1719 println!("\n{}", "🌐 CONNECTIVITY".bright_yellow().bold());
1720 println!(
1721 "{}",
1722 "─────────────────────────────────────────────────".bright_black()
1723 );
1724
1725 let conn_icon = if report.connectivity {
1726 "✓".green()
1727 } else {
1728 "✗".red()
1729 };
1730 println!(
1731 " {} {}",
1732 conn_icon,
1733 if report.connectivity {
1734 "Internet Connected".green()
1735 } else {
1736 "No Internet".red()
1737 }
1738 );
1739
1740 if let Some(speed) = &report.internet_speed {
1741 println!(" {} {:.2} ms", "Ping:".bright_white(), speed.ping_ms);
1742 println!(
1743 " {} {:.2} Mbps",
1744 "Download:".bright_white(),
1745 speed.download_mbps
1746 );
1747 if speed.upload_mbps > 0.0 {
1748 println!(
1749 " {} {:.2} Mbps",
1750 "Upload:".bright_white(),
1751 speed.upload_mbps
1752 );
1753 }
1754 }
1755
1756 println!(
1757 "\n{}",
1758 "═══════════════════════════════════════════════════".bright_cyan()
1759 );
1760}
1761
1762fn format_eta(duration: Duration) -> String {
1763 let total_seconds = duration.as_secs();
1764 let minutes = total_seconds / 60;
1765 let seconds = total_seconds % 60;
1766 if minutes > 0 {
1767 format!("{}m {:02}s", minutes, seconds)
1768 } else {
1769 format!("{}s", seconds)
1770 }
1771}
1772
1773fn estimate_diag_timeout_budget(ports_count: usize, skip_speed_test: bool) -> Duration {
1774 let fixed_seconds =
1776 2 + 2 + 2 + 2 + 3 + 3 + 10 + 4 + PM2_DIAG_TIMEOUT.as_secs() + NGINX_CHECK_TIMEOUT.as_secs();
1777 let port_seconds =
1778 PORT_OWNERSHIP_TIMEOUT.as_secs() + (ports_count as u64 * PER_PORT_CHECK_TIMEOUT.as_secs());
1779 let speed_seconds = if skip_speed_test {
1780 0
1781 } else {
1782 INTERNET_SPEED_TIMEOUT.as_secs()
1783 };
1784 Duration::from_secs(fixed_seconds + port_seconds + speed_seconds)
1785}
1786
1787fn set_step_message(
1788 pb: &ProgressBar,
1789 step_index: usize,
1790 step_total: usize,
1791 started_at: Instant,
1792 estimated_budget: Duration,
1793 label: &str,
1794 timeout: Option<Duration>,
1795) {
1796 let elapsed = started_at.elapsed();
1797 let remaining = estimated_budget.saturating_sub(elapsed);
1798 let timeout_suffix = timeout
1799 .map(|value| format!(" | timeout {}s", value.as_secs()))
1800 .unwrap_or_default();
1801 pb.set_message(format!(
1802 "[{}/{}] {} | ETA {}{}",
1803 step_index,
1804 step_total,
1805 label,
1806 format_eta(remaining),
1807 timeout_suffix
1808 ));
1809}
1810
1811async fn collect_port_ownership_with_timeout() -> BTreeMap<u16, ListeningPortOwnership> {
1812 let task = tokio::task::spawn_blocking(collect_listening_port_ownership);
1813 match tokio::time::timeout(PORT_OWNERSHIP_TIMEOUT, task).await {
1814 Ok(Ok(Ok(ports))) => ports,
1815 Ok(Ok(Err(err))) => {
1816 let _ = log_warn("diag", "Port ownership scan failed", Some(err.as_str())).await;
1817 BTreeMap::new()
1818 }
1819 Ok(Err(err)) => {
1820 let message = format!("Port ownership scan task failed: {}", err);
1821 let _ = log_warn("diag", "Port ownership scan failed", Some(message.as_str())).await;
1822 BTreeMap::new()
1823 }
1824 Err(_) => {
1825 let timeout_note = format!(
1826 "Port ownership scan timed out after {} seconds",
1827 PORT_OWNERSHIP_TIMEOUT.as_secs()
1828 );
1829 let _ = log_warn(
1830 "diag",
1831 "Port ownership scan timed out",
1832 Some(timeout_note.as_str()),
1833 )
1834 .await;
1835 BTreeMap::new()
1836 }
1837 }
1838}
1839
1840pub async fn run_full_diagnostics(
1841 ports: Vec<u16>,
1842 options: DiagnosticRunOptions,
1843) -> Result<DiagnosticReport> {
1844 let _ = log_info("diag", "Running system diagnostics...", None).await;
1845
1846 let estimated_budget = estimate_diag_timeout_budget(ports.len(), options.skip_speed_test);
1847 let timeout_summary = format!(
1848 "Estimated max runtime {} (PM2 {}s, ports {}s + {}s/port{})",
1849 format_eta(estimated_budget),
1850 PM2_DIAG_TIMEOUT.as_secs(),
1851 PORT_OWNERSHIP_TIMEOUT.as_secs(),
1852 PER_PORT_CHECK_TIMEOUT.as_secs(),
1853 if options.skip_speed_test {
1854 ", speed test skipped"
1855 } else {
1856 ", speed test up to 20s"
1857 }
1858 );
1859 let _ = log_info(
1860 "diag",
1861 "Diagnostics timeout budget",
1862 Some(timeout_summary.as_str()),
1863 )
1864 .await;
1865
1866 let pb: ProgressBar = ProgressBar::new_spinner();
1867 pb.enable_steady_tick(Duration::from_millis(80));
1868 pb.set_style(
1869 ProgressStyle::with_template("{spinner} {msg}")
1870 .unwrap_or_else(|_| ProgressStyle::default_spinner()),
1871 );
1872 let started_at = Instant::now();
1873 let step_total = 13;
1874 let mut step_index = 1usize;
1875
1876 set_step_message(
1877 &pb,
1878 step_index,
1879 step_total,
1880 started_at,
1881 estimated_budget,
1882 "Collecting system metrics",
1883 None,
1884 );
1885 step_index += 1;
1886 let system_metrics = get_system_metrics().await?;
1887
1888 set_step_message(
1889 &pb,
1890 step_index,
1891 step_total,
1892 started_at,
1893 estimated_budget,
1894 "Collecting OS/CPU/GPU",
1895 None,
1896 );
1897 step_index += 1;
1898 let os = get_os_info().await;
1899 let cpu = get_cpu_info().await;
1900 let gpu_candidates = get_gpu_candidates().await;
1901
1902 set_step_message(
1903 &pb,
1904 step_index,
1905 step_total,
1906 started_at,
1907 estimated_budget,
1908 "Collecting disks",
1909 None,
1910 );
1911 step_index += 1;
1912 let disks = get_disk_infos().await;
1913
1914 set_step_message(
1915 &pb,
1916 step_index,
1917 step_total,
1918 started_at,
1919 estimated_budget,
1920 "Collecting shell + clipboard",
1921 None,
1922 );
1923 step_index += 1;
1924 let shell = get_shell_info();
1925 let clipboard_tools = get_clipboard_tools().await;
1926
1927 set_step_message(
1928 &pb,
1929 step_index,
1930 step_total,
1931 started_at,
1932 estimated_budget,
1933 "Detecting proxies",
1934 None,
1935 );
1936 step_index += 1;
1937 let proxy_detection = get_proxy_detection().await;
1938
1939 set_step_message(
1940 &pb,
1941 step_index,
1942 step_total,
1943 started_at,
1944 estimated_budget,
1945 "Checking network/public IP",
1946 None,
1947 );
1948 step_index += 1;
1949 let connectivity = check_internet_connectivity().await.unwrap_or(false);
1950 let exposure = get_exposure_info(connectivity).await;
1951
1952 set_step_message(
1953 &pb,
1954 step_index,
1955 step_total,
1956 started_at,
1957 estimated_budget,
1958 "Checking tools",
1959 Some(TOOL_VERSION_TIMEOUT),
1960 );
1961 step_index += 1;
1962 let installed_programs = check_installed_programs().await;
1963 let tool_versions = get_tool_versions().await;
1964
1965 set_step_message(
1966 &pb,
1967 step_index,
1968 step_total,
1969 started_at,
1970 estimated_budget,
1971 "Checking file permissions",
1972 None,
1973 );
1974 step_index += 1;
1975 let path_permission_checks = get_path_permission_checks().await;
1976
1977 set_step_message(
1978 &pb,
1979 step_index,
1980 step_total,
1981 started_at,
1982 estimated_budget,
1983 "Checking services",
1984 None,
1985 );
1986 step_index += 1;
1987 let service_statuses = get_service_statuses().await;
1988
1989 set_step_message(
1990 &pb,
1991 step_index,
1992 step_total,
1993 started_at,
1994 estimated_budget,
1995 "Checking PM2 process table",
1996 Some(PM2_DIAG_TIMEOUT),
1997 );
1998 step_index += 1;
1999 let pm2_process_count = get_pm2_process_count().await;
2000
2001 let ports_timeout_budget = PORT_OWNERSHIP_TIMEOUT
2002 + Duration::from_secs(ports.len() as u64 * PER_PORT_CHECK_TIMEOUT.as_secs());
2003 set_step_message(
2004 &pb,
2005 step_index,
2006 step_total,
2007 started_at,
2008 estimated_budget,
2009 "Checking ports",
2010 Some(ports_timeout_budget),
2011 );
2012 step_index += 1;
2013 let port_ownership = collect_port_ownership_with_timeout().await;
2014
2015 let mut port_checks = Vec::new();
2016 for port in ports {
2017 let timed_check = tokio::time::timeout(
2018 PER_PORT_CHECK_TIMEOUT,
2019 check_port_availability(port, port_ownership.get(&port)),
2020 )
2021 .await;
2022 match timed_check {
2023 Ok(Ok(check)) => port_checks.push(check),
2024 Ok(Err(err)) => {
2025 let details = format!("Port {} check failed: {}", port, err);
2026 let _ = log_warn("diag", "Port check failed", Some(details.as_str())).await;
2027 }
2028 Err(_) => {
2029 let details = format!(
2030 "Port {} check timed out after {} seconds",
2031 port,
2032 PER_PORT_CHECK_TIMEOUT.as_secs()
2033 );
2034 let _ = log_warn("diag", "Port check timed out", Some(details.as_str())).await;
2035 }
2036 }
2037 }
2038
2039 set_step_message(
2040 &pb,
2041 step_index,
2042 step_total,
2043 started_at,
2044 estimated_budget,
2045 "Measuring internet speed",
2046 Some(INTERNET_SPEED_TIMEOUT),
2047 );
2048 step_index += 1;
2049 let internet_speed = if options.skip_speed_test {
2050 let _ = log_info(
2051 "diag",
2052 "Skipping internet speed test (--no-speed-test)",
2053 None,
2054 )
2055 .await;
2056 None
2057 } else if connectivity {
2058 match tokio::time::timeout(INTERNET_SPEED_TIMEOUT, measure_internet_speed()).await {
2059 Ok(Ok(speed)) => Some(speed),
2060 Ok(Err(err)) => {
2061 let details = format!("Internet speed probe failed: {}", err);
2062 let _ = log_warn(
2063 "diag",
2064 "Internet speed probe failed",
2065 Some(details.as_str()),
2066 )
2067 .await;
2068 None
2069 }
2070 Err(_) => {
2071 let details = format!(
2072 "Internet speed probe timed out after {} seconds",
2073 INTERNET_SPEED_TIMEOUT.as_secs()
2074 );
2075 let _ = log_warn(
2076 "diag",
2077 "Internet speed probe timed out",
2078 Some(details.as_str()),
2079 )
2080 .await;
2081 None
2082 }
2083 }
2084 } else {
2085 None
2086 };
2087
2088 set_step_message(
2089 &pb,
2090 step_index,
2091 step_total,
2092 started_at,
2093 estimated_budget,
2094 "Checking nginx",
2095 Some(NGINX_CHECK_TIMEOUT),
2096 );
2097 let nginx_status = match tokio::time::timeout(NGINX_CHECK_TIMEOUT, check_nginx_status()).await {
2098 Ok(Ok(status)) => Some(status),
2099 Ok(Err(err)) => {
2100 let details = format!("Nginx status check failed: {}", err);
2101 let _ = log_warn("diag", "Nginx status check failed", Some(details.as_str())).await;
2102 None
2103 }
2104 Err(_) => {
2105 let details = format!(
2106 "Nginx status check timed out after {} seconds",
2107 NGINX_CHECK_TIMEOUT.as_secs()
2108 );
2109 let _ = log_warn(
2110 "diag",
2111 "Nginx status check timed out",
2112 Some(details.as_str()),
2113 )
2114 .await;
2115 None
2116 }
2117 };
2118
2119 let provider_manifests: Vec<String> = ProjectDetector::detect_provider_manifests(
2120 &env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
2121 );
2122
2123 let mut feature_flags: HashMap<String, bool> = HashMap::new();
2124 feature_flags.insert("monitoring".to_string(), cfg!(feature = "monitoring"));
2125 feature_flags.insert(
2126 "kafka".to_string(),
2127 cfg!(all(feature = "kafka", not(windows))),
2128 );
2129 feature_flags.insert("kubernetes".to_string(), cfg!(feature = "kubernetes"));
2130 feature_flags.insert("docker".to_string(), cfg!(feature = "docker"));
2131
2132 let xbp_cli_version = env!("CARGO_PKG_VERSION").to_string();
2133
2134 pb.finish_and_clear();
2135
2136 Ok(DiagnosticReport {
2137 system_metrics,
2138 os,
2139 cpu,
2140 gpu_candidates,
2141 disks,
2142 shell,
2143 proxy_detection,
2144 clipboard_tools,
2145 exposure,
2146 pm2_process_count,
2147 tool_versions,
2148 service_statuses,
2149 feature_flags,
2150 xbp_cli_version,
2151 provider_manifests,
2152 nginx_status,
2153 port_checks,
2154 internet_speed,
2155 connectivity,
2156 installed_programs,
2157 path_permission_checks,
2158 })
2159}