feat: Add HTML report generation with configurable output formats and improve packet processing robustness.

This commit is contained in:
eddieoz
2025-11-28 20:25:40 +02:00
parent b822c6b116
commit ded1de6b2f
4 changed files with 94 additions and 40 deletions

View File

@@ -189,7 +189,8 @@ class MeshMonitor:
def on_receive(self, packet: dict, interface) -> None: def on_receive(self, packet: dict, interface) -> None:
""" """
Callback for received packets. Callback for received packets.
""" # We need: id, fromId, hopLimit (if available) """
try:
pkt_info = { pkt_info = {
'id': packet.get('id'), 'id': packet.get('id'),
'fromId': packet.get('fromId'), 'fromId': packet.get('fromId'),

View File

@@ -7,6 +7,9 @@ from .utils import get_val, haversine, get_node_name
from mesh_analyzer.route_analyzer import RouteAnalyzer from mesh_analyzer.route_analyzer import RouteAnalyzer
import io
import markdown
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NetworkReporter: class NetworkReporter:
@@ -19,7 +22,7 @@ class NetworkReporter:
def generate_report(self, nodes: dict, test_results: list, analysis_issues: list, local_node: dict = None, router_stats: list = None, analyzer: object = None, override_timestamp: str = None, override_location: str = None, save_json: bool = True, output_filename: str = None) -> str: def generate_report(self, nodes: dict, test_results: list, analysis_issues: list, local_node: dict = None, router_stats: list = None, analyzer: object = None, override_timestamp: str = None, override_location: str = None, save_json: bool = True, output_filename: str = None) -> str:
""" """
Generates a Markdown report based on collected data. Generates a Markdown and/or HTML report based on collected data.
Also persists all raw data to JSON format. Also persists all raw data to JSON format.
Args: Args:
@@ -41,58 +44,99 @@ class NetworkReporter:
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
report_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S') report_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# Use custom filename if provided, otherwise use timestamp-based name # Determine base filename
if output_filename: if output_filename:
filename = output_filename if output_filename.endswith('.md') else f"{output_filename}.md" base_name = output_filename.replace('.md', '').replace('.html', '')
# Extract base name without extension for JSON
base_name = output_filename.replace('.md', '')
json_filename = f"{base_name}.json"
else: else:
filename = f"report-{timestamp}.md" base_name = f"report-{timestamp}"
json_filename = f"report-{timestamp}.json"
filepath = os.path.join(self.report_dir, filename) json_filename = f"{base_name}.json"
json_filepath = os.path.join(self.report_dir, json_filename) json_filepath = os.path.join(self.report_dir, json_filename)
logger.info(f"Generating network report: {filepath}") logger.info(f"Generating network report: {base_name}")
# Run Route Analysis # Run Route Analysis
route_analyzer = RouteAnalyzer(nodes) route_analyzer = RouteAnalyzer(nodes)
route_analysis = route_analyzer.analyze_routes(test_results) route_analysis = route_analyzer.analyze_routes(test_results)
try: try:
# --- Generate Markdown Report --- # --- Generate Report Content ---
with open(filepath, "w") as f: # We build the markdown content in memory first
# Header f = io.StringIO()
f.write(f"# Meshtastic Network Report\n")
f.write(f"**Date:** {report_date}\n\n") # Header
f.write(f"# Meshtastic Network Report\n")
f.write(f"**Date:** {report_date}\n\n")
# Calculate location if not overridden # Calculate location if not overridden
if override_location: if override_location:
test_location = override_location test_location = override_location
else: else:
test_location = self._get_location_string(nodes, local_node) test_location = self._get_location_string(nodes, local_node)
# 1. Executive Summary # 1. Executive Summary
self._write_executive_summary(f, nodes, test_results, analysis_issues, test_location) self._write_executive_summary(f, nodes, test_results, analysis_issues, test_location)
# 2. Network Health (Analysis Findings) # 2. Network Health (Analysis Findings)
self._write_network_health(f, analysis_issues, analyzer) self._write_network_health(f, analysis_issues, analyzer)
# 2.1 Router Performance Table (New)
if router_stats:
self._write_router_performance_table(f, router_stats)
# 3. Route Analysis (New Section)
self._write_route_analysis(f, route_analysis)
# 4. Traceroute Results
self._write_traceroute_results(f, test_results, nodes, local_node)
# 5. Recommendations
self._write_recommendations(f, analysis_issues, test_results, analyzer)
# Get the full markdown content
markdown_content = f.getvalue()
f.close()
# --- Output to Files ---
output_formats = self.config.get('report_output_formats', ['markdown'])
generated_files = []
# 1. Markdown Output
if 'markdown' in output_formats:
md_filepath = os.path.join(self.report_dir, f"{base_name}.md")
with open(md_filepath, "w") as md_file:
md_file.write(markdown_content)
generated_files.append(md_filepath)
logger.info(f"Report generated: {md_filepath}")
# 2. HTML Output
if 'html' in output_formats:
html_filepath = os.path.join(self.report_dir, f"{base_name}.html")
# 2.1 Router Performance Table (New) # Basic CSS for better readability
if router_stats: css = """
self._write_router_performance_table(f, router_stats) <style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max_width: 960px; margin: 0 auto; padding: 20px; }
# 3. Route Analysis (New Section) h1, h2, h3 { color: #2c3e50; margin-top: 1.5em; }
self._write_route_analysis(f, route_analysis) h1 { border-bottom: 2px solid #eee; padding-bottom: 10px; }
h2 { border-bottom: 1px solid #eee; padding-bottom: 5px; }
# 4. Traceroute Results table { border-collapse: collapse; width: 100%; margin: 20px 0; }
self._write_traceroute_results(f, test_results, nodes, local_node) th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f8f9fa; font-weight: bold; }
# 5. Recommendations tr:nth-child(even) { background-color: #f9f9f9; }
self._write_recommendations(f, analysis_issues, test_results, analyzer) code { background-color: #f5f5f5; padding: 2px 4px; border-radius: 3px; font-family: monospace; }
ul { padding-left: 20px; }
logger.info(f"Report generated successfully: {filepath}") li { margin-bottom: 5px; }
</style>
"""
html_content = markdown.markdown(markdown_content, extensions=['tables', 'fenced_code'])
full_html = f"<!DOCTYPE html>\n<html>\n<head>\n<meta charset='utf-8'>\n<title>Meshtastic Network Report - {report_date}</title>\n{css}\n</head>\n<body>\n{html_content}\n</body>\n</html>"
with open(html_filepath, "w") as html_file:
html_file.write(full_html)
generated_files.append(html_filepath)
logger.info(f"Report generated: {html_filepath}")
# --- Persist Raw Data to JSON --- # --- Persist Raw Data to JSON ---
if save_json: if save_json:
@@ -112,7 +156,10 @@ class NetworkReporter:
except Exception as json_e: except Exception as json_e:
logger.error(f"Failed to save JSON data: {json_e}") logger.error(f"Failed to save JSON data: {json_e}")
return filepath # Return the primary file path (prefer markdown if available, else first generated)
if generated_files:
return generated_files[0]
return None
except Exception as e: except Exception as e:
logger.error(f"Failed to generate report: {e}") logger.error(f"Failed to generate report: {e}")
return None return None

View File

@@ -2,3 +2,4 @@ meshtastic
pypubsub pypubsub
PyYAML PyYAML
markdown

View File

@@ -32,6 +32,11 @@ auto_discovery_limit: 5
# Generate report after N full testing cycles # Generate report after N full testing cycles
report_cycles: 1 report_cycles: 1
# Report Output Formats
# Options: 'markdown', 'html'
report_output_formats:
- markdown
# Active Testing Settings # Active Testing Settings
# Timeout for traceroute response (in seconds) # Timeout for traceroute response (in seconds)
traceroute_timeout: 90 traceroute_timeout: 90