#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Cross-Platform Coverage Dashboard Generator Purpose: Generate HTML dashboard with matplotlib charts visualizing 30-day coverage trends for each platform (backend, frontend, mobile, desktop) and overall coverage. Creates self-contained HTML with embedded base64 images. Usage: python generate_cross_platform_dashboard.py [options] Options: --trending-file PATH Path to cross_platform_trend.json (default: relative path) --output PATH Output HTML file path (default: coverage_trend_30d.html) --days INT Number of days to include in chart (default: 30) --width INT Chart width in pixels (default: 1200) --height INT Chart height in pixels (default: 600) Example: python generate_cross_platform_dashboard.py --days 30 --output coverage_dashboard.html """ import argparse import base64 import io import json import sys from datetime import datetime from pathlib import Path from typing import Dict, List, Optional # Matplotlib imports import matplotlib matplotlib.use('Agg') # Use non-interactive backend for CI/CD import matplotlib.dates as mdates import matplotlib.pyplot as plt # Configure logging from logging import basicConfig, getLogger, INFO basicConfig( level=INFO, format='%(levelname)s: %(message)s' ) logger = getLogger(__name__) # Default paths TREND_FILE = Path("tests/coverage_reports/metrics/cross_platform_trend.json") OUTPUT_DIR = Path("tests/coverage_reports/dashboards") # Chart colors for platforms CHART_COLORS = { "backend": "#3B82F6", # Blue "frontend": "#10B981", # Green "mobile": "#F59E0B", # Orange "desktop": "#8B5CF6", # Purple "overall": "#111827" # Dark gray } # Default configuration DEFAULT_DAYS = 30 DEFAULT_WIDTH = 1200 DEFAULT_HEIGHT = 600 DPI = 100 def load_trending_data(trend_file: Path) -> Dict: """ Load trending data from cross_platform_trend.json. Reuses load_trending_data() from update_cross_platform_trending.py. Args: trend_file: Path to cross_platform_trend.json Returns: Dict with history list and latest entry """ # Import from update_cross_platform_trending.py try: # Add scripts directory to path script_dir = Path(__file__).parent sys.path.insert(0, str(script_dir)) from update_cross_platform_trending import load_trending_data as load_trend return load_trend(trend_file) except ImportError: # Fallback to simple implementation default_structure = { "history": [], "latest": {}, "platform_trends": {}, "computed_weights": { "backend": 0.35, "frontend": 0.40, "mobile": 0.15, "desktop": 0.10 } } if not trend_file.exists(): logger.warning(f"Trend file not found: {trend_file}, using empty structure") return default_structure try: with open(trend_file, 'r') as f: trending_data = json.load(f) # Validate structure required_keys = ["history", "latest", "platform_trends"] for key in required_keys: if key not in trending_data: logger.warning(f"Missing key '{key}', using default") trending_data[key] = default_structure[key] return trending_data except (json.JSONDecodeError, IOError) as e: logger.error(f"Error loading trending data: {e}") return default_structure def prepare_chart_data(trending_data: Dict, days: int = 30) -> Dict: """ Prepare chart data by filtering history to last N days. Args: trending_data: Trending data dict with history days: Number of days to include (default: 30) Returns: Dict with timestamps, platforms dict, and overall list """ history = trending_data.get("history", []) if not history: logger.warning("No history data available") return { "timestamps": [], "platforms": {}, "overall": [] } # Filter to last N entries (days parameter interpreted as entries for simplicity) filtered_history = history[-days:] if len(history) > days else history # Extract timestamps timestamps = [] for entry in filtered_history: try: # Parse timestamp ts = entry.get("timestamp", "") ts_clean = ts.replace("Z", "").replace("+00:00", "") dt = datetime.fromisoformat(ts_clean) timestamps.append(dt) except (ValueError, KeyError): # Use current time if parsing fails timestamps.append(datetime.now()) # Extract platform coverage values platforms_data = { "backend": [], "frontend": [], "mobile": [], "desktop": [] } overall_data = [] for entry in filtered_history: platforms = entry.get("platforms", {}) for platform in platforms_data.keys(): platforms_data[platform].append(platforms.get(platform, 0.0)) overall_data.append(entry.get("overall_coverage", 0.0)) return { "timestamps": timestamps, "platforms": platforms_data, "overall": overall_data } def create_line_chart( data: Dict, title: str = "Coverage Trend (30 Days)", width: int = DEFAULT_WIDTH, height: int = DEFAULT_HEIGHT ) -> bytes: """ Create line chart with all platforms and overall coverage. Args: data: Chart data dict with timestamps, platforms, overall title: Chart title width: Chart width in pixels height: Chart height in pixels Returns: Base64-encoded PNG image bytes """ timestamps = data.get("timestamps", []) platforms = data.get("platforms", {}) overall = data.get("overall", []) if not timestamps: logger.warning("No data available for chart") return b"" # Create figure figsize = (width / DPI, height / DPI) fig, ax = plt.subplots(figsize=figsize, dpi=DPI) # Plot overall coverage (thick, dark line) if overall: ax.plot(timestamps, overall, color=CHART_COLORS["overall"], linewidth=3, label="Overall", alpha=0.8) # Plot each platform (thinner, colored lines) for platform_name, coverage_values in platforms.items(): if coverage_values: ax.plot(timestamps, coverage_values, color=CHART_COLORS.get(platform_name, "#000000"), linewidth=1.5, label=platform_name.capitalize(), alpha=0.7) # Add legend ax.legend(loc='best', framealpha=0.9) # Add grid ax.grid(True, linestyle='--', alpha=0.7) # Format x-axis with dates ax.xaxis.set_major_formatter(mdates.DateFormatter('%m-%d')) ax.xaxis.set_major_locator(mdates.DayLocator(interval=max(1, len(timestamps) // 10))) plt.xticks(rotation=45, ha='right') # Set y-axis range and format ax.set_ylim(0, 100) ax.set_ylabel('Coverage %', fontsize=11) ax.set_xlabel('Date', fontsize=11) # Set title ax.set_title(title, fontsize=14, fontweight='bold', pad=20) # Use tight layout to prevent clipping plt.tight_layout() # Save to BytesIO buffer buf = io.BytesIO() fig.savefig(buf, format='png', dpi=DPI, bbox_inches='tight') buf.seek(0) # Get base64 encoded bytes image_bytes = buf.getvalue() base64_bytes = base64.b64encode(image_bytes) # Close figure to prevent memory leak plt.close(fig) return base64_bytes def create_platform_charts(data: Dict) -> Dict[str, bytes]: """ Create individual platform charts in 2x2 grid. Args: data: Chart data dict with timestamps, platforms, overall Returns: Dict mapping platform name to base64 image bytes """ timestamps = data.get("timestamps", []) platforms = data.get("platforms", {}) if not timestamps: logger.warning("No data available for platform charts") return {} # Create 2x2 subplot figure fig, axes = plt.subplots(2, 2, figsize=(14, 10), dpi=DPI) axes = axes.flatten() for idx, (platform_name, coverage_values) in enumerate(platforms.items()): if idx >= len(axes): break ax = axes[idx] if not coverage_values: continue # Plot platform coverage ax.plot(timestamps, coverage_values, color=CHART_COLORS.get(platform_name, "#000000"), linewidth=2, label=platform_name.capitalize(), marker='o', markersize=4, alpha=0.8) # Add threshold line (70% as example) threshold = 70.0 ax.axhline(y=threshold, color='red', linestyle='--', linewidth=1, alpha=0.5, label=f'Threshold ({threshold}%)') # Color code: above threshold green, below red if coverage_values and coverage_values[-1] >= threshold: title_color = 'green' else: title_color = 'red' # Format subplot ax.set_title(f"{platform_name.capitalize()} Coverage", fontsize=12, fontweight='bold', color=title_color) ax.set_ylim(0, 100) ax.set_ylabel('Coverage %', fontsize=10) ax.grid(True, linestyle='--', alpha=0.5) ax.legend(loc='best', fontsize=9) # Format x-axis ax.xaxis.set_major_formatter(mdates.DateFormatter('%m-%d')) ax.xaxis.set_major_locator(mdates.DayLocator(interval=max(1, len(timestamps) // 5))) plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right', fontsize=8) # Adjust layout plt.tight_layout() # Save to BytesIO buffer buf = io.BytesIO() fig.savefig(buf, format='png', dpi=DPI, bbox_inches='tight') buf.seek(0) # Get base64 encoded bytes image_bytes = buf.getvalue() base64_bytes = base64.b64encode(image_bytes) # Close figure to prevent memory leak plt.close(fig) return {"platforms": base64_bytes} def calculate_statistics(data: Dict) -> Dict: """ Calculate summary statistics for each platform. Args: data: Chart data dict with timestamps, platforms, overall Returns: Dict with statistics for each platform and overall """ platforms = data.get("platforms", {}) overall = data.get("overall", []) stats = {} # Calculate overall statistics if overall: stats["overall"] = { "current": round(overall[-1], 2) if overall else 0.0, "min": round(min(overall), 2) if overall else 0.0, "max": round(max(overall), 2) if overall else 0.0, "avg": round(sum(overall) / len(overall), 2) if overall else 0.0, "trend": _calculate_trend(overall) } # Calculate platform statistics for platform_name, coverage_values in platforms.items(): if coverage_values: stats[platform_name] = { "current": round(coverage_values[-1], 2), "min": round(min(coverage_values), 2), "max": round(max(coverage_values), 2), "avg": round(sum(coverage_values) / len(coverage_values), 2), "trend": _calculate_trend(coverage_values) } return stats def _calculate_trend(values: List[float]) -> str: """ Calculate trend direction (up/down/stable). Args: values: List of coverage values Returns: "up", "down", or "stable" """ if len(values) < 2: return "stable" first = values[0] last = values[-1] delta = last - first if delta > 1.0: return "up" elif delta < -1.0: return "down" else: return "stable" def _get_trend_indicator(trend: str) -> str: """Get trend indicator symbol.""" if trend == "up": return "↑" elif trend == "down": return "↓" else: return "→" def generate_html_template( chart_base64: str, platform_charts: Dict[str, str], data: Dict, statistics: Dict ) -> str: """ Generate self-contained HTML dashboard. Args: chart_base64: Base64-encoded main chart image platform_charts: Dict with platform chart base64 images data: Chart data dict statistics: Statistics dict Returns: Complete HTML string """ # Convert base64 bytes to string main_chart_src = f"data:image/png;base64,{chart_base64.decode('utf-8')}" if isinstance(chart_base64, bytes) else chart_base64 platforms_chart_src = "" if "platforms" in platform_charts: platforms_chart_bytes = platform_charts["platforms"] if isinstance(platforms_chart_bytes, bytes): platforms_chart_src = f"data:image/png;base64,{platforms_chart_bytes.decode('utf-8')}" else: platforms_chart_src = platforms_chart_bytes # Get generation time generation_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC") # Generate statistics table rows stats_rows = "" for name, stats_data in statistics.items(): trend_indicator = _get_trend_indicator(stats_data.get("trend", "stable")) trend_color = "green" if stats_data.get("trend") == "up" else "red" if stats_data.get("trend") == "down" else "gray" stats_rows += f""" {name.capitalize()} {stats_data.get('current', 0):.2f}% {stats_data.get('min', 0):.2f}% {stats_data.get('max', 0):.2f}% {stats_data.get('avg', 0):.2f}% {trend_indicator} """ html_template = f""" Coverage Trend Dashboard (30 Days)

