public inbox for kdevops@lists.linux.dev
 help / color / mirror / Atom feed
From: Luis Chamberlain <mcgrof@kernel.org>
To: Chuck Lever <cel@kernel.org>, Daniel Gomez <da.gomez@kruces.com>,
	kdevops@lists.linux.dev
Cc: Luis Chamberlain <mcgrof@kernel.org>
Subject: [PATCH 13/13] pynfs: add visualization support for test results
Date: Mon, 22 Sep 2025 02:36:55 -0700	[thread overview]
Message-ID: <20250922093656.2361016-14-mcgrof@kernel.org> (raw)
In-Reply-To: <20250922093656.2361016-1-mcgrof@kernel.org>

Add 'make pynfs-visualize' target to generate comprehensive HTML reports
with PNG charts for pynfs test results. This makes it easy to understand
test outcomes at a glance and share results.

Features:
- HTML report with test summaries, statistics, and detailed results
- PNG charts showing test distribution (pie and bar charts)
- Comparison charts when multiple NFS versions are tested
- Automatic kernel version detection from results directory
- Self-contained output directory for easy transfer via scp

The visualization script generates:
- index.html: Main report with interactive tabs
- pynfs-v4_0-results.png: NFS v4.0 test charts
- pynfs-v4_1-results.png: NFS v4.1 test charts
- pynfs-vblock-results.png: pNFS block layout charts
- pynfs-comparison.png: Side-by-side version comparison

Usage:
  make pynfs-visualize                    # Auto-detect kernel
  make pynfs-visualize LAST_KERNEL=<version>  # Specific kernel

Output is generated in:
  workflows/pynfs/results/<kernel>/html/

Generated-by: Claude AI
Signed-off-by: Luis Chamberlain <mcgrof@kernel.org>
---
 scripts/workflows/pynfs/visualize_results.py | 1014 ++++++++++++++++++
 workflows/pynfs/Makefile                     |   17 +-
 2 files changed, 1030 insertions(+), 1 deletion(-)
 create mode 100755 scripts/workflows/pynfs/visualize_results.py

