Skip to main content

xbp_cli/commands/
system_diag.rs

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        // Missing path: we can still estimate create capability via parent directory.
602        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    // This is a conservative upper bound shown to operators while diagnostics runs.
1775    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}