1use crate::config::Config;
2use crate::diagnostics::{DiagnosticResults, DiagnosticStatus, TechnicianResults};
3use crate::render::color::{colorize_status, dim};
4use crate::render::table::ReportBuilder;
5
6pub fn render(results: &DiagnosticResults, config: &Config) -> String {
7 let label_width = 16;
8 let data_width = 40;
9 let chars = config.box_chars();
10
11 let mut output = String::new();
12
13 let mut builder = ReportBuilder::new(label_width, data_width, chars)
15 .header(config.title(), config.subtitle());
16
17 builder = builder.span_row(" DIAGNOSTIC SUMMARY").divider();
18
19 builder = render_summary_row(builder, &results.adapters, config);
20 builder = render_summary_row(builder, &results.interfaces, config);
21 builder = render_summary_row(builder, &results.gateway, config);
22 builder = render_summary_row(builder, &results.dns, config);
23 builder = render_summary_row(builder, &results.public_ip, config);
24 builder = render_summary_row(builder, &results.latency, config);
25 builder = render_summary_row(builder, &results.speed, config);
26 builder = render_summary_row(builder, &results.ports, config);
27
28 let (fail_count, warn_count) = count_issues(results);
29 let overall = format_overall(fail_count, warn_count, config);
30 builder = builder.divider();
31 builder = builder.span_row(&format!(" OVERALL: {}", overall));
32
33 if fail_count > 0 {
34 builder = builder.span_row(&dim(" Run 'nd300 -f' to attempt automatic fixes", config));
35 }
36
37 output.push_str(&builder.finish());
38 output.push('\n');
39
40 if let Some(ref ifaces) = results.interface_details {
44 let mut b = ReportBuilder::new(label_width, data_width, chars);
45 b = b
46 .full_top_border()
47 .span_row(" NETWORK INTERFACES")
48 .divider();
49
50 for (i, iface) in ifaces.iter().enumerate() {
51 if i > 0 {
52 b = b.divider();
53 }
54 b = b.row("Name", &iface.name);
55 b = b.row("Type", &iface.interface_type);
56 b = b.row("MAC", &iface.mac);
57 b = b.row("Status", if iface.is_up { "Up" } else { "Down" });
58 for ip in &iface.ip_addresses {
59 b = b.row("IP Address", ip);
60 }
61 }
62 output.push_str(&b.finish());
63 output.push('\n');
64 }
65
66 if let Some(ref adapters) = results.adapter_details {
68 if !adapters.is_empty() {
69 let mut b = ReportBuilder::new(label_width, data_width, chars);
70 b = b.full_top_border().span_row(" NETWORK ADAPTERS").divider();
71
72 for (i, adapter) in adapters.iter().enumerate() {
73 if i > 0 {
74 b = b.divider();
75 }
76 let adapter_label = adapter.description.as_deref().unwrap_or(&adapter.name);
78 b = b.row("Adapter", adapter_label);
79
80 let type_detail = if let Some(ref pm) = adapter.physical_medium {
82 format!("{} ({})", adapter.adapter_type, pm)
83 } else {
84 adapter.adapter_type.clone()
85 };
86 b = b.row("Type", &type_detail);
87 b = b.row("Status", &adapter.status);
88
89 if let Some(ref mac) = adapter.mac_address {
90 b = b.row("MAC", mac);
91 }
92
93 match (adapter.link_speed_mbps, adapter.rx_link_speed_mbps) {
95 (Some(tx), Some(rx)) if tx == rx => {
96 b = b.row("Link Speed", &format_link_speed(tx));
97 }
98 (Some(tx), Some(rx)) => {
99 b = b.row(
100 "Link Speed",
101 &format!(
102 "TX: {} / RX: {}",
103 format_link_speed(tx),
104 format_link_speed(rx)
105 ),
106 );
107 }
108 (Some(tx), None) => {
109 b = b.row("Link Speed", &format_link_speed(tx));
110 }
111 _ => {}
112 }
113
114 if let Some(ref gws) = adapter.gateways {
115 for gw in gws {
116 b = b.row("Gateway", gw);
117 }
118 }
119 if let Some(ref dns) = adapter.dns_servers {
120 for server in dns {
121 b = b.row("DNS", server);
122 }
123 }
124 if let Some(mtu) = adapter.mtu {
125 b = b.row("MTU", &mtu.to_string());
126 }
127 if let Some(metric) = adapter.ipv4_metric {
128 b = b.row("IPv4 Metric", &metric.to_string());
129 }
130
131 if let Some(ref drv) = adapter.driver_name {
132 b = b.row("Driver", drv);
133 }
134 if let Some(ref ver) = adapter.driver_version {
135 b = b.row("Driver Version", ver);
136 }
137 if let Some(ref date) = adapter.driver_date {
138 b = b.row("Driver Date", date);
139 }
140 }
141 output.push_str(&b.finish());
142 output.push('\n');
143 }
144 }
145
146 if let Some(ref gw) = results.gateway_details {
148 let mut b = ReportBuilder::new(label_width, data_width, chars);
149 b = b.full_top_border().span_row(" GATEWAY").divider();
150 b = b.row("IP Address", &gw.ip);
151 b = b.row("Reachable", if gw.reachable { "Yes" } else { "No" });
152 if let Some(lat) = gw.latency_ms {
153 b = b.row("Latency", &format!("{:.1}ms", lat));
154 }
155 if gw.packets_sent > 0 {
156 b = b.row(
157 "Probes",
158 &format!("{}/{} replies", gw.packets_received, gw.packets_sent),
159 );
160 }
161 output.push_str(&b.finish());
162 output.push('\n');
163 }
164
165 if let Some(ref dns) = results.dns_details {
167 let mut b = ReportBuilder::new(label_width, data_width, chars);
168 b = b.full_top_border().span_row(" DNS SERVERS").divider();
169
170 for server in &dns.servers {
171 b = b.row("Server", &server.address);
172 }
173
174 if dns.resolution_tests.is_empty() {
175 if let Some(ref test) = dns.resolution_test {
177 b = b.divider();
178 b = b.row("Test Domain", &test.domain);
179 b = b.row("Resolved", if test.resolved { "Yes" } else { "No" });
180 b = b.row("Time", &format!("{:.1}ms", test.resolution_time_ms));
181 for ip in &test.resolved_ips {
182 b = b.row("Resolved IP", ip);
183 }
184 }
185 } else {
186 for test in &dns.resolution_tests {
187 b = b.divider();
188 b = b.row("Test Domain", &test.domain);
189 b = b.row("Resolved", if test.resolved { "Yes" } else { "No" });
190 b = b.row("Time", &format!("{:.1}ms", test.resolution_time_ms));
191 for ip in &test.resolved_ips {
192 b = b.row("Resolved IP", ip);
193 }
194 }
195 }
196 output.push_str(&b.finish());
197 output.push('\n');
198 }
199
200 if let Some(ref pip) = results.public_ip_details {
202 let mut b = ReportBuilder::new(label_width, data_width, chars);
203 b = b
204 .full_top_border()
205 .span_row(" PUBLIC IP & GEOLOCATION")
206 .divider();
207 b = b.row("Public IP", &pip.ip);
208 b = b.row("Lookup Time", &format!("{:.0}ms", pip.lookup_time_ms));
209 b = b.row("Behind NAT", if pip.behind_nat { "Yes" } else { "No" });
210
211 if let Some(ref city) = pip.city {
212 b = b.row("City", city);
213 }
214 if let Some(ref region) = pip.region {
215 b = b.row("Region", region);
216 }
217 if let Some(ref country) = pip.country {
218 b = b.row("Country", country);
219 }
220 if let Some(ref isp) = pip.isp {
221 b = b.row("ISP", isp);
222 }
223 if let Some(ref org) = pip.org {
224 b = b.row("Organization", org);
225 }
226 output.push_str(&b.finish());
227 output.push('\n');
228 }
229
230 if let Some(ref latencies) = results.latency_details {
232 let mut b = ReportBuilder::new(label_width, data_width, chars);
233 b = b.full_top_border().span_row(" LATENCY TESTS").divider();
234
235 for (i, lat) in latencies.iter().enumerate() {
236 if i > 0 {
237 b = b.divider();
238 }
239 b = b.row("Host", &format!("{} ({})", lat.host, lat.label));
240 b = b.row("Reachable", if lat.reachable { "Yes" } else { "No" });
241 if let Some(min) = lat.min_ms {
242 b = b.row("Min", &format!("{:.1}ms", min));
243 }
244 if let Some(avg) = lat.avg_ms {
245 b = b.row("Avg", &format!("{:.1}ms", avg));
246 }
247 if let Some(max) = lat.max_ms {
248 b = b.row("Max", &format!("{:.1}ms", max));
249 }
250 if let Some(jitter) = lat.jitter_ms {
251 b = b.row("Jitter", &format!("{:.1}ms", jitter));
252 }
253 b = b.row("Packet Loss", &format!("{:.0}%", lat.packet_loss));
254 }
255 output.push_str(&b.finish());
256 output.push('\n');
257 }
258
259 if let Some(ref speed) = results.speed_details {
261 let mut b = ReportBuilder::new(label_width, data_width, chars);
262 b = b.full_top_border().span_row(" SPEED TEST").divider();
263
264 if let Some(ping) = speed.ping_ms {
265 b = b.row("Ping", &format!("{:.1} ms", ping));
266 }
267 if let Some(jitter) = speed.jitter_ms {
268 b = b.row("Jitter", &format!("{:.1} ms", jitter));
269 }
270 let dl_margin = speed
271 .confidence_intervals
272 .as_ref()
273 .and_then(|ci| ci.download.as_ref())
274 .map(|ci| format!(" ±{}", crate::speedtest::format_mbps(ci.margin)))
275 .unwrap_or_default();
276 let ul_margin = speed
277 .confidence_intervals
278 .as_ref()
279 .and_then(|ci| ci.upload.as_ref())
280 .map(|ci| format!(" ±{}", crate::speedtest::format_mbps(ci.margin)))
281 .unwrap_or_default();
282 b = b.row(
283 "Download",
284 &format!(
285 "{}{} (avg)",
286 crate::speedtest::format_mbps(speed.download_mbps),
287 dl_margin
288 ),
289 );
290 b = b.row(
291 "Upload",
292 &format!(
293 "{}{} (avg)",
294 crate::speedtest::format_mbps(speed.upload_mbps),
295 ul_margin
296 ),
297 );
298 if let Some(loss) = speed.packet_loss_pct {
299 b = b.row("Packet Loss", &format!("{:.0}%", loss));
300 }
301 b = b.row("Duration", &format!("{:.1}s", speed.duration_s));
302 if !speed.merge_exclusions.is_empty() {
303 let list = speed
304 .merge_exclusions
305 .iter()
306 .map(|e| {
307 format!(
308 "{} {} ({} sample{})",
309 e.provider,
310 if e.direction == "download" {
311 "DL"
312 } else {
313 "UL"
314 },
315 e.samples,
316 if e.samples == 1 { "" } else { "s" },
317 )
318 })
319 .collect::<Vec<_>>()
320 .join(", ");
321 b = b.row("Excluded", &list);
322 }
323
324 for provider in &speed.providers {
326 if provider.error.is_some() {
327 continue;
328 }
329 b = b.section_header(&provider.provider);
330 b = b.row("Server", &provider.server);
331 if let Some(ref location) = provider.location {
332 b = b.row("Location", location);
333 }
334 if let Some(dl) = provider.download_mbps {
335 b = b.row("Download", &crate::speedtest::format_mbps(dl));
336 }
337 if let Some(ul) = provider.upload_mbps {
338 b = b.row("Upload", &crate::speedtest::format_mbps(ul));
339 }
340 b = b.row("DL Data", &format_bytes(provider.download_bytes));
341 b = b.row("UL Data", &format_bytes(provider.upload_bytes));
342 }
343
344 output.push_str(&b.finish());
345 output.push('\n');
346 }
347
348 if let Some(ref ports) = results.port_details {
350 let mut b = ReportBuilder::new(label_width, data_width, chars);
351 b = b
352 .full_top_border()
353 .span_row(" PORT CONNECTIVITY")
354 .divider();
355
356 for port in ports {
357 let status = match port.outcome {
358 crate::diagnostics::ports::PortOutcome::Open => "Open",
359 crate::diagnostics::ports::PortOutcome::Blocked => "Blocked",
360 crate::diagnostics::ports::PortOutcome::Unresolved => "Untested (DNS)",
361 };
362 let lat = port
363 .latency_ms
364 .map(|l| format!(" ({:.0}ms)", l))
365 .unwrap_or_default();
366 b = b.row(
367 &format!("{} ({})", port.service, port.port),
368 &format!("{}{}", status, lat),
369 );
370 }
371 output.push_str(&b.finish());
372 output.push('\n');
373 }
374
375 if let Some(ref tech) = results.technician {
377 output.push_str(&render_technician_details(tech, config));
378 }
379
380 output.push_str(&format!(" Report generated: {}\n", results.timestamp));
382 output.push('\n');
383
384 output
385}
386
387fn render_technician_details(tech: &TechnicianResults, config: &Config) -> String {
388 let label_width = 16;
389 let data_width = 40;
390 let chars = config.box_chars();
391 let mut output = String::new();
392
393 if let Some(ref arp) = tech.arp_table {
395 if !arp.is_empty() {
396 let mut b = ReportBuilder::new(label_width, data_width, chars);
397 b = b.full_top_border().span_row(" ARP TABLE").divider();
398 for entry in arp.iter().take(30) {
399 b = b.row(&entry.ip, &format!("{} ({})", entry.mac, entry.entry_type));
400 }
401 if arp.len() > 30 {
402 b = b.row("", &format!("... and {} more", arp.len() - 30));
403 }
404 if let Some(ref health) = tech.arp_health {
405 b = b.divider();
406 b = b.row(
407 "Gateway in ARP",
408 if health.gateway_in_table { "Yes" } else { "No" },
409 );
410 if let Some(ref mac) = health.gateway_mac {
411 b = b.row("Gateway MAC", mac);
412 }
413 for dup in &health.duplicate_ip_macs {
414 b = b.row(&format!("Duplicate {}", dup.ip), &dup.macs.join(", "));
415 }
416 b = b.row("Assessment", &health.assessment);
417 }
418 output.push_str(&b.finish());
419 output.push('\n');
420 }
421 }
422
423 if let Some(ref routes) = tech.routing_table {
425 if !routes.is_empty() {
426 let mut b = ReportBuilder::new(label_width, data_width, chars);
427 b = b.full_top_border().span_row(" ROUTING TABLE").divider();
428 for route in routes.iter().take(20) {
429 let gw = if route.gateway.is_empty() {
430 "direct".to_string()
431 } else {
432 route.gateway.clone()
433 };
434 let metric = route
435 .metric
436 .map(|m| format!(" m:{}", m))
437 .unwrap_or_default();
438 b = b.row(
439 &route.destination,
440 &format!("via {} dev {}{}", gw, route.interface, metric),
441 );
442 }
443 if routes.len() > 20 {
444 b = b.row("", &format!("... and {} more", routes.len() - 20));
445 }
446 output.push_str(&b.finish());
447 output.push('\n');
448 }
449 }
450
451 if let Some(ref conns) = tech.active_connections {
453 if !conns.is_empty() {
454 let established: Vec<_> = conns
455 .iter()
456 .filter(|c| c.state == "ESTABLISHED")
457 .take(20)
458 .collect();
459 if !established.is_empty() {
460 let mut b = ReportBuilder::new(label_width, data_width, chars);
461 b = b
462 .full_top_border()
463 .span_row(" ACTIVE CONNECTIONS (ESTABLISHED)")
464 .divider();
465 for conn in &established {
466 let proc_info = conn.process_name.as_deref().unwrap_or("?");
467 b = b.row(
468 &conn.local_addr,
469 &format!("{} [{}]", conn.remote_addr, proc_info),
470 );
471 }
472 let total_established = conns.iter().filter(|c| c.state == "ESTABLISHED").count();
473 if total_established > 20 {
474 b = b.row("", &format!("... and {} more", total_established - 20));
475 }
476 output.push_str(&b.finish());
477 output.push('\n');
478 }
479 }
480 }
481
482 if let Some(ref ports) = tech.listening_ports {
484 if !ports.is_empty() {
485 let mut b = ReportBuilder::new(label_width, data_width, chars);
486 b = b.full_top_border().span_row(" LISTENING PORTS").divider();
487 for port in ports.iter().take(20) {
488 let proc_info = port.process_name.as_deref().unwrap_or("?");
489 b = b.row(
490 &format!("{} :{}", port.protocol, port.port),
491 &format!("{} [{}]", port.address, proc_info),
492 );
493 }
494 if ports.len() > 20 {
495 b = b.row("", &format!("... and {} more", ports.len() - 20));
496 }
497 output.push_str(&b.finish());
498 output.push('\n');
499 }
500 }
501
502 if let Some(ref dhcp) = tech.dhcp_info {
504 if !dhcp.is_empty() {
505 let mut b = ReportBuilder::new(label_width, data_width, chars);
506 b = b.full_top_border().span_row(" DHCP LEASES").divider();
507 for (i, lease) in dhcp.iter().enumerate() {
508 if i > 0 {
509 b = b.divider();
510 }
511 b = b.row("Interface", &lease.interface);
512 b = b.row(
513 "DHCP Enabled",
514 if lease.dhcp_enabled { "Yes" } else { "No" },
515 );
516 if let Some(ref server) = lease.dhcp_server {
517 b = b.row("DHCP Server", server);
518 }
519 if let Some(ref ip) = lease.ip_address {
520 b = b.row("IP Address", ip);
521 }
522 if let Some(ref obtained) = lease.lease_obtained {
523 b = b.row("Obtained", obtained);
524 }
525 if let Some(ref expires) = lease.lease_expires {
526 b = b.row("Expires", expires);
527 }
528 }
529 output.push_str(&b.finish());
530 output.push('\n');
531 }
532 }
533
534 if let Some(ref stats) = tech.protocol_stats {
536 let mut b = ReportBuilder::new(label_width, data_width, chars);
537 b = b
538 .full_top_border()
539 .span_row(" PROTOCOL STATISTICS")
540 .divider();
541
542 b = b.row("TCP Active Opens", &stats.tcp.active_opens.to_string());
543 b = b.row("TCP Passive Opens", &stats.tcp.passive_opens.to_string());
544 b = b.row("TCP Current", &stats.tcp.current_connections.to_string());
545 b = b.row("TCP Failed", &stats.tcp.failed_connections.to_string());
546 b = b.row("TCP Resets", &stats.tcp.reset_connections.to_string());
547 b = b.row(
548 "TCP Retransmits",
549 &stats.tcp.segments_retransmitted.to_string(),
550 );
551 b = b.row("TCP Segments In", &stats.tcp.segments_received.to_string());
552 b = b.row("TCP Segments Out", &stats.tcp.segments_sent.to_string());
553 b = b.divider();
554 b = b.row("UDP In", &stats.udp.datagrams_received.to_string());
555 b = b.row("UDP Out", &stats.udp.datagrams_sent.to_string());
556 b = b.row("UDP Errors", &stats.udp.receive_errors.to_string());
557 b = b.divider();
558 b = b.row("ICMP In", &stats.icmp.messages_received.to_string());
559 b = b.row("ICMP Out", &stats.icmp.messages_sent.to_string());
560 b = b.row("ICMP Errors In", &stats.icmp.errors_received.to_string());
561
562 output.push_str(&b.finish());
563 output.push('\n');
564 }
565
566 if let Some(ref hw) = tech.adapter_hw_stats {
568 if !hw.is_empty() {
569 let mut b = ReportBuilder::new(label_width, data_width, chars);
570 b = b
571 .full_top_border()
572 .span_row(" ADAPTER HARDWARE STATS")
573 .divider();
574 for (i, stat) in hw.iter().enumerate() {
575 if i > 0 {
576 b = b.divider();
577 }
578 b = b.row("Interface", &stat.name);
579 b = b.row("RX Bytes", &format_bytes(stat.rx_bytes));
580 b = b.row("TX Bytes", &format_bytes(stat.tx_bytes));
581 b = b.row("RX Packets", &stat.rx_packets.to_string());
582 b = b.row("TX Packets", &stat.tx_packets.to_string());
583 b = b.row("RX Errors", &stat.rx_errors.to_string());
584 b = b.row("TX Errors", &stat.tx_errors.to_string());
585 if let Some(ref speed) = stat.link_speed {
586 b = b.row("Link Speed", speed);
587 }
588 if let Some(ref dup) = stat.duplex {
589 b = b.row("Duplex", dup);
590 }
591 }
592 output.push_str(&b.finish());
593 output.push('\n');
594 }
595 }
596
597 if let Some(ref proxy) = tech.proxy_config {
599 let mut b = ReportBuilder::new(label_width, data_width, chars);
600 b = b
601 .full_top_border()
602 .span_row(" PROXY CONFIGURATION")
603 .divider();
604 b = b.row(
605 "Proxy Enabled",
606 if proxy.proxy_enabled { "Yes" } else { "No" },
607 );
608 if let Some(ref http) = proxy.http_proxy {
609 b = b.row("HTTP Proxy", http);
610 }
611 if let Some(ref https) = proxy.https_proxy {
612 b = b.row("HTTPS Proxy", https);
613 }
614 if let Some(ref socks) = proxy.socks_proxy {
615 b = b.row("SOCKS Proxy", socks);
616 }
617 if let Some(ref pac) = proxy.pac_url {
618 b = b.row("PAC URL", pac);
619 }
620 if let Some(reachable) = proxy.pac_reachable {
621 let size = proxy
622 .pac_size_bytes
623 .map(|s| format!(" ({} bytes)", s))
624 .unwrap_or_default();
625 b = b.row(
626 "PAC Reachable",
627 &if reachable {
628 format!("Yes{}", size)
629 } else {
630 "NO — configured PAC script is unreachable".to_string()
631 },
632 );
633 }
634 if let Some(wpad) = proxy.wpad_dns_detected {
635 b = b.row(
636 "WPAD DNS",
637 if wpad {
638 "Detected — proxy auto-discovery is live on this network"
639 } else {
640 "Not detected"
641 },
642 );
643 }
644 if let Some(ref no) = proxy.no_proxy {
645 b = b.row("No Proxy", no);
646 }
647 output.push_str(&b.finish());
648 output.push('\n');
649 }
650
651 if let Some(ref vpns) = tech.vpn_info {
653 let mut b = ReportBuilder::new(label_width, data_width, chars);
654 b = b.full_top_border().span_row(" VPN DETECTION").divider();
655 for (i, vpn) in vpns.iter().enumerate() {
656 if i > 0 {
657 b = b.divider();
658 }
659 b = b.row("VPN Adapter", &vpn.name);
660 b = b.row("Type", &vpn.adapter_type);
661 b = b.row("Status", &vpn.status);
662 if let Some(ref vendor) = vpn.vendor {
663 b = b.row("Vendor", vendor);
664 }
665 if vpn.is_enterprise {
666 b = b.row("Policy", "Enterprise/Managed");
667 }
668 if let Some(ref iface) = vpn.interface_name {
669 b = b.row("Interface", iface);
670 }
671 if let Some(ref ip) = vpn.ip_address {
672 b = b.row("IP Address", ip);
673 }
674 }
675 output.push_str(&b.finish());
676 output.push('\n');
677 }
678
679 if let Some(ref fw) = tech.firewall_info {
681 let mut b = ReportBuilder::new(label_width, data_width, chars);
682 b = b.full_top_border().span_row(" FIREWALL STATUS").divider();
683 b = b.row("Status", &fw.summary);
684 for profile in &fw.profiles {
685 b = b.row(
686 &profile.name,
687 if profile.enabled {
688 "Enabled"
689 } else {
690 "Disabled"
691 },
692 );
693 }
694 output.push_str(&b.finish());
695 output.push('\n');
696 }
697
698 if let Some(ref cache) = tech.dns_cache {
700 if !cache.is_empty() {
701 let mut b = ReportBuilder::new(label_width, data_width, chars);
702 b = b.full_top_border().span_row(" DNS CACHE").divider();
703 for entry in cache.iter().take(20) {
704 b = b.row(
705 &entry.name,
706 &format!("{}: {}", entry.record_type, entry.data),
707 );
708 }
709 if cache.len() > 20 {
710 b = b.row("", &format!("... and {} more entries", cache.len() - 20));
711 }
712 output.push_str(&b.finish());
713 output.push('\n');
714 }
715 }
716
717 if let Some(ref ipv6) = tech.ipv6_info {
719 let mut b = ReportBuilder::new(label_width, data_width, chars);
720 b = b.full_top_border().span_row(" IPv6 STATUS").divider();
721 b = b.row("Available", if ipv6.available { "Yes" } else { "No" });
722 let conn_str = match ipv6.connectivity {
723 crate::diagnostics::ipv6::Ipv6Connectivity::Full => "Full",
724 crate::diagnostics::ipv6::Ipv6Connectivity::LinkLocal => "Link-local only",
725 crate::diagnostics::ipv6::Ipv6Connectivity::None => "None",
726 };
727 b = b.row("Connectivity", conn_str);
728 b = b.row("Dual Stack", if ipv6.dual_stack { "Yes" } else { "No" });
729 if let Some(http_ok) = ipv6.v6_http_ok {
730 b = b.row("HTTPS over v6", if http_ok { "Working" } else { "FAILED" });
731 }
732 if let (Some(v4), Some(v6)) = (ipv6.v4_connect_ms, ipv6.v6_connect_ms) {
733 b = b.row("v4 vs v6 connect", &format!("{:.0}ms vs {:.0}ms", v4, v6));
734 }
735 if let Some(penalty) = ipv6.v6_penalty_ms {
736 if penalty > 0.0 {
737 b = b.row("v6 penalty", &format!("+{:.0}ms", penalty));
738 }
739 }
740
741 for addr in ipv6.addresses.iter().take(10) {
742 b = b.row(
743 &addr.scope,
744 &format!("{} ({})", addr.address, addr.interface),
745 );
746 }
747 output.push_str(&b.finish());
748 output.push('\n');
749 }
750
751 if let Some(ref mtus) = tech.mtu_info {
753 if !mtus.is_empty() {
754 let mut b = ReportBuilder::new(label_width, data_width, chars);
755 b = b
756 .full_top_border()
757 .span_row(" MTU PER INTERFACE")
758 .divider();
759 for mtu in mtus {
760 b = b.row(&mtu.interface, &mtu.mtu.to_string());
761 }
762 if let Some(ref pm) = tech.path_mtu {
763 b = b.divider();
764 b = b.row(
765 &format!("Path MTU ({})", pm.target),
766 &if pm.path_mtu > 0 {
767 format!("{} ({} probes)", pm.path_mtu, pm.probes)
768 } else {
769 "unmeasurable".to_string()
770 },
771 );
772 b = b.row("Assessment", &pm.assessment);
773 }
774 output.push_str(&b.finish());
775 output.push('\n');
776 }
777 }
778
779 if let Some(ref states) = tech.connection_states {
781 let mut b = ReportBuilder::new(label_width, data_width, chars);
782 b = b
783 .full_top_border()
784 .span_row(" TCP CONNECTION STATES")
785 .divider();
786 b = b.row("ESTABLISHED", &states.established.to_string());
787 b = b.row("TIME_WAIT", &states.time_wait.to_string());
788 b = b.row("CLOSE_WAIT", &states.close_wait.to_string());
789 b = b.row("FIN_WAIT_1", &states.fin_wait_1.to_string());
790 b = b.row("FIN_WAIT_2", &states.fin_wait_2.to_string());
791 b = b.row("SYN_SENT", &states.syn_sent.to_string());
792 b = b.row("SYN_RECEIVED", &states.syn_received.to_string());
793 b = b.row("CLOSING", &states.closing.to_string());
794 b = b.row("LAST_ACK", &states.last_ack.to_string());
795 b = b.row("LISTEN", &states.listen.to_string());
796 output.push_str(&b.finish());
797 output.push('\n');
798 }
799
800 if let Some(ref bb) = tech.bufferbloat {
802 let mut b = ReportBuilder::new(label_width, data_width, chars);
803 b = b.full_top_border().span_row(" BUFFERBLOAT TEST").divider();
804 b = b.row("Grade", &bb.grade);
805 b = b.row(
806 "Idle Latency",
807 &format!(
808 "{:.1}ms ({} probes)",
809 bb.unloaded_latency_ms, bb.samples_idle
810 ),
811 );
812 if let Some(loaded) = bb.loaded_latency_ms {
813 let grade = bb
814 .download_grade
815 .as_deref()
816 .map(|g| format!(" — grade {}", g))
817 .unwrap_or_default();
818 b = b.row(
819 "Loaded (down)",
820 &format!(
821 "{:.1}ms{}{}",
822 loaded,
823 bb.download_bloat_ms
824 .map(|d| format!(" (+{:.1}ms)", d))
825 .unwrap_or_default(),
826 grade
827 ),
828 );
829 }
830 if let Some(ul_loaded) = bb.upload_loaded_latency_ms {
831 let grade = bb
832 .upload_grade
833 .as_deref()
834 .map(|g| format!(" — grade {}", g))
835 .unwrap_or_default();
836 b = b.row(
837 "Loaded (up)",
838 &format!(
839 "{:.1}ms{}{}",
840 ul_loaded,
841 bb.upload_bloat_ms
842 .map(|d| format!(" (+{:.1}ms)", d))
843 .unwrap_or_default(),
844 grade
845 ),
846 );
847 }
848 b = b.row("Assessment", &bb.description);
849 output.push_str(&b.finish());
850 output.push('\n');
851 }
852
853 if let Some(ref rdns) = tech.reverse_dns {
855 if !rdns.is_empty() {
856 let mut b = ReportBuilder::new(label_width, data_width, chars);
857 b = b.full_top_border().span_row(" REVERSE DNS").divider();
858 for entry in rdns {
859 let hostname = entry.hostname.as_deref().unwrap_or("(no PTR)");
860 b = b.row(&format!("{} ({})", entry.label, entry.ip), hostname);
861 }
862 output.push_str(&b.finish());
863 output.push('\n');
864 }
865 }
866
867 if let Some(ref tls) = tech.tls_inspection {
869 let mut b = ReportBuilder::new(label_width, data_width, chars);
870 b = b
871 .full_top_border()
872 .span_row(" TLS INSPECTION CHECK")
873 .divider();
874 b = b.row("Intercepted", if tls.detected { "DETECTED" } else { "No" });
875 b = b.row("Assessment", &tls.description);
876 output.push_str(&b.finish());
877 output.push('\n');
878 }
879
880 if let Some(ref traffic) = tech.traffic_counters {
882 if !traffic.is_empty() {
883 let mut b = ReportBuilder::new(label_width, data_width, chars);
884 b = b
885 .full_top_border()
886 .span_row(" TRAFFIC COUNTERS (since boot)")
887 .divider();
888 for counter in traffic {
889 b = b.row(
890 &counter.interface,
891 &format!("RX {} / TX {}", counter.rx_formatted, counter.tx_formatted),
892 );
893 }
894 output.push_str(&b.finish());
895 output.push('\n');
896 }
897 }
898
899 if let Some(ref path) = tech.route_path {
901 let mut b = ReportBuilder::new(label_width, data_width, chars);
902 b = b
903 .full_top_border()
904 .span_row(" ROUTE PATH (TRACEROUTE)")
905 .divider();
906 for hop in path.hops.iter().take(16) {
907 let value = if hop.timed_out {
908 "*".to_string()
909 } else {
910 format!(
911 "{}{}",
912 hop.ip.as_deref().unwrap_or("?"),
913 hop.avg_ms
914 .map(|ms| format!(" {:.1}ms", ms))
915 .unwrap_or_default()
916 )
917 };
918 b = b.row(&format!("hop {}", hop.number), &value);
919 }
920 b = b.divider();
921 b = b.row("Reached", if path.reached { "Yes" } else { "No" });
922 if let Some(first) = path.first_hop_ms {
923 b = b.row("First Hop", &format!("{:.1}ms (your router)", first));
924 }
925 if let Some(boundary) = path.isp_boundary_hop {
926 b = b.row("ISP Boundary", &format!("hop {}", boundary));
927 }
928 if let Some(ref jump) = path.largest_jump {
929 b = b.row(
930 "Largest Jump",
931 &format!(
932 "+{:.1}ms (hop {}→{}, {})",
933 jump.delta_ms, jump.from_hop, jump.to_hop, jump.segment
934 ),
935 );
936 }
937 b = b.row("Assessment", &path.assessment);
938 output.push_str(&b.finish());
939 output.push('\n');
940 }
941
942 if let Some(ref losses) = tech.packet_loss {
944 let mut b = ReportBuilder::new(label_width, data_width, chars);
945 b = b
946 .full_top_border()
947 .span_row(" SUSTAINED PACKET LOSS")
948 .divider();
949 for (i, loss) in losses.iter().enumerate() {
950 if i > 0 {
951 b = b.divider();
952 }
953 b = b.row("Target", &format!("{} ({})", loss.host, loss.label));
954 b = b.row(
955 "Loss",
956 &format!(
957 "{:.1}% ({}/{} received)",
958 loss.loss_pct, loss.received, loss.sent
959 ),
960 );
961 if let (Some(min), Some(avg), Some(max)) = (loss.min_ms, loss.avg_ms, loss.max_ms) {
962 b = b.row(
963 "Latency",
964 &format!("min {:.1} / avg {:.1} / max {:.1}ms", min, avg, max),
965 );
966 }
967 if let Some(p95) = loss.p95_ms {
968 b = b.row("P95", &format!("{:.1}ms", p95));
969 }
970 if let Some(jitter) = loss.jitter_ms {
971 b = b.row("Jitter", &format!("{:.1}ms", jitter));
972 }
973 b = b.row("Assessment", &loss.assessment);
974 }
975 output.push_str(&b.finish());
976 output.push('\n');
977 }
978
979 if let Some(ref nat) = tech.nat_analysis {
981 let mut b = ReportBuilder::new(label_width, data_width, chars);
982 b = b.full_top_border().span_row(" NAT ANALYSIS").divider();
983 if let Some(ref gw) = nat.gateway_ip {
984 b = b.row(
985 "Gateway",
986 &format!(
987 "{} ({})",
988 gw,
989 if nat.gateway_is_private {
990 "private"
991 } else {
992 "public"
993 }
994 ),
995 );
996 }
997 if let Some(ref hop2) = nat.hop2_ip {
998 b = b.row(
999 "Second Hop",
1000 &format!(
1001 "{} ({})",
1002 hop2,
1003 if nat.hop2_is_private {
1004 "private"
1005 } else {
1006 "public"
1007 }
1008 ),
1009 );
1010 }
1011 if let Some(ref pip) = nat.public_ip {
1012 b = b.row("Public IP", pip);
1013 }
1014 b = b.row("NAT Layers", &format!("~{}", nat.nat_layers_estimate));
1015 b = b.row("CGNAT", if nat.cgnat_detected { "DETECTED" } else { "No" });
1016 b = b.row(
1017 "Double NAT",
1018 if nat.double_nat_suspected {
1019 "Suspected"
1020 } else {
1021 "No"
1022 },
1023 );
1024 b = b.row("Assessment", &nat.assessment);
1025 output.push_str(&b.finish());
1026 output.push('\n');
1027 }
1028
1029 if let Some(ref links) = tech.wifi {
1031 let mut b = ReportBuilder::new(label_width, data_width, chars);
1032 b = b
1033 .full_top_border()
1034 .span_row(" WI-FI LINK QUALITY")
1035 .divider();
1036 for (i, link) in links.iter().enumerate() {
1037 if i > 0 {
1038 b = b.divider();
1039 }
1040 b = b.row("Interface", &link.interface);
1041 if let Some(ref ssid) = link.ssid {
1042 b = b.row("SSID", ssid);
1043 }
1044 if let Some(ref bssid) = link.bssid {
1045 b = b.row("BSSID", bssid);
1046 }
1047 match (link.signal_pct, link.rssi_dbm) {
1048 (Some(pct), _) => b = b.row("Signal", &format!("{}%", pct)),
1049 (None, Some(rssi)) => b = b.row("Signal", &format!("{} dBm", rssi)),
1050 _ => {}
1051 }
1052 if let (Some(ch), Some(ref band)) = (link.channel, link.band.as_ref()) {
1053 b = b.row("Channel", &format!("{} ({})", ch, band));
1054 } else if let Some(ref band) = link.band {
1055 b = b.row("Band", band);
1056 }
1057 if let Some(ref phy) = link.phy_mode {
1058 b = b.row("PHY Mode", phy);
1059 }
1060 if let Some(ref sec) = link.security {
1061 b = b.row("Security", sec);
1062 }
1063 match (link.rx_rate_mbps, link.tx_rate_mbps) {
1064 (Some(rx), Some(tx)) => {
1065 b = b.row("Link Rate", &format!("RX {:.0} / TX {:.0} Mbps", rx, tx))
1066 }
1067 (Some(rx), None) => b = b.row("Link Rate", &format!("RX {:.0} Mbps", rx)),
1068 (None, Some(tx)) => b = b.row("Link Rate", &format!("TX {:.0} Mbps", tx)),
1069 _ => {}
1070 }
1071 b = b.row("Assessment", &link.assessment);
1072 }
1073 output.push_str(&b.finish());
1074 output.push('\n');
1075 }
1076
1077 if let Some(ref bench) = tech.dns_benchmark {
1079 let mut b = ReportBuilder::new(label_width, data_width, chars);
1080 b = b
1081 .full_top_border()
1082 .span_row(" DNS RESOLVER BENCHMARK")
1083 .divider();
1084 for r in &bench.resolvers {
1085 let timing = r
1086 .avg_ms
1087 .map(|ms| format!("{:.0}ms avg", ms))
1088 .unwrap_or_else(|| "no replies".to_string());
1089 b = b.row(
1090 &r.name,
1091 &format!("{} ({}/{} ok)", timing, r.queries_ok, r.queries_total),
1092 );
1093 }
1094 b = b.divider();
1095 if let Some(ref fastest) = bench.fastest {
1096 b = b.row("Fastest", fastest);
1097 }
1098 if let Some(delta) = bench.system_vs_fastest_ms {
1099 b = b.row("System vs Best", &format!("{:+.0}ms", delta));
1100 }
1101 if let Some(hijack) = bench.hijack_detected {
1102 b = b.row("NXDOMAIN Hijack", if hijack { "DETECTED" } else { "No" });
1103 }
1104 if let Some(dnssec) = bench.dnssec_validating {
1105 b = b.row(
1106 "DNSSEC",
1107 if dnssec {
1108 "Validating"
1109 } else {
1110 "Not validating"
1111 },
1112 );
1113 }
1114 b = b.row("Assessment", &bench.assessment);
1115 output.push_str(&b.finish());
1116 output.push('\n');
1117 }
1118
1119 if let Some(ref portal) = tech.captive_portal {
1121 let mut b = ReportBuilder::new(label_width, data_width, chars);
1122 b = b.full_top_border().span_row(" CAPTIVE PORTAL").divider();
1123 b = b.row(
1124 "Portal",
1125 if portal.portal_detected {
1126 "DETECTED"
1127 } else {
1128 "Not detected"
1129 },
1130 );
1131 if let Some(status) = portal.http_status {
1132 b = b.row("Probe Status", &format!("HTTP {}", status));
1133 }
1134 if let Some(ref loc) = portal.redirect_location {
1135 b = b.row("Redirects To", loc);
1136 }
1137 b = b.row("Assessment", &portal.assessment);
1138 output.push_str(&b.finish());
1139 output.push('\n');
1140 }
1141
1142 if let Some(ref clock) = tech.clock_sync {
1144 let mut b = ReportBuilder::new(label_width, data_width, chars);
1145 b = b.full_top_border().span_row(" CLOCK SYNC (NTP)").divider();
1146 if let Some(offset) = clock.offset_ms {
1147 b = b.row("Clock Offset", &format!("{:+.0}ms", offset));
1148 }
1149 b = b.row(
1150 "Source",
1151 &format!(
1152 "{} ({} server{} responded)",
1153 clock.source,
1154 clock.servers_responded,
1155 if clock.servers_responded == 1 {
1156 ""
1157 } else {
1158 "s"
1159 }
1160 ),
1161 );
1162 if clock.ntp_blocked {
1163 b = b.row("NTP (UDP/123)", "Blocked or filtered");
1164 }
1165 b = b.row("Assessment", &clock.assessment);
1166 output.push_str(&b.finish());
1167 output.push('\n');
1168 }
1169
1170 output
1171}
1172
1173fn render_summary_row(
1174 builder: ReportBuilder,
1175 result: &crate::diagnostics::DiagnosticResult,
1176 config: &Config,
1177) -> ReportBuilder {
1178 let icon = config.status_chars(&result.status);
1179 let colored_icon = colorize_status(icon, &result.status, config);
1180 let label = format!("{} {}", colored_icon, result.category);
1181
1182 let lines: Vec<&str> = result.summary.split('\n').collect();
1183 let mut b = builder.row(&label, lines[0]);
1184 for line in lines.iter().skip(1) {
1185 b = b.row("", line);
1186 }
1187 b
1188}
1189
1190fn count_issues(results: &DiagnosticResults) -> (usize, usize) {
1191 let statuses = [
1192 &results.adapters.status,
1193 &results.interfaces.status,
1194 &results.gateway.status,
1195 &results.dns.status,
1196 &results.public_ip.status,
1197 &results.latency.status,
1198 &results.speed.status,
1199 &results.ports.status,
1200 ];
1201
1202 let fails = statuses
1203 .iter()
1204 .filter(|s| ***s == DiagnosticStatus::Fail)
1205 .count();
1206 let warns = statuses
1207 .iter()
1208 .filter(|s| ***s == DiagnosticStatus::Warn)
1209 .count();
1210 (fails, warns)
1211}
1212
1213fn format_overall(fails: usize, warns: usize, config: &Config) -> String {
1214 if fails > 0 && warns > 0 {
1215 let text = format!(
1216 "{} failure{}, {} warning{}",
1217 fails,
1218 if fails > 1 { "s" } else { "" },
1219 warns,
1220 if warns > 1 { "s" } else { "" }
1221 );
1222 colorize_status(&text, &DiagnosticStatus::Fail, config)
1223 } else if fails > 0 {
1224 let text = format!(
1225 "{} failure{} detected",
1226 fails,
1227 if fails > 1 { "s" } else { "" }
1228 );
1229 colorize_status(&text, &DiagnosticStatus::Fail, config)
1230 } else if warns > 0 {
1231 let text = format!(
1232 "{} warning{} detected",
1233 warns,
1234 if warns > 1 { "s" } else { "" }
1235 );
1236 colorize_status(&text, &DiagnosticStatus::Warn, config)
1237 } else {
1238 colorize_status("All diagnostics passed", &DiagnosticStatus::Ok, config)
1239 }
1240}
1241
1242fn format_link_speed(mbps: u64) -> String {
1243 if mbps >= 1000 {
1244 format!("{:.1} Gbps", mbps as f64 / 1000.0)
1245 } else {
1246 format!("{} Mbps", mbps)
1247 }
1248}
1249
1250fn format_bytes(bytes: u64) -> String {
1251 const KB: u64 = 1024;
1252 const MB: u64 = 1024 * KB;
1253 const GB: u64 = 1024 * MB;
1254
1255 if bytes >= GB {
1256 format!("{:.2} GB", bytes as f64 / GB as f64)
1257 } else if bytes >= MB {
1258 format!("{:.1} MB", bytes as f64 / MB as f64)
1259 } else if bytes >= KB {
1260 format!("{:.1} KB", bytes as f64 / KB as f64)
1261 } else {
1262 format!("{} B", bytes)
1263 }
1264}