github-action-benchmark icon indicating copy to clipboard operation
github-action-benchmark copied to clipboard

UI Rework and graph changed suggestion

Open nicolas-goyon opened this issue 4 months ago • 0 comments

Hello there,

Context

I've been working a bit on a project using the github-action-benchmark and i've done a couple of changes.

I've wanted to share them to you in case if you wanted a quick help with that. It's maybe not 100% functionable, I haven't tested it with other languages and results. The UI isn't perfect too, but it could be a boilerplate for improvements.

Key points

  1. TailwindCSS and Flowbyte to handle UI elements
  2. ApexCharts for charts
  3. I've added a config data object to handle benchmark range to defines upper/lower limits that will appear in the graphs

Showcase

Image

JS

Here is a loadChart.js that handle creating all charts.

/*
* Refactored benchmark chart renderer
* ----------------------------------
*
* What this file does
* - Extracts series from window.BENCHMARK_DATA
* - Builds a responsive card (title + chart container) per benchmark -> Expect Tailwind
* - Renders a line chart via ApexCharts with optional expected-value annotations
*
* Expected external globals
* - window.BENCHMARK_DATA: { entries: { Benchmark: Array<{ date: string, benches: Array<{ name: string, value: number }> }> } }
* - ApexCharts: provided by <script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
* - TailwindCSS : provided by <script src="https://cdn.tailwindcss.com"></script>
* - Flowbite : provided by <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/flowbite.min.js"></script> & <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/flowbite.min.css" rel="stylesheet" />
*/


/**
* Optional benchmark ranges used for annotations and y‑axis padding.
* You can tailor these per benchmark method without touching the code.
*/

BENCHMARK_RANGES = {
    SpeedBenchmarks: {
        Default_MeshToString:
        {
            mean_ms_min: 100.0,
            mean_ms_max: 250.0
        },
        Occluder: {
            mean_ms_max: 50.0,
        },
        Optimize: {
            mean_ms_max: 50.0,
        },
        Occluder_MeshToString: {
            mean_ms_max: 50.0,
        },
        Optimize_MeshToString: {
            mean_ms_max: 50.0,
        }
    }
}

/** Visual defaults */
const COLORS = {
    expectedBand: { border: "#000", fill: "#FEB019" },
    minMaxLine: "#00E396",
};




/** ------------------------------------
* Small utilities
* -------------------------------------*/


/**
* Return the config object for ApexCharts y‑axis annotations based on provided range.
* If both min and max are provided => shaded band. If only one => labeled line.
*/
function buildYAnnotations(min, max) {
    const ann = [];
    if (typeof min === "number" && typeof max === "number") {
        ann.push({
            y: min,
            y2: max,
            borderColor: COLORS.expectedBand.border,
            fillColor: COLORS.expectedBand.fill,
            opacity: 0.2,
            label: {
                borderColor: "#333",
                style: { fontSize: "10px", color: "#333", background: COLORS.expectedBand.fill },
                text: "Expected range",
            },
        });
    } else if (typeof min === "number") {
        ann.push({
            y: min,
            borderColor: COLORS.minMaxLine,
            label: {
                borderColor: COLORS.minMaxLine,
                style: { color: "#fff", background: COLORS.minMaxLine },
                text: "Min value",
            },
        });
    } else if (typeof max === "number") {
        ann.push({
            y: max,
            borderColor: COLORS.minMaxLine,
            borderWidth: 4,
            strokeDashArray: 10,
            label: {
                borderColor: COLORS.minMaxLine,
                style: { color: "#fff", background: COLORS.minMaxLine, fontSize: "12px" },
                text: "Max value",
            },
        });
    }
    return ann;
}



/**
* Compute y‑axis min/max with optional padding around raw values and explicit range.
* @param {number[]} values – data series values
* @param {{min?: number, max?: number}} explicit – explicit range (optional)
* @param {boolean} addMargin – whether to pad the axis around min/max
*/
function computeYAxisBounds(values, explicit = {}, addMargin = true) {
    const rawMin = Math.min(...values);
    const rawMax = Math.max(...values);


    let min = typeof explicit.min === "number" ? Math.min(explicit.min, rawMin) : rawMin;
    let max = typeof explicit.max === "number" ? Math.max(explicit.max, rawMax) : rawMax;


    if (addMargin) {
        const span = Math.max(1e-9, max - min); // avoid zero‑span axis
        const pad = span * 0.2; // 20% headroom/tailroom
        min = min - pad;
        max = max + pad;
    }


    return { min, max };
}



/**
* Lookup benchmark range by composite id "Suite.Method".
*/
function lookupRange(compositeId) {
    const [suite, ...rest] = String(compositeId).split(".");
    const method = rest.join(".");
    const entry = BENCHMARK_RANGES?.[suite]?.[method];
    if (!entry) return { min: undefined, max: undefined };
    return { min: entry.mean_ms_min, max: entry.mean_ms_max };
}



/** ------------------------------------
* DOM helpers
* -------------------------------------*/


/**
* Create a simple card wrapper with a title and an inner div that will host the chart.
* Returns the created chart container element.
*/
function createChartCard(parent, title, containerId) {
    const card = document.createElement("div");
    card.className = "w-full bg-white rounded-lg shadow-sm dark:bg-gray-800 p-4 md:p-6";


    const header = document.createElement("div");
    header.className = "flex justify-between";


    const titleEl = document.createElement("h2");
    titleEl.className = "text-2xl font-bold text-gray-900 dark:text-white pb-2";
    titleEl.textContent = title;


    header.appendChild(titleEl);
    card.appendChild(header);


    const container = document.createElement("div");
    container.id = containerId;
    card.appendChild(container);


    parent.appendChild(card);
    return container;
}