Cross-Platform Coverage Trend Dashboard

Last 30 days of coverage metrics across all platforms

Overall Coverage Trend

Overall Coverage Trend Chart

Platform-Specific Trends

Platform-Specific Coverage Charts

Summary Statistics

{stats_rows}
Platform Current (%) Min (%) Max (%) Average (%) Trend

Trend Indicators

  • ↑ Improved (>1% increase from start of period)
  • ↓ Regressed (>1% decrease from start of period)
  • → Stable (within ±1% from start of period)
""" return html_template def main(): """Main execution function.""" parser = argparse.ArgumentParser( description="Generate HTML cross-platform coverage dashboard with matplotlib charts" ) parser.add_argument( "--trending-file", type=Path, default=TREND_FILE, help="Path to cross_platform_trend.json" ) parser.add_argument( "--output", type=Path, default=OUTPUT_DIR / "coverage_trend_30d.html", help="Output HTML file path" ) parser.add_argument( "--days", type=int, default=DEFAULT_DAYS, help=f"Number of days to include in chart (default: {DEFAULT_DAYS})" ) parser.add_argument( "--width", type=int, default=DEFAULT_WIDTH, help=f"Chart width in pixels (default: {DEFAULT_WIDTH})" ) parser.add_argument( "--height", type=int, default=DEFAULT_HEIGHT, help=f"Chart height in pixels (default: {DEFAULT_HEIGHT})" ) args = parser.parse_args() # Load trend data logger.info(f"Loading trend data from: {args.trending_file}") trending_data = load_trending_data(args.trending_file) # Prepare chart data logger.info(f"Preparing chart data (last {args.days} entries)") chart_data = prepare_chart_data(trending_data, days=args.days) if not chart_data.get("timestamps"): logger.error("No data available for chart generation") sys.exit(1) # Generate main chart logger.info("Generating main coverage trend chart") main_chart_base64 = create_line_chart( chart_data, title=f"Coverage Trend (Last {len(chart_data['timestamps'])} Entries)", width=args.width, height=args.height ) # Generate platform charts logger.info("Generating platform-specific charts") platform_charts = create_platform_charts(chart_data) # Calculate statistics logger.info("Calculating summary statistics") statistics = calculate_statistics(chart_data) # Generate HTML logger.info("Generating HTML dashboard") html_content = generate_html_template( main_chart_base64, platform_charts, chart_data, statistics ) # Write output file output_path = args.output output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, 'w') as f: f.write(html_content) logger.info(f"Dashboard generated: {output_path}") logger.info(f" File size: {output_path.stat().st_size / 1024:.2f} KB") logger.info(f" Data points: {len(chart_data['timestamps'])}") logger.info(f" Platforms: {', '.join(chart_data['platforms'].keys())}") return 0 if __name__ == "__main__": sys.exit(main())