#!/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)
Summary Statistics
| Platform |
Current (%) |
Min (%) |
Max (%) |
Average (%) |
Trend |
{stats_rows}
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())