/** ------------------------------------
* Chart creation
* -------------------------------------*/


/**
* Render a line chart in the given containerId using ApexCharts.
*/
function renderLineChart({ containerId, values, categories, yRange, addMargin = true }) {
    if (!document.getElementById(containerId)) return;
    if (typeof ApexCharts === "undefined") return;


    const annotations = buildYAnnotations(yRange?.min, yRange?.max);
    const yBounds = computeYAxisBounds(values, yRange, addMargin);


    /** @type {import('apexcharts').ApexOptions} */
    const options = {
        series: [{ data: values }],
        chart: {
            height: "auto",
            type: "line",
            id: `chart-${containerId}`,
            zoom: { enabled: false },
            toolbar: { show: false },
        },
        annotations: { yaxis: annotations },
        dataLabels: { enabled: false },
        stroke: { curve: "straight" },
        labels: categories,
        xaxis: { type: "datetime" },
        yaxis: { decimalsInFloat: 0, min: yBounds.min, max: yBounds.max },
    };


    const chart = new ApexCharts(document.getElementById(containerId), options);
    chart.render();
}

/** ------------------------------------
* Data extraction
* -------------------------------------*/


/**
* Convert BENCHMARK_DATA into a map keyed by benchmark name with arrays of {values, dates}.
* BENCHMARK_DATA number values are assumed to be presented in nanoseconds and will be converted to milliseconds.
*/
function extractSeriesFromBenchmarkData(benchmarkData) {
    const out = {};
    const entries = benchmarkData?.entries?.Benchmark ?? [];


    for (const commit of entries) {
        const date = commit.date; // assumed ISO string
        for (const bench of commit.benches ?? []) {
            const id = bench.name;
            if (!out[id]) out[id] = { values: [], dates: [] };
            out[id].values.push(bench.value / 1_000_000); // ns -> ms
            out[id].dates.push(date);
        }
    }


    return out;
}


/** ------------------------------------
* Bootstrapping
* -------------------------------------*/


function initBenchmarkCharts() {
    try {
        const root = document.getElementById("BenchmarkResults");
        if (!root) return;
        if (!window.BENCHMARK_DATA) return;


        const seriesById = extractSeriesFromBenchmarkData(window.BENCHMARK_DATA);


        for (const [benchmarkId, series] of Object.entries(seriesById)) {
            // Build card frame and container
            createChartCard(root, benchmarkId, benchmarkId);


            // Lookup optional expected range to annotate & pad y‑axis
            const range = lookupRange(benchmarkId);


            // Render the chart
            renderLineChart({
                containerId: benchmarkId,
                values: series.values,
                categories: series.dates,
                yRange: range,
                addMargin: true,
            });
        }
    } catch (err) {
        // Non‑fatal: keep the page usable even if one chart fails
        console.error("Failed to initialize benchmark charts:", err);
    }
}


// Kick things off once the DOM is ready
if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initBenchmarkCharts);
} else {
    initBenchmarkCharts();
}

HTML

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Benchmarks</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/flowbite.min.css" rel="stylesheet" />
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/flowbite.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/apexcharts.min.js"></script>

  <script src="data.js"></script>
  <script src="loadChart.js"   ></script>


</head>

<body class="bg-white dark:bg-gray-900">

  <nav class="bg-white dark:bg-gray-900 fixed w-full z-20 top-0 start-0 border-b border-gray-200 dark:border-gray-600">
    <div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
      <a href="https://flowbite.com/" class="flex items-center space-x-3 rtl:space-x-reverse">
        <img src="https://flowbite.com/docs/images/logo.svg" class="h-8" alt="Flowbite Logo">
        <span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Flowbite</span>
      </a>
      <div class="flex md:order-2 space-x-3 md:space-x-0 rtl:space-x-reverse">
        <button type="button"
          class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
          Get started
        </button>
      </div>
      <div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-sticky">
        <ul
          class="flex flex-col p-4 md:p-0 mt-4 font-medium border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
          <li>
            <a href="#"
              class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500"
              aria-current="page">Home</a>
          </li>
          <li>
            <a href="#"
              class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:p-0 md:dark:hover:text-blue-500 dark:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700">About</a>
          </li>
          <li>
            <a href="#"
              class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:p-0 md:dark:hover:text-blue-500 dark:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700">Services</a>
          </li>
          <li>
            <a href="#"
              class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:p-0 md:dark:hover:text-blue-500 dark:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700">Contact</a>
          </li>
        </ul>
      </div>
    </div>
  </nav>


  <main class="mx-auto space-y-12 py-32 px-32">


    <section>
      <h2 class="text-5xl font-extrabold dark:text-white">Overview</h2>
      <p class="my-4 mb-3 text-gray-500 dark:text-gray-400">
        Overview of your project, feel free to fill this area.
      </p>
    </section>

    <div id="BenchmarkResults" class="grid grid-cols-1 md:grid-cols-3 gap-8">

    </div>

  </main>
  <footer class="text-center py-4 bg-gray-200">
    <p>Name of your project</p>
  </footer>
</body>

</html>

nicolas-goyon avatar Aug 21 '25 12:08 nicolas-goyon