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