diff --git a/scripts/workflows/pynfs/visualize_results.py b/scripts/workflows/pynfs/visualize_results.py
new file mode 100755
index 00000000..15b0089b
--- /dev/null
+++ b/scripts/workflows/pynfs/visualize_results.py
@@ -0,0 +1,1014 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+"""
+Generate HTML visualization report for pynfs test results with charts and summaries.
+Creates both an HTML report and PNG chart files.
+"""
+
+import json
+import os
+import sys
+import argparse
+from pathlib import Path
+from datetime import datetime
+import re
+
+# Try to import matplotlib for PNG generation
+try:
+    import matplotlib
+
+    matplotlib.use("Agg")  # Use non-interactive backend
+    import matplotlib.pyplot as plt
+    import matplotlib.patches as mpatches
+
+    MATPLOTLIB_AVAILABLE = True
+except ImportError:
+    MATPLOTLIB_AVAILABLE = False
+    print("Warning: matplotlib not available, PNG charts will not be generated")
+    print("Install with: pip3 install matplotlib")
+
+
+def load_json_results(filepath):
+    """Load and parse a JSON result file."""
+    try:
+        with open(filepath, "r") as f:
+            return json.load(f)
+    except Exception as e:
+        print(f"Error loading {filepath}: {e}")
+        return None
+
+
+def categorize_tests(testcases):
+    """Categorize tests by their class/module."""
+    categories = {}
+    for test in testcases:
+        classname = test.get("classname", "unknown")
+        if classname not in categories:
+            categories[classname] = {
+                "passed": [],
+                "failed": [],
+                "skipped": [],
+                "error": [],
+            }
+
+        if test.get("skipped"):
+            categories[classname]["skipped"].append(test)
+        elif test.get("failure"):
+            categories[classname]["failed"].append(test)
+        elif test.get("error"):
+            categories[classname]["error"].append(test)
+        else:
+            categories[classname]["passed"].append(test)
+
+    return categories
+
+
+def generate_png_charts(charts, output_dir):
+    """Generate PNG charts using matplotlib."""
+    if not MATPLOTLIB_AVAILABLE:
+        return []
+
+    png_files = []
+
+    # Set up the style
+    plt.style.use("seaborn-v0_8-darkgrid")
+
+    for chart in charts:
+        version = chart["version"]
+
+        # Create figure with subplots
+        fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
+        fig.suptitle(
+            f'PyNFS {version.upper()} Test Results - Kernel {chart.get("kernel", "Unknown")}',
+            fontsize=16,
+            fontweight="bold",
+        )
+
+        # Pie chart
+        sizes = [chart["passed"], chart["failed"], chart["errors"], chart["skipped"]]
+        labels = ["Passed", "Failed", "Errors", "Skipped"]
+        colors = ["#48bb78", "#f56565", "#ed8936", "#a0aec0"]
+        explode = (0.05, 0.1, 0.1, 0)  # Explode failed and error slices
+
+        # Only show non-zero values
+        non_zero_sizes = []
+        non_zero_labels = []
+        non_zero_colors = []
+        non_zero_explode = []
+        for i, size in enumerate(sizes):
+            if size > 0:
+                non_zero_sizes.append(size)
+                non_zero_labels.append(f"{labels[i]}: {size}")
+                non_zero_colors.append(colors[i])
+                non_zero_explode.append(explode[i])
+
+        ax1.pie(
+            non_zero_sizes,
+            explode=non_zero_explode,
+            labels=non_zero_labels,
+            colors=non_zero_colors,
+            autopct="%1.1f%%",
+            startangle=90,
+            shadow=True,
+        )
+        ax1.set_title("Test Distribution")
+
+        # Bar chart
+        ax2.bar(labels, sizes, color=colors, edgecolor="black", linewidth=1.5)
+        ax2.set_ylabel("Number of Tests", fontweight="bold")
+        ax2.set_title("Test Counts")
+        ax2.grid(axis="y", alpha=0.3)
+
+        # Add text annotations on bars
+        for i, (label, value) in enumerate(zip(labels, sizes)):
+            ax2.text(
+                i,
+                value + max(sizes) * 0.01,
+                str(value),
+                ha="center",
+                va="bottom",
+                fontweight="bold",
+            )
+
+        # Add summary statistics
+        total = chart["total"]
+        pass_rate = chart["pass_rate"]
+        fig.text(
+            0.5,
+            0.02,
+            f"Total Tests: {total} | Pass Rate: {pass_rate}%",
+            ha="center",
+            fontsize=12,
+            fontweight="bold",
+            bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5),
+        )
+
+        plt.tight_layout()
+
+        # Save the figure
+        png_filename = f'pynfs-{version.replace(".", "_")}-results.png'
+        png_path = output_dir / png_filename
+        plt.savefig(png_path, dpi=150, bbox_inches="tight")
+        plt.close()
+
+        png_files.append(png_filename)
+        print(f"  Generated: {png_path}")
+
+    # Generate a summary chart comparing all versions
+    if len(charts) > 1:
+        fig, axes = plt.subplots(2, 2, figsize=(14, 10))
+        fig.suptitle("PyNFS Test Results Comparison", fontsize=18, fontweight="bold")
+
+        # Prepare data
+        versions = [c["version"].upper() for c in charts]
+        passed = [c["passed"] for c in charts]
+        failed = [c["failed"] for c in charts]
+        errors = [c["errors"] for c in charts]
+        skipped = [c["skipped"] for c in charts]
+        pass_rates = [c["pass_rate"] for c in charts]
+
+        x = range(len(versions))
+        width = 0.2
+
+        # Grouped bar chart
+        ax = axes[0, 0]
+        ax.bar(
+            [i - width * 1.5 for i in x], passed, width, label="Passed", color="#48bb78"
+        )
+        ax.bar(
+            [i - width * 0.5 for i in x], failed, width, label="Failed", color="#f56565"
+        )
+        ax.bar(
+            [i + width * 0.5 for i in x], errors, width, label="Errors", color="#ed8936"
+        )
+        ax.bar(
+            [i + width * 1.5 for i in x],
+            skipped,
+            width,
+            label="Skipped",
+            color="#a0aec0",
+        )
+        ax.set_xlabel("Version")
+        ax.set_ylabel("Number of Tests")
+        ax.set_title("Test Results by Version")
+        ax.set_xticks(x)
+        ax.set_xticklabels(versions)
+        ax.legend()
+        ax.grid(axis="y", alpha=0.3)
+
+        # Pass rate comparison
+        ax = axes[0, 1]
+        bars = ax.bar(
+            versions,
+            pass_rates,
+            color=[
+                "#48bb78" if p >= 90 else "#ed8936" if p >= 70 else "#f56565"
+                for p in pass_rates
+            ],
+        )
+        ax.set_ylabel("Pass Rate (%)")
+        ax.set_title("Pass Rate Comparison")
+        ax.set_ylim(0, 105)
+        ax.grid(axis="y", alpha=0.3)
+
+        # Add value labels on bars
+        for bar, rate in zip(bars, pass_rates):
+            height = bar.get_height()
+            ax.text(
+                bar.get_x() + bar.get_width() / 2.0,
+                height + 1,
+                f"{rate:.1f}%",
+                ha="center",
+                va="bottom",
+                fontweight="bold",
+            )
+
+        # Stacked bar chart
+        ax = axes[1, 0]
+        ax.bar(versions, passed, label="Passed", color="#48bb78")
+        ax.bar(versions, failed, bottom=passed, label="Failed", color="#f56565")
+        ax.bar(
+            versions,
+            errors,
+            bottom=[p + f for p, f in zip(passed, failed)],
+            label="Errors",
+            color="#ed8936",
+        )
+        ax.bar(
+            versions,
+            skipped,
+            bottom=[p + f + e for p, f, e in zip(passed, failed, errors)],
+            label="Skipped",
+            color="#a0aec0",
+        )
+        ax.set_ylabel("Number of Tests")
+        ax.set_title("Stacked Test Results")
+        ax.legend()
+        ax.grid(axis="y", alpha=0.3)
+
+        # Summary table
+        ax = axes[1, 1]
+        ax.axis("tight")
+        ax.axis("off")
+
+        table_data = [
+            ["Version", "Total", "Passed", "Failed", "Errors", "Skipped", "Pass Rate"]
+        ]
+        for c in charts:
+            table_data.append(
+                [
+                    c["version"].upper(),
+                    str(c["total"]),
+                    str(c["passed"]),
+                    str(c["failed"]),
+                    str(c["errors"]),
+                    str(c["skipped"]),
+                    f"{c['pass_rate']}%",
+                ]
+            )
+
+        table = ax.table(cellText=table_data, loc="center", cellLoc="center")
+        table.auto_set_font_size(False)
+        table.set_fontsize(10)
+        table.scale(1.2, 1.5)
+
+        # Style the header row
+        for i in range(7):
+            table[(0, i)].set_facecolor("#4a5568")
+            table[(0, i)].set_text_props(weight="bold", color="white")
+
+        # Color code the cells
+        for i in range(1, len(table_data)):
+            # Pass rate column
+            pass_rate = float(table_data[i][6].strip("%"))
+            if pass_rate >= 90:
+                table[(i, 6)].set_facecolor("#c6f6d5")
+            elif pass_rate >= 70:
+                table[(i, 6)].set_facecolor("#feebc8")
+            else:
+                table[(i, 6)].set_facecolor("#fed7d7")
+
+        plt.tight_layout()
+
+        # Save comparison chart
+        comparison_path = output_dir / "pynfs-comparison.png"
+        plt.savefig(comparison_path, dpi=150, bbox_inches="tight")
+        plt.close()
+
+        png_files.append("pynfs-comparison.png")
+        print(f"  Generated: {comparison_path}")
+
+    return png_files
+
+
+def generate_chart_data(results, kernel_version):
+    """Generate data for charts."""
+    charts = []
+    for version, data in results.items():
+        if not data:
+            continue
+
+        total = data.get("tests", 0)
+        passed = (
+            total
+            - data.get("failures", 0)
+            - data.get("errors", 0)
+            - data.get("skipped", 0)
+        )
+        failed = data.get("failures", 0)
+        errors = data.get("errors", 0)
+        skipped = data.get("skipped", 0)
+
+        charts.append(
+            {
+                "version": version,
+                "kernel": kernel_version,
+                "total": total,
+                "passed": passed,
+                "failed": failed,
+                "errors": errors,
+                "skipped": skipped,
+                "pass_rate": round((passed / total * 100) if total > 0 else 0, 2),
+            }
+        )
+
+    return charts
+
+
+def generate_html_report(results_dir, kernel_version):
+    """Generate the main HTML report with embedded charts and links to PNG files."""
+    results = {}
+
+    # Load all JSON files for this kernel version
+    for json_file in Path(results_dir).glob(f"{kernel_version}*.json"):
+        # Extract version from filename (e.g., v4.0, v4.1, vblock)
+        match = re.search(r"-v(4\.[01]|block)\.json$", str(json_file))
+        if match:
+            version = "v" + match.group(1)
+            results[version] = load_json_results(json_file)
+
+    if not results:
+        print(f"No results found for kernel {kernel_version}")
+        return None, []
+
+    # Generate chart data
+    charts = generate_chart_data(results, kernel_version)
+
+    # Create output directory for HTML and PNGs
+    output_dir = Path(results_dir) / "html"
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    # Generate PNG charts
+    png_files = generate_png_charts(charts, output_dir)
+
+    # Generate detailed test results
+    detailed_results = {}
+    for version, data in results.items():
+        if data and "testcase" in data:
+            detailed_results[version] = categorize_tests(data["testcase"])
+
+    # Create HTML content
+    html_content = f"""<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>PyNFS Test Results - {kernel_version}</title>
+    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+    <style>
+        * {{
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }}
+
+        body {{
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            min-height: 100vh;
+            padding: 20px;
+        }}
+
+        .container {{
+            max-width: 1400px;
+            margin: 0 auto;
+        }}
+
+        .header {{
+            background: white;
+            border-radius: 15px;
+            padding: 30px;
+            margin-bottom: 30px;
+            box-shadow: 0 10px 30px rgba(0,0,0,0.1);
+        }}
+
+        h1 {{
+            color: #2d3748;
+            font-size: 2.5em;
+            margin-bottom: 10px;
+        }}
+
+        .subtitle {{
+            color: #718096;
+            font-size: 1.1em;
+        }}
+
+        .png-links {{
+            margin-top: 20px;
+            padding: 15px;
+            background: #f7fafc;
+            border-radius: 8px;
+        }}
+
+        .png-links h3 {{
+            color: #2d3748;
+            margin-bottom: 10px;
+        }}
+
+        .png-links a {{
+            color: #667eea;
+            text-decoration: none;
+            margin-right: 15px;
+            font-weight: 500;
+        }}
+
+        .png-links a:hover {{
+            text-decoration: underline;
+        }}
+
+        .summary-grid {{
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+            gap: 20px;
+            margin-bottom: 30px;
+        }}
+
+        .summary-card {{
+            background: white;
+            border-radius: 15px;
+            padding: 25px;
+            box-shadow: 0 10px 30px rgba(0,0,0,0.1);
+            transition: transform 0.3s ease;
+        }}
+
+        .summary-card:hover {{
+            transform: translateY(-5px);
+        }}
+
+        .card-title {{
+            font-size: 1.3em;
+            color: #4a5568;
+            margin-bottom: 20px;
+            font-weight: 600;
+        }}
+
+        .stats-grid {{
+            display: grid;
+            grid-template-columns: repeat(2, 1fr);
+            gap: 15px;
+        }}
+
+        .stat-item {{
+            padding: 10px;
+            background: #f7fafc;
+            border-radius: 8px;
+        }}
+
+        .stat-label {{
+            color: #718096;
+            font-size: 0.9em;
+            margin-bottom: 5px;
+        }}
+
+        .stat-value {{
+            font-size: 1.5em;
+            font-weight: bold;
+        }}
+
+        .stat-value.passed {{
+            color: #48bb78;
+        }}
+
+        .stat-value.failed {{
+            color: #f56565;
+        }}
+
+        .stat-value.error {{
+            color: #ed8936;
+        }}
+
+        .stat-value.skipped {{
+            color: #a0aec0;
+        }}
+
+        .chart-container {{
+            position: relative;
+            height: 300px;
+            margin-top: 20px;
+        }}
+
+        .png-preview {{
+            margin-top: 20px;
+            text-align: center;
+        }}
+
+        .png-preview img {{
+            max-width: 100%;
+            border-radius: 8px;
+            box-shadow: 0 4px 6px rgba(0,0,0,0.1);
+        }}
+
+        .details-section {{
+            background: white;
+            border-radius: 15px;
+            padding: 30px;
+            margin-bottom: 30px;
+            box-shadow: 0 10px 30px rgba(0,0,0,0.1);
+        }}
+
+        .test-category {{
+            margin-bottom: 25px;
+            padding: 20px;
+            background: #f8f9fa;
+            border-radius: 10px;
+        }}
+
+        .category-header {{
+            font-size: 1.2em;
+            color: #2d3748;
+            margin-bottom: 15px;
+            font-weight: 600;
+            border-bottom: 2px solid #e2e8f0;
+            padding-bottom: 10px;
+        }}
+
+        .test-list {{
+            display: grid;
+            gap: 10px;
+        }}
+
+        .test-item {{
+            padding: 12px;
+            border-radius: 6px;
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            transition: all 0.2s ease;
+        }}
+
+        .test-item:hover {{
+            transform: translateX(5px);
+        }}
+
+        .test-item.passed {{
+            background: #c6f6d5;
+            border-left: 4px solid #48bb78;
+        }}
+
+        .test-item.failed {{
+            background: #fed7d7;
+            border-left: 4px solid #f56565;
+        }}
+
+        .test-item.skipped {{
+            background: #e2e8f0;
+            border-left: 4px solid #a0aec0;
+        }}
+
+        .test-item.error {{
+            background: #feebc8;
+            border-left: 4px solid #ed8936;
+        }}
+
+        .test-name {{
+            font-family: 'Courier New', monospace;
+            font-size: 0.95em;
+        }}
+
+        .test-code {{
+            background: rgba(0,0,0,0.1);
+            padding: 2px 8px;
+            border-radius: 4px;
+            font-size: 0.85em;
+            font-family: monospace;
+        }}
+
+        .tabs {{
+            display: flex;
+            gap: 10px;
+            margin-bottom: 20px;
+            border-bottom: 2px solid #e2e8f0;
+        }}
+
+        .tab-button {{
+            padding: 10px 20px;
+            background: none;
+            border: none;
+            color: #718096;
+            cursor: pointer;
+            font-size: 1em;
+            transition: all 0.3s ease;
+            position: relative;
+        }}
+
+        .tab-button:hover {{
+            color: #2d3748;
+        }}
+
+        .tab-button.active {{
+            color: #667eea;
+            font-weight: 600;
+        }}
+
+        .tab-button.active::after {{
+            content: '';
+            position: absolute;
+            bottom: -2px;
+            left: 0;
+            right: 0;
+            height: 2px;
+            background: #667eea;
+        }}
+
+        .tab-content {{
+            display: none;
+        }}
+
+        .tab-content.active {{
+            display: block;
+        }}
+
+        .footer {{
+            text-align: center;
+            color: white;
+            margin-top: 40px;
+            font-size: 0.9em;
+        }}
+
+        .progress-bar {{
+            height: 20px;
+            background: #e2e8f0;
+            border-radius: 10px;
+            overflow: hidden;
+            margin: 15px 0;
+        }}
+
+        .progress-fill {{
+            height: 100%;
+            background: linear-gradient(90deg, #48bb78, #38a169);
+            transition: width 0.5s ease;
+        }}
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="header">
+            <h1>🧪 PyNFS Test Results</h1>
+            <div class="subtitle">Kernel Version: {kernel_version}</div>
+            <div class="subtitle">Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</div>
+"""
+
+    # Add PNG download links if any were generated
+    if png_files:
+        html_content += """
+            <div class="png-links">
+                <h3>📊 Download Charts:</h3>
+"""
+        for png_file in png_files:
+            html_content += (
+                f'                <a href="{png_file}" download>{png_file}</a>\n'
+            )
+        html_content += """            </div>
+"""
+
+    html_content += """
+        </div>
+
+        <div class="summary-grid">
+"""
+
+    # Add summary cards for each version
+    for chart in charts:
+        version = chart["version"]
+        png_file = f'pynfs-{version.replace(".", "_")}-results.png'
+
+        html_content += f"""
+            <div class="summary-card">
+                <div class="card-title">NFS {version.upper()} Results</div>
+                <div class="stats-grid">
+                    <div class="stat-item">
+                        <div class="stat-label">Total Tests</div>
+                        <div class="stat-value">{chart['total']}</div>
+                    </div>
+                    <div class="stat-item">
+                        <div class="stat-label">Pass Rate</div>
+                        <div class="stat-value passed">{chart['pass_rate']}%</div>
+                    </div>
+                    <div class="stat-item">
+                        <div class="stat-label">Passed</div>
+                        <div class="stat-value passed">{chart['passed']}</div>
+                    </div>
+                    <div class="stat-item">
+                        <div class="stat-label">Failed</div>
+                        <div class="stat-value failed">{chart['failed']}</div>
+                    </div>
+                    <div class="stat-item">
+                        <div class="stat-label">Errors</div>
+                        <div class="stat-value error">{chart['errors']}</div>
+                    </div>
+                    <div class="stat-item">
+                        <div class="stat-label">Skipped</div>
+                        <div class="stat-value skipped">{chart['skipped']}</div>
+                    </div>
+                </div>
+                <div class="progress-bar">
+                    <div class="progress-fill" style="width: {chart['pass_rate']}%"></div>
+                </div>
+"""
+
+        # Add PNG preview if available
+        if png_file in png_files:
+            html_content += f"""
+                <div class="png-preview">
+                    <a href="{png_file}" target="_blank">
+                        <img src="{png_file}" alt="{version.upper()} Results Chart">
+                    </a>
+                </div>
+"""
+        else:
+            # Fallback to JavaScript chart
+            html_content += f"""
+                <div class="chart-container">
+                    <canvas id="chart-{version.replace('.', '')}"></canvas>
+                </div>
+"""
+
+        html_content += """
+            </div>
+"""
+
+    html_content += """
+        </div>
+"""
+
+    # Add comparison chart preview if available
+    if "pynfs-comparison.png" in png_files:
+        html_content += """
+        <div class="details-section">
+            <h2 style="margin-bottom: 20px; color: #2d3748;">Test Results Comparison</h2>
+            <div class="png-preview">
+                <a href="pynfs-comparison.png" target="_blank">
+                    <img src="pynfs-comparison.png" alt="PyNFS Comparison Chart">
+                </a>
+            </div>
+        </div>
+"""
+
+    html_content += """
+        <div class="details-section">
+            <h2 style="margin-bottom: 20px; color: #2d3748;">Detailed Test Results</h2>
+            <div class="tabs">
+"""
+
+    # Add tabs for each version
+    first = True
+    for version in detailed_results.keys():
+        active = "active" if first else ""
+        html_content += f"""
+                <button class="tab-button {active}" onclick="showTab('{version}')">{version.upper()}</button>
+"""
+        first = False
+
+    html_content += """
+            </div>
+"""
+
+    # Add tab content for each version
+    first = True
+    for version, categories in detailed_results.items():
+        active = "active" if first else ""
+        html_content += f"""
+            <div id="tab-{version}" class="tab-content {active}">
+"""
+
+        # Sort categories by name
+        for category_name in sorted(categories.keys()):
+            category = categories[category_name]
+            total_in_category = (
+                len(category["passed"])
+                + len(category["failed"])
+                + len(category["skipped"])
+                + len(category["error"])
+            )
+
+            if total_in_category == 0:
+                continue
+
+            html_content += f"""
+                <div class="test-category">
+                    <div class="category-header">
+                        {category_name} ({total_in_category} tests)
+                    </div>
+                    <div class="test-list">
+"""
+
+            # Add passed tests
+            for test in sorted(category["passed"], key=lambda x: x.get("name", "")):
+                html_content += f"""
+                        <div class="test-item passed">
+                            <span class="test-name">{test.get('name', 'Unknown')}</span>
+                            <span class="test-code">{test.get('code', '')}</span>
+                        </div>
+"""
+
+            # Add failed tests
+            for test in sorted(category["failed"], key=lambda x: x.get("name", "")):
+                html_content += f"""
+                        <div class="test-item failed">
+                            <span class="test-name">{test.get('name', 'Unknown')}</span>
+                            <span class="test-code">{test.get('code', '')}</span>
+                        </div>
+"""
+
+            # Add error tests
+            for test in sorted(category["error"], key=lambda x: x.get("name", "")):
+                html_content += f"""
+                        <div class="test-item error">
+                            <span class="test-name">{test.get('name', 'Unknown')}</span>
+                            <span class="test-code">{test.get('code', '')}</span>
+                        </div>
+"""
+
+            # Add skipped tests (collapsed by default)
+            if category["skipped"]:
+                html_content += f"""
+                        <details>
+                            <summary style="cursor: pointer; padding: 10px; background: #f0f0f0; border-radius: 5px; margin-top: 10px;">
+                                Skipped Tests ({len(category['skipped'])})
+                            </summary>
+                            <div style="margin-top: 10px;">
+"""
+                for test in sorted(
+                    category["skipped"], key=lambda x: x.get("name", "")
+                ):
+                    html_content += f"""
+                                <div class="test-item skipped">
+                                    <span class="test-name">{test.get('name', 'Unknown')}</span>
+                                    <span class="test-code">{test.get('code', '')}</span>
+                                </div>
+"""
+                html_content += """
+                            </div>
+                        </details>
+"""
+
+            html_content += """
+                    </div>
+                </div>
+"""
+
+        html_content += """
+            </div>
+"""
+        first = False
+
+    html_content += """
+        </div>
+
+        <div class="footer">
+            Generated by kdevops pynfs-visualize | 🤖 Generated with Claude Code
+        </div>
+    </div>
+
+    <script>
+        // Tab switching function
+        function showTab(version) {
+            // Hide all tabs
+            document.querySelectorAll('.tab-content').forEach(tab => {
+                tab.classList.remove('active');
+            });
+            document.querySelectorAll('.tab-button').forEach(button => {
+                button.classList.remove('active');
+            });
+
+            // Show selected tab
+            document.getElementById('tab-' + version).classList.add('active');
+            event.target.classList.add('active');
+        }
+"""
+
+    # Add fallback JavaScript charts if matplotlib is not available
+    if not MATPLOTLIB_AVAILABLE:
+        html_content += """
+        // Chart initialization (fallback when PNGs are not available)
+"""
+        for chart in charts:
+            version = chart["version"]
+            canvas_id = f"chart-{version.replace('.', '')}"
+
+            html_content += f"""
+        if (document.getElementById('{canvas_id}')) {{
+            new Chart(document.getElementById('{canvas_id}'), {{
+                type: 'doughnut',
+                data: {{
+                    labels: ['Passed', 'Failed', 'Errors', 'Skipped'],
+                    datasets: [{{
+                        data: [{chart['passed']}, {chart['failed']}, {chart['errors']}, {chart['skipped']}],
+                        backgroundColor: [
+                            '#48bb78',
+                            '#f56565',
+                            '#ed8936',
+                            '#a0aec0'
+                        ],
+                        borderWidth: 0
+                    }}]
+                }},
+                options: {{
+                    responsive: true,
+                    maintainAspectRatio: false,
+                    plugins: {{
+                        legend: {{
+                            position: 'bottom',
+                            labels: {{
+                                padding: 15,
+                                font: {{
+                                    size: 12
+                                }}
+                            }}
+                        }},
+                        tooltip: {{
+                            callbacks: {{
+                                label: function(context) {{
+                                    let label = context.label || '';
+                                    if (label) {{
+                                        label += ': ';
+                                    }}
+                                    label += context.parsed;
+                                    let total = context.dataset.data.reduce((a, b) => a + b, 0);
+                                    let percentage = ((context.parsed / total) * 100).toFixed(1);
+                                    label += ' (' + percentage + '%)';
+                                    return label;
+                                }}
+                            }}
+                        }}
+                    }}
+                }}
+            }});
+        }}
+"""
+
+    html_content += """
+    </script>
+</body>
+</html>
+"""
+
+    return html_content, png_files
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Generate HTML visualization for pynfs results"
+    )
+    parser.add_argument("results_dir", help="Path to results directory")
+    parser.add_argument("kernel_version", help="Kernel version string")
+    parser.add_argument("--output", "-o", help="Output HTML file path")
+
+    args = parser.parse_args()
+
+    # Generate the HTML report and PNG charts
+    html_content, png_files = generate_html_report(
+        args.results_dir, args.kernel_version
+    )
+
+    if not html_content:
+        sys.exit(1)
+
+    # Determine output path
+    if args.output:
+        output_path = Path(args.output)
+        output_path.parent.mkdir(parents=True, exist_ok=True)
+    else:
+        output_dir = Path(args.results_dir) / "html"
+        output_dir.mkdir(parents=True, exist_ok=True)
+        output_path = output_dir / "index.html"
+
+    # Write the HTML file
+    with open(output_path, "w") as f:
+        f.write(html_content)
+
+    print(f"✅ HTML report generated: {output_path}")
+
+    if png_files:
+        print(f"✅ Generated {len(png_files)} PNG charts in: {output_path.parent}")
+    elif MATPLOTLIB_AVAILABLE:
+        print("⚠️  No PNG charts generated (no data)")
+    else:
+        print("⚠️  PNG charts not generated (matplotlib not installed)")
+        print("   Install with: pip3 install matplotlib")
+
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/workflows/pynfs/Makefile b/workflows/pynfs/Makefile
index e0da0cf5..29b0feac 100644
--- a/workflows/pynfs/Makefile
+++ b/workflows/pynfs/Makefile
@@ -13,7 +13,7 @@ WORKFLOW_ARGS += $(PYNFS_ARGS)
 
 
 ifndef LAST_KERNEL
