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