Source code for net_benchmark.dns_benchmark.exporters

"""Export functionality for DNS benchmark results."""

import os
import tempfile
from typing import Any, Dict, List, Optional

import matplotlib
import matplotlib.pyplot as plt
import pandas as pd
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
from openpyxl.utils.dataframe import dataframe_to_rows

try:
    from weasyprint import HTML
except ImportError:
    HTML = None

from net_benchmark.dns_benchmark.analysis import BenchmarkAnalyzer
from net_benchmark.dns_benchmark.core import DNSQueryResult

matplotlib.use("Agg")  # Use non-interactive backend


[docs] class ExportBundle:
[docs] @staticmethod def export_json( results: List[DNSQueryResult], analyzer: BenchmarkAnalyzer, domain_stats: Optional[List[Dict[str, Any]]], record_type_stats: Optional[List[Dict[str, Any]]], error_stats: Optional[Dict[str, int]], output_path: str, ) -> None: payload = { "overall": analyzer.get_overall_statistics(), "resolver_stats": [vars(s) for s in analyzer.get_resolver_statistics()], "protocol_stats": analyzer.get_protocol_statistics(), "dnssec_stats": analyzer.get_dnssec_statistics(), "raw_results": [ { "resolver_name": r.resolver_name, "resolver_ip": r.resolver_ip, "domain": r.domain, "record_type": r.record_type, "latency_ms": r.latency_ms, "status": r.status.value, "answers_count": len(r.answers), "ttl": r.ttl, "error_message": r.error_message, "start_time": r.start_time, "end_time": r.end_time, "attempt_number": r.attempt_number, "cache_hit": r.cache_hit, "iteration": r.iteration, "query_id": r.query_id, "protocol": r.protocol.value, "dnssec_validated": r.dnssec_validated, } for r in results ], "domain_stats": domain_stats, "record_type_stats": record_type_stats, "error_stats": error_stats, } with open(output_path, "w") as f: import json json.dump(payload, f, indent=2)
[docs] class CSVExporter: """Export DNS benchmark results to CSV format."""
[docs] @staticmethod def export_raw_results(results: List[DNSQueryResult], output_path: str) -> None: """Export raw query results to CSV.""" data = [] for result in results: data.append( { "timestamp": result.start_time, "resolver_name": result.resolver_name, "resolver_ip": result.resolver_ip, "domain": result.domain, "record_type": result.record_type, "latency_ms": result.latency_ms, "status": result.status.value, "answers_count": len(result.answers), "ttl": result.ttl or "", "error_message": result.error_message or "", "cache_hit": result.cache_hit, "iteration": result.iteration, "query_id": result.query_id, "protocol": result.protocol.value, "dnssec_validated": result.dnssec_validated, } ) df = pd.DataFrame(data) df.to_csv(output_path, index=False)
[docs] @staticmethod def export_summary_statistics( analyzer: BenchmarkAnalyzer, output_path: str ) -> None: """Export summary statistics to CSV.""" resolver_stats = analyzer.get_resolver_statistics() data = [] for stats in resolver_stats: data.append( { "resolver_name": stats.resolver_name, "resolver_ip": stats.resolver_ip, "total_queries": stats.total_queries, "successful_queries": stats.successful_queries, "success_rate": stats.success_rate, "min_latency_ms": stats.min_latency, "avg_latency_ms": stats.avg_latency, "median_latency_ms": stats.median_latency, "max_latency_ms": stats.max_latency, "std_latency_ms": stats.std_latency, "p95_latency_ms": stats.p95_latency, "p99_latency_ms": stats.p99_latency, "jitter_ms": stats.jitter, "consistency_score": stats.consistency_score, "dnssec_validated_queries": stats.dnssec_validated_queries, "dnssec_validation_rate": stats.dnssec_validation_rate, } ) df = pd.DataFrame(data) df.to_csv(output_path, index=False)
[docs] @staticmethod def export_domain_statistics( domain_stats: List[Dict[str, Any]], output_path: str ) -> None: df = pd.DataFrame(domain_stats) df.to_csv(output_path, index=False)
[docs] @staticmethod def export_record_type_statistics( rt_stats: List[Dict[str, Any]], output_path: str ) -> None: df = pd.DataFrame(rt_stats) df.to_csv(output_path, index=False)
[docs] @staticmethod def export_error_statistics(error_stats: Dict[str, int], output_path: str) -> None: df = pd.DataFrame( [{"error_message": k, "count": v} for k, v in error_stats.items()] ) df.to_csv(output_path, index=False)
[docs] @staticmethod def export_protocol_statistics( protocol_stats: List[Dict[str, Any]], output_path: str ) -> None: df = pd.DataFrame(protocol_stats) df.to_csv(output_path, index=False)
[docs] @staticmethod def export_dnssec_statistics( dnssec_stats: List[Dict[str, Any]], output_path: str ) -> None: df = pd.DataFrame(dnssec_stats) df.to_csv(output_path, index=False)
[docs] class ExcelExporter: """Export DNS benchmark results to Excel format."""
[docs] @staticmethod def export_results( results: List[DNSQueryResult], analyzer: BenchmarkAnalyzer, output_path: str, domain_stats: Optional[List[Dict[str, Any]]] = None, record_type_stats: Optional[List[Dict[str, Any]]] = None, error_stats: Optional[Dict[str, int]] = None, include_charts: bool = False, ) -> None: wb = Workbook() wb.remove(wb.active) # Temporary directory for chart images temp_dir = None chart_paths = [] try: # Add standard sheets ExcelExporter._add_raw_data_sheet(wb, results) ExcelExporter._add_resolver_summary_sheet(wb, analyzer) if domain_stats: ExcelExporter._add_simple_table_sheet( wb, "Domain Stats", pd.DataFrame(domain_stats) ) if record_type_stats: ExcelExporter._add_simple_table_sheet( wb, "Record Type Stats", pd.DataFrame(record_type_stats) ) if error_stats: df = pd.DataFrame( [{"Error": k, "Count": v} for k, v in error_stats.items()] ) ExcelExporter._add_simple_table_sheet(wb, "Error Breakdown", df) # Protocol breakdown sheet protocol_stats = analyzer.get_protocol_statistics() if protocol_stats: ExcelExporter._add_simple_table_sheet( wb, "Protocol Stats", pd.DataFrame(protocol_stats) ) # DNSSEC breakdown sheet dnssec_stats = analyzer.get_dnssec_statistics() if dnssec_stats: ExcelExporter._add_dnssec_sheet(wb, dnssec_stats) # Add charts if requested if include_charts: temp_dir = tempfile.mkdtemp() chart_paths = ExcelExporter._add_charts_sheet(wb, analyzer, temp_dir) # Save workbook before cleaning up temp files wb.save(output_path) finally: # Clean up temporary files after workbook is saved for chart_path in chart_paths: if os.path.exists(chart_path): try: os.remove(chart_path) except OSError: pass # Remove temp directory if it exists if temp_dir and os.path.exists(temp_dir): try: os.rmdir(temp_dir) except OSError: pass
@staticmethod def _add_simple_table_sheet(wb: Workbook, title: str, df: pd.DataFrame) -> None: ws = wb.create_sheet(title) headers = list(df.columns) for col_idx, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col_idx, value=header) cell.font = Font(bold=True) cell.fill = PatternFill( start_color="E0E0E0", end_color="E0E0E0", fill_type="solid" ) for row_idx, row in enumerate( dataframe_to_rows(df, index=False, header=False), 2 ): for col_idx, value in enumerate(row, 1): ws.cell(row=row_idx, column=col_idx, value=value) for column in ws.columns: max_length = 0 letter = column[0].column_letter for cell in column: try: max_length = max(max_length, len(str(cell.value))) except: # noqa: E722 pass ws.column_dimensions[letter].width = min(max_length + 2, 50) @staticmethod def _add_raw_data_sheet(wb: Workbook, results: List[DNSQueryResult]) -> None: """Add raw query results sheet.""" ws = wb.create_sheet("Raw Data") data = [] for result in results: data.append( { "Resolver Name": result.resolver_name, "Resolver IP": result.resolver_ip, "Domain": result.domain, "Record Type": result.record_type, "Latency (ms)": result.latency_ms, "Status": result.status.value, "Answers Count": len(result.answers), "TTL": result.ttl or "", "Error Message": result.error_message or "", "Attempts": result.attempt_number, "Cached": result.cache_hit, "Iteration": result.iteration, "Protocol": result.protocol.value, "DNSSEC Validated": result.dnssec_validated, } ) df = pd.DataFrame(data) # Add headers with formatting headers = list(df.columns) for col_idx, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col_idx, value=header) cell.font = Font(bold=True) cell.fill = PatternFill( start_color="E0E0E0", end_color="E0E0E0", fill_type="solid" ) # Add data for row_idx, row in enumerate( dataframe_to_rows(df, index=False, header=False), 2 ): for col_idx, value in enumerate(row, 1): ws.cell(row=row_idx, column=col_idx, value=value) # Auto-size columns for column in ws.columns: max_length = 0 column_letter = column[0].column_letter for cell in column: try: if len(str(cell.value)) > max_length: max_length = len(str(cell.value)) except: # noqa: E722 pass adjusted_width = min(max_length + 2, 50) ws.column_dimensions[column_letter].width = adjusted_width @staticmethod def _add_resolver_summary_sheet(wb: Workbook, analyzer: BenchmarkAnalyzer) -> None: """Add resolver statistics sheet.""" ws = wb.create_sheet("Resolver Summary") resolver_stats = analyzer.get_resolver_statistics() data = [] for stats in resolver_stats: data.append( { "Resolver Name": stats.resolver_name, "Resolver IP": stats.resolver_ip, "Total Queries": stats.total_queries, "Successful Queries": stats.successful_queries, "Success Rate (%)": round(stats.success_rate, 2), "Min Latency (ms)": round(stats.min_latency, 2), "Avg Latency (ms)": round(stats.avg_latency, 2), "Median Latency (ms)": round(stats.median_latency, 2), "Max Latency (ms)": round(stats.max_latency, 2), "Std Dev (ms)": round(stats.std_latency, 2), "P95 Latency (ms)": round(stats.p95_latency, 2), "P99 Latency (ms)": round(stats.p99_latency, 2), "Jitter": round(stats.jitter, 2), "Consistency": round(stats.consistency_score, 2), "DNSSEC Validated": stats.dnssec_validated_queries, "DNSSEC Rate (%)": round(stats.dnssec_validation_rate, 2), } ) df = pd.DataFrame(data) # Add headers headers = list(df.columns) for col_idx, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col_idx, value=header) cell.font = Font(bold=True) cell.fill = PatternFill( start_color="E0E0E0", end_color="E0E0E0", fill_type="solid" ) # Add data for row_idx, row in enumerate( dataframe_to_rows(df, index=False, header=False), 2 ): for col_idx, value in enumerate(row, 1): ws.cell(row=row_idx, column=col_idx, value=value) # Auto-size columns for column in ws.columns: max_length = 0 column_letter = column[0].column_letter for cell in column: try: if len(str(cell.value)) > max_length: max_length = len(str(cell.value)) except: # noqa: E722 pass adjusted_width = min(max_length + 2, 50) ws.column_dimensions[column_letter].width = adjusted_width @staticmethod def _add_charts_sheet( wb: Workbook, analyzer: BenchmarkAnalyzer, temp_dir: str ) -> List[str]: """Add a sheet with embedded chart images. Returns: List of chart file paths that need to be cleaned up later. """ from openpyxl.drawing.image import Image as XLImage ws = wb.create_sheet("Charts") # Generate charts latency_chart_path = ExcelExporter._generate_latency_chart_for_excel( analyzer, temp_dir ) success_chart_path = ExcelExporter._generate_success_chart_for_excel( analyzer, temp_dir ) # Add title ws["A1"] = "DNS Resolver Performance Charts" ws["A1"].font = Font(bold=True, size=14) # Add latency chart ws["A3"] = "Average Latency Comparison" ws["A3"].font = Font(bold=True, size=12) img1 = XLImage(latency_chart_path) img1.width = 600 img1.height = 360 ws.add_image(img1, "A4") # Add success rate chart ws["A24"] = "Success Rate Comparison" ws["A24"].font = Font(bold=True, size=12) img2 = XLImage(success_chart_path) img2.width = 600 img2.height = 360 ws.add_image(img2, "A25") # Return paths for cleanup after workbook is saved return [latency_chart_path, success_chart_path] @staticmethod def _add_dnssec_sheet(wb: Workbook, dnssec_stats: List[Dict[str, Any]]) -> None: """DNSSEC validation sheet with conditional colouring.""" ws = wb.create_sheet("DNSSEC") headers = [ "Resolver", "IP", "Protocol", "Total Queries", "Validated", "Validation Rate (%)", "Fully Validated", ] GREEN = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") RED = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") HEADER = PatternFill( start_color="E0E0E0", end_color="E0E0E0", fill_type="solid" ) for col_idx, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col_idx, value=header) cell.font = Font(bold=True) cell.fill = HEADER for row_idx, stat in enumerate(dnssec_stats, 2): values = [ stat["resolver_name"], stat["resolver_ip"], stat["protocol"], stat["total_queries"], stat["dnssec_validated_queries"], round(stat["dnssec_validation_rate"], 2), stat["fully_validated"], ] for col_idx, value in enumerate(values, 1): cell = ws.cell(row=row_idx, column=col_idx, value=value) # Colour entire row by validation status row_fill = GREEN if stat["fully_validated"] else RED for col_idx in range(1, len(headers) + 1): ws.cell(row=row_idx, column=col_idx).fill = row_fill for column in ws.columns: max_length = max( (len(str(cell.value)) for cell in column if cell.value), default=10 ) ws.column_dimensions[column[0].column_letter].width = min( max_length + 2, 40 ) @staticmethod def _generate_latency_chart_for_excel( analyzer: BenchmarkAnalyzer, output_dir: str ) -> str: """Generate latency chart for Excel embedding.""" resolver_stats = analyzer.get_resolver_statistics() valid_resolvers = [s for s in resolver_stats if s.successful_queries > 0] fig, ax = plt.subplots(figsize=(10, 6)) if not valid_resolvers: ax.text( 0.5, 0.5, "No successful queries", ha="center", va="center", fontsize=14 ) ax.axis("off") else: names = [s.resolver_name for s in valid_resolvers] avg_latencies = [s.avg_latency for s in valid_resolvers] colors = [ "#2ecc71" if lat < 50 else "#f39c12" if lat < 100 else "#e74c3c" for lat in avg_latencies ] bars = ax.bar(range(len(names)), avg_latencies, color=colors) ax.set_xticks(range(len(names))) ax.set_xticklabels(names, rotation=45, ha="right") ax.set_ylabel("Average Latency (ms)") ax.set_title("DNS Resolver Performance Comparison") # Add value labels on bars for bar in bars: height = bar.get_height() ax.annotate( f"{height:.1f}", xy=(bar.get_x() + bar.get_width() / 2, height), xytext=(0, 3), textcoords="offset points", ha="center", va="bottom", ) ax.grid(True, alpha=0.3, axis="y") plt.tight_layout() chart_path = os.path.join(output_dir, "excel_latency_chart.png") plt.savefig(chart_path, dpi=150, bbox_inches="tight") plt.close() return chart_path @staticmethod def _generate_success_chart_for_excel( analyzer: BenchmarkAnalyzer, output_dir: str ) -> str: """Generate success rate chart for Excel embedding.""" resolver_stats = analyzer.get_resolver_statistics() names = [s.resolver_name for s in resolver_stats] rates = [s.success_rate for s in resolver_stats] colors = [ "#2ecc71" if r > 95 else "#f39c12" if r > 80 else "#e74c3c" for r in rates ] fig, ax = plt.subplots(figsize=(10, 6)) bars = ax.bar(range(len(names)), rates, color=colors) ax.set_xticks(range(len(names))) ax.set_xticklabels(names, rotation=45, ha="right") ax.set_ylabel("Success Rate (%)") ax.set_title("DNS Resolver Success Rates") # ax.set_ylim(0, 100) # Add value labels for bar in bars: height = bar.get_height() ax.annotate( f"{height:.1f}%", xy=(bar.get_x() + bar.get_width() / 2, height), xytext=(0, 3), textcoords="offset points", ha="center", va="bottom", ) ax.grid(True, alpha=0.3, axis="y") plt.tight_layout() chart_path = os.path.join(output_dir, "excel_success_chart.png") plt.savefig(chart_path, dpi=150, bbox_inches="tight") plt.close() return chart_path
[docs] class PDFExporter: """Export DNS benchmark results to PDF format."""
[docs] @staticmethod def export_results( results: List[DNSQueryResult], analyzer: BenchmarkAnalyzer, output_path: str, include_success_chart: bool = False, ) -> None: if HTML is None: raise RuntimeError( "PDF export requires 'weasyprint'. Install with: pip install net-benchmark[pdf]" ) charts_dir = tempfile.mkdtemp() try: # Generate charts latency_chart_path = PDFExporter._generate_latency_chart( analyzer, charts_dir ) success_chart_path = ( PDFExporter._generate_success_rate_chart(analyzer, charts_dir) if include_success_chart else None ) # Convert images to base64 for embedding import base64 with open(latency_chart_path, "rb") as f: latency_b64 = base64.b64encode(f.read()).decode("utf-8") success_b64 = None if success_chart_path: with open(success_chart_path, "rb") as f: success_b64 = base64.b64encode(f.read()).decode("utf-8") # Generate HTML content with base64 images html_content = PDFExporter._generate_html_content( analyzer, latency_b64, success_b64, dnssec_stats=analyzer.get_dnssec_statistics(), ) # Write PDF before cleaning up temp files HTML(string=html_content).write_pdf(output_path) finally: # Clean up temporary files after PDF is written for p in [latency_chart_path, success_chart_path]: if p and os.path.exists(p): try: os.remove(p) except OSError: pass if os.path.exists(charts_dir): try: os.rmdir(charts_dir) except OSError: pass
@staticmethod def _generate_latency_chart(analyzer: BenchmarkAnalyzer, output_dir: str) -> str: """Generate latency comparison bar chart.""" resolver_stats = analyzer.get_resolver_statistics() valid_resolvers = [s for s in resolver_stats if s.successful_queries > 0] fig, ax = plt.subplots(figsize=(10, 6)) if not valid_resolvers: ax.text( 0.5, 0.5, "No successful queries", ha="center", va="center", fontsize=14 ) ax.axis("off") else: names = [s.resolver_name for s in valid_resolvers] avg_latencies = [s.avg_latency for s in valid_resolvers] colors = [ "#2ecc71" if latency < 50 else "#f39c12" if latency < 100 else "#e74c3c" for latency in avg_latencies ] bars = ax.bar(range(len(names)), avg_latencies, color=colors) ax.set_xticks(range(len(names))) ax.set_xticklabels(names, rotation=45, ha="right") ax.set_ylabel("Average Latency (ms)") ax.set_title("DNS Resolver Performance Comparison") for bar in bars: h = bar.get_height() ax.annotate( f"{h:.1f}", xy=(bar.get_x() + bar.get_width() / 2, h), xytext=(0, 3), textcoords="offset points", ha="center", va="bottom", ) ax.grid(True, alpha=0.3, axis="y") plt.tight_layout() chart_path = os.path.join(output_dir, "latency_comparison.png") plt.savefig(chart_path, dpi=150, bbox_inches="tight") plt.close() return chart_path @staticmethod def _generate_success_rate_chart( analyzer: BenchmarkAnalyzer, output_dir: str ) -> str: """Generate success rate chart.""" resolver_stats = analyzer.get_resolver_statistics() names = [s.resolver_name for s in resolver_stats] rates = [s.success_rate for s in resolver_stats] colors = [ "#2ecc71" if r > 95 else "#f39c12" if r > 80 else "#e74c3c" for r in rates ] fig, ax = plt.subplots(figsize=(10, 6)) bars = ax.bar(range(len(names)), rates, color=colors) ax.set_xticks(range(len(names))) ax.set_xticklabels(names, rotation=45, ha="right") ax.set_ylabel("Success Rate (%)") ax.set_title("DNS Resolver Success Rates") # ax.set_ylim(0, 100) for bar in bars: h = bar.get_height() ax.annotate( f"{h:.1f}%", xy=(bar.get_x() + bar.get_width() / 2, h), xytext=(0, 3), textcoords="offset points", ha="center", va="bottom", ) ax.grid(True, alpha=0.3, axis="y") plt.tight_layout() chart_path = os.path.join(output_dir, "success_rates.png") plt.savefig(chart_path, dpi=150, bbox_inches="tight") plt.close() return chart_path @staticmethod def _generate_html_content( analyzer: BenchmarkAnalyzer, latency_chart_b64: str, success_chart_b64: Optional[str] = None, dnssec_stats: Optional[List[Dict[str, Any]]] = None, ) -> str: """Generate HTML content for PDF report.""" resolver_stats = analyzer.get_resolver_statistics() overall_stats = analyzer.get_overall_statistics() ranked_resolvers = sorted( [s for s in resolver_stats if s.successful_queries > 0], key=lambda x: x.avg_latency, ) from datetime import datetime current_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Optional success chart block success_block = "" if success_chart_b64: success_block = f""" <div class="section"> <h2>Success Rates</h2> <div class="chart"> <img src="data:image/png;base64,{success_chart_b64}" alt="DNS Resolver Success Rates"> <p><em>Success rate comparison across DNS resolvers (higher is better)</em></p> </div> </div> """ dnssec_block = "" if dnssec_stats: rows = "".join( f"<tr>" f"<td>{s['resolver_name']}</td>" f"<td>{s['protocol']}</td>" f"<td>{s['dnssec_validated_queries']}/{s['total_queries']}</td>" f"<td>{s['dnssec_validation_rate']:.1f}%</td>" f"<td style='color:{'green' if s['fully_validated'] else 'red'};font-weight:bold'>" f"{'✓' if s['fully_validated'] else '✗'}</td>" f"</tr>" for s in dnssec_stats ) dnssec_block = f""" <div class="section"> <h2>DNSSEC Validation</h2> <table> <tr> <th>Resolver</th> <th>Protocol</th> <th>Validated</th> <th>Rate</th> <th>Fully Validated</th> </tr> {rows} </table> </div>""" # Extended executive summary with DNSSEC fields (safe fallback) dnssec_summary = "" if dnssec_stats: total_validated = sum( s.get("dnssec_validated_queries", 0) for s in dnssec_stats ) total_queries_dnssec = sum(s.get("total_queries", 0) for s in dnssec_stats) validation_rate = ( (total_validated / total_queries_dnssec * 100) if total_queries_dnssec else 0 ) dnssec_summary = f""" <p><strong>DNSSEC validated queries:</strong> {total_validated} ({validation_rate:.1f}%)</p> """ # Add protocols used (collect unique protocols from dnssec_stats) protocols = sorted(set(s.get("protocol", "Unknown") for s in dnssec_stats)) dnssec_summary += ( f"<p><strong>Protocols tested:</strong> {', '.join(protocols)}</p>" ) template_str = f""" <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>DNS Benchmark Report</title> <style> body {{ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 20px; color: #333; line-height: 1.6; }} .header {{ text-align: center; border-bottom: 3px solid #2c3e50; padding-bottom: 20px; margin-bottom: 30px; }} .section {{ margin-bottom: 40px; }} table {{ width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 0.9em; }} th, td {{ padding: 12px 15px; text-align: left; border-bottom: 1px solid #ddd; }} th {{ background-color: #34495e; color: white; }} tr:nth-child(even) {{ background-color: #f8f9fa; }} .chart {{ text-align: center; margin: 30px 0; }} .chart img {{ max-width: 100%; height: auto; border: 1px solid #ddd; border-radius: 5px; }} </style> </head> <body> <div class="header"> <h1>DNS Benchmark Report</h1> <p>Generated on: {current_date}</p> </div> <div class="section"> <h2>Executive Summary</h2> <p><strong>Total queries:</strong> {overall_stats['total_queries']}</p> <p><strong>Successful:</strong> {overall_stats['successful_queries']} ({overall_stats['overall_success_rate']:.1f}%)</p> <p><strong>Average latency:</strong> {overall_stats['overall_avg_latency']:.1f} ms</p> <p><strong>Median latency:</strong> {overall_stats['overall_median_latency']:.1f} ms</p> <p><strong>Resolvers tested:</strong> {overall_stats['resolver_count']}</p> <p><strong>Domains tested:</strong> {overall_stats['domain_count']}</p> <p><strong>Fastest resolver:</strong> {overall_stats['fastest_resolver']}</p> <p><strong>Slowest resolver:</strong> {overall_stats['slowest_resolver']}</p> </div> <div class="section"> <h2>Latency Comparison</h2> <div class="chart"> <img src="data:image/png;base64,{latency_chart_b64}" alt="Latency Comparison"> </div> </div> {success_block} <div class="section"> <h2>Resolver Rankings</h2> <table> <tr> <th>Rank</th> <th>Resolver</th> <th>Avg Latency (ms)</th> <th>Success Rate (%)</th> <th>Queries</th> </tr> {''.join( f"<tr><td>{i + 1}</td><td>{r.resolver_name}</td>" f"<td>{r.avg_latency:.1f}</td><td>{r.success_rate:.1f}%</td>" f"<td>{r.successful_queries}/{r.total_queries}</td></tr>" for i, r in enumerate(ranked_resolvers) )} </table> </div> <div class="section"> <h2>Detailed Statistics</h2> <table> <tr> <th>Resolver</th> <th>IP Address</th> <th>Min (ms)</th> <th>Avg (ms)</th> <th>Max (ms)</th> <th>Std Dev</th> <th>P95 (ms)</th> <th>Success Rate (%)</th> </tr> {''.join( f"<tr><td>{r.resolver_name}</td><td>{r.resolver_ip}</td>" f"<td>{r.min_latency:.1f}</td><td>{r.avg_latency:.1f}</td>" f"<td>{r.max_latency:.1f}</td><td>{r.std_latency:.1f}</td>" f"<td>{r.p95_latency:.1f}</td><td>{r.success_rate:.1f}%</td></tr>" for r in resolver_stats )} </table> </div> {dnssec_block} </body> </html> """ return template_str