-LAST_KERNEL := $(shell cat workflows/pynfs/results/last-kernel.txt 2>/dev/null)
+LAST_KERNEL := $(shell cat workflows/pynfs/results/last-kernel.txt 2>/dev/null || ls -1dt workflows/pynfs/results/*/ 2>/dev/null | grep -v "last-run" | head -1 | xargs -r basename)
 endif
 
 ifeq ($(LAST_KERNEL), $(shell cat workflows/pynfs/results/last-kernel.txt 2>/dev/null))
@@ -76,10 +76,25 @@ pynfs-show-results:
 		| xargs $(XARGS_ARGS) \
 		| sed '$${/^$$/d;}'
 
+pynfs-visualize:
+	$(Q)if [ ! -d "workflows/pynfs/results/$(LAST_KERNEL)" ]; then \
+		echo "Error: No results found for kernel $(LAST_KERNEL)"; \
+		echo "Available kernels:"; \
+		ls -1 workflows/pynfs/results/ | grep -v last; \
+		exit 1; \
+	fi
+	$(Q)echo "Generating HTML visualization for kernel $(LAST_KERNEL)..."
+	$(Q)python3 scripts/workflows/pynfs/visualize_results.py \
+		workflows/pynfs/results/$(LAST_KERNEL) \
+		$(LAST_KERNEL) \
+		--output workflows/pynfs/results/$(LAST_KERNEL)/html/index.html
+	$(Q)echo "✅ Visualization complete: workflows/pynfs/results/$(LAST_KERNEL)/html/index.html"
+
 pynfs-help-menu:
 	@echo "pynfs options:"
 	@echo "pynfs                             - Git clone pynfs, build and install it"
 	@echo "pynfs-{baseline,dev}              - Run the pynfs test on baseline  or dev hosts and collect results"
+	@echo "pynfs-visualize                   - Generate HTML visualization of test results"
 	@echo ""
 
 HELP_TARGETS += pynfs-help-menu
-- 
2.51.0


  parent reply	other threads:[~2025-09-22  9:36 UTC|newest]

Thread overview: 15+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-09-22  9:36 [PATCH 00/13] nfs: few fixes and enhancements Luis Chamberlain
2025-09-22  9:36 ` [PATCH 01/13] defconfigs: add NFS testing configurations Luis Chamberlain
2025-09-22  9:36 ` [PATCH 02/13] devconfig: exclude nfsd from journal upload client configuration Luis Chamberlain
2025-09-22  9:36 ` [PATCH 03/13] iscsi: add missing initiator packages for Debian Luis Chamberlain
2025-09-22  9:36 ` [PATCH 04/13] fstests: fix pNFS block layout iSCSI setup Luis Chamberlain
2025-09-22  9:36 ` [PATCH 05/13] nfsd/fstests: fix pNFS block layout iSCSI configuration Luis Chamberlain
2025-09-22  9:36 ` [PATCH 06/13] fstests: set up iSCSI target on NFS server before test nodes Luis Chamberlain
2025-09-22  9:36 ` [PATCH 07/13] fstests: move conditional to play level for iSCSI setup Luis Chamberlain
2025-09-22  9:36 ` [PATCH 08/13] fstests: temporarily disable iSCSI setup for pNFS Luis Chamberlain
2025-09-22  9:36 ` [PATCH 09/13] nfsd_add_export: fix become method for filesystem formatting Luis Chamberlain
2025-09-22  9:36 ` [PATCH 10/13] workflows: fstests: fix incorrect pNFS export configuration Luis Chamberlain
2025-09-22  9:36 ` [PATCH 11/13] nfstest: add results visualization support Luis Chamberlain
2025-09-22  9:36 ` [PATCH 12/13] fstests: add soak duration to nfs template Luis Chamberlain
2025-09-22  9:36 ` Luis Chamberlain [this message]
2025-09-22  9:46 ` [PATCH 00/13] nfs: few fixes and enhancements Luis Chamberlain

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20250922093656.2361016-14-mcgrof@kernel.org \
    --to=mcgrof@kernel.org \
    --cc=cel@kernel.org \
    --cc=da.gomez@kruces.com \
    --cc=kdevops@lists.linux.dev \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox