From 5e6797a25430d469715e8d0030c05d9f109ac596 Mon Sep 17 00:00:00 2001 From: Nik Kale Date: Fri, 26 Dec 2025 16:02:17 -0800 Subject: [PATCH] feat: implement interactive HTML reports with service graph visualization --- autorca_core/outputs/reports.py | 448 +++++++++++++++++++++++++++++--- 1 file changed, 415 insertions(+), 33 deletions(-) diff --git a/autorca_core/outputs/reports.py b/autorca_core/outputs/reports.py index 0a2902b..365b51d 100644 --- a/autorca_core/outputs/reports.py +++ b/autorca_core/outputs/reports.py @@ -3,10 +3,12 @@ """ import json -from typing import Dict, Any +from typing import Dict, Any, List from datetime import datetime from autorca_core.reasoning.loop import RCARunResult +from autorca_core.reasoning.rules import RootCauseCandidate +from autorca_core.model.graph import ServiceGraph from autorca_core.logging import get_logger logger = get_logger(__name__) @@ -154,47 +156,427 @@ def generate_json_report(result: RCARunResult, indent: int = 2) -> str: def generate_html_report(result: RCARunResult) -> str: """ - Generate an HTML-formatted RCA report. + Generate an interactive HTML-formatted RCA report with visualizations. - TODO: Implement HTML report generation with charts and visualizations. + Features: + - Service graph visualization (SVG) + - Interactive timeline + - Collapsible evidence sections + - Copy-to-clipboard functionality + - Self-contained (no external dependencies) Args: result: RCA run result Returns: - HTML-formatted report string + Interactive HTML-formatted report string """ - # For now, convert markdown to basic HTML - markdown = generate_markdown_report(result) - - html_parts = [ - "", - "", - "", - " ", - " RCA Report - AutoRCA-Core", - " ", - "", - "", - "
",
-        markdown,
-        "  
", - "", - "", + # Generate service graph SVG + graph_svg = _generate_service_graph_svg(result.service_graph, result.root_cause_candidates) + + # Generate timeline HTML + timeline_html = _generate_timeline_html(result.timeline) + + # Generate candidates HTML + candidates_html = _generate_candidates_html(result.root_cause_candidates) + + html = f""" + + + + + RCA Report - {result.primary_symptom} + + + +
+

🔍 Root Cause Analysis Report

+
+ Incident: {result.primary_symptom}
+ Analysis Time: {datetime.now().isoformat()}
+ Window: {result.metadata.get('window_start', 'N/A')} to {result.metadata.get('window_end', 'N/A')} +
+
+ +
+

📊 Overview

+
+
+
{result.metadata.get('num_services', 0)}
+
Services
+
+
+
{result.metadata.get('num_incidents', 0)}
+
Incidents
+
+
+
{len(result.root_cause_candidates)}
+
Candidates
+
+
+
{result.metadata.get('num_logs', 0)}
+
Log Events
+
+
+
+ +
+

📝 Executive Summary

+
{result.summary}
+
+ +
+

🌐 Service Graph

+
+ {graph_svg} +
+
+ +
+

🎯 Root Cause Candidates

+ {candidates_html} +
+ +
+

⏱️ Incident Timeline

+ {timeline_html} +
+ + + +""" + + return html + + +def _generate_service_graph_svg(graph: ServiceGraph, candidates: List[RootCauseCandidate]) -> str: + """Generate SVG visualization of the service graph.""" + if not graph.services: + return "

No services detected

" + + # Find root cause services + root_cause_services = {c.service for c in candidates[:3]} + + # Simple force-directed layout + services = list(graph.services.keys()) + num_services = len(services) + + if num_services == 0: + return "

No services to display

" + + # Calculate positions (simple circular layout) + import math + radius = max(200, num_services * 30) + positions = {} + for i, service in enumerate(services): + angle = 2 * math.pi * i / num_services + x = 400 + radius * math.cos(angle) + y = 300 + radius * math.sin(angle) + positions[service] = (x, y) + + # Build SVG + svg_parts = [ + f'', + '', + '', + '', + '', + '', ] - return "\n".join(html_parts) + # Draw edges (dependencies) + for dep in graph.dependencies: + if dep.from_service in positions and dep.to_service in positions: + x1, y1 = positions[dep.from_service] + x2, y2 = positions[dep.to_service] + svg_parts.append( + f'' + ) + + # Draw nodes (services) + for service, (x, y) in positions.items(): + is_root_cause = service in root_cause_services + color = "#e74c3c" if is_root_cause else "#3498db" + stroke_width = 4 if is_root_cause else 2 + + # Service circle + svg_parts.append( + f'' + ) + + # Service label + label = service[:10] + "..." if len(service) > 10 else service + svg_parts.append( + f'{label}' + ) + + # Incident marker + incidents = graph.get_incidents_for_service(service) + if incidents: + svg_parts.append( + f'' + ) + svg_parts.append( + f'{len(incidents)}' + ) + + svg_parts.append('') + return '\n'.join(svg_parts) + + +def _generate_timeline_html(timeline: List[Dict[str, Any]]) -> str: + """Generate HTML for incident timeline.""" + if not timeline: + return "

No incidents detected

" + + html_parts = [] + for incident in timeline[:20]: # Limit to 20 + timestamp = incident['timestamp'][:19] + service = incident['service'] + inc_type = incident['type'] + description = incident['description'] + severity = incident['severity'] + + html_parts.append( + f'
' + f'{timestamp} | ' + f'{service} | ' + f'{inc_type}: {description} ' + f'(severity: {severity:.2f})' + f'
' + ) + + return '\n'.join(html_parts) + + +def _generate_candidates_html(candidates: List[RootCauseCandidate]) -> str: + """Generate HTML for root cause candidates.""" + if not candidates: + return "

No root cause candidates identified

" + + html_parts = [] + for i, candidate in enumerate(candidates[:5], 1): + evidence_items = '\n'.join( + f'
  • {ev}
  • ' for ev in candidate.evidence[:10] + ) + remediation_items = '\n'.join( + f'
  • {step}
  • ' for step in candidate.remediation + ) + + html_parts.append(f''' +
    +
    +

    #{i}: {candidate.service}

    + {candidate.confidence:.0%} Confidence +
    +

    {candidate.explanation}

    + + +
    +
      {evidence_items}
    +
    + + +
    +
      {remediation_items}
    +
    +
    +''') + + return '\n'.join(html_parts) def save_report(result: RCARunResult, output_path: str, format: str = "markdown") -> None: