Repository: conan-io/conan
Pull request: Add html output for graph build-order and graph build-order-merge
Changelog: Feature: Add html output for graph build-order and graph build-order-merge
Docs: https://github.com/conan-io/docs/pull/3805

Closes: https://github.com/conan-io/conan/issues/16356

![image](https://github.com/conan-io/conan/assets/5045666/e2a3c6f6-6784-4426-bdd1-7568e9b90370)




Problem statement:
[feature] html graph from existing build-order.json
### What is your suggestion?

The `conan graph info` command has a fantastic html output for graphs that can be used to look through a chosen graph.
The `conan graph build-order` command can be used to produce many such chunks of graphable data,
and the `conan graph build-order-merge` command does a great job combining several into a single json file,..

...But there does not seem to be any way to take a merged build-order json file, and feed it into the same nice html format for review. If there is one, I haven't found it yet. :)

I would love it if, either:

1. The `build-order` and/or `build-order-merge` commands, also supported html output (I would just output my json and then my html with the same command),
_or_
2. If the `conan graph info` command, could support taking in an input json file, and graphing that.

I don't have any preference, both versions would satisfy my needs just fine, and I'm happy to entertain other alternatives that I missed when evaluating this feature request.

If there was a chosen method from above, or another, I would not be totally against attempting to contribute such a method, though my familiarity with the conan codebase itself is low at the moment.

Thanks for considering!

### Have you read the CONTRIBUTING guide?

- [X] I've read the CONTRIBUTING guide

Hints from issue discussion:

Commit message: different node for build context

Patch to review:
diff --git a/conan/cli/commands/graph.py b/conan/cli/commands/graph.py
index 85d7cb510ec..8f412474438 100644
--- a/conan/cli/commands/graph.py
+++ b/conan/cli/commands/graph.py
@@ -8,6 +8,7 @@
 from conan.cli.command import conan_command, conan_subcommand
 from conan.cli.commands.list import prepare_pkglist_compact, print_serial
 from conan.cli.formatters.graph import format_graph_html, format_graph_json, format_graph_dot
+from conan.cli.formatters.graph.build_order_html import format_build_order_html
 from conan.cli.formatters.graph.graph_info_text import format_graph_info
 from conan.cli.printers.graph import print_graph_packages, print_graph_basic
 from conan.errors import ConanException
@@ -62,7 +63,8 @@ def json_build_order(build_order):
     cli_out_write(json.dumps(build_order, indent=4))
 
 
-@conan_subcommand(formatters={"text": cli_build_order, "json": json_build_order})
+@conan_subcommand(formatters={"text": cli_build_order, "json": json_build_order,
+                              "html": format_build_order_html})
 def graph_build_order(conan_api, parser, subparser, *args):
     """
     Compute the build order of a dependency graph.
@@ -129,7 +131,8 @@ def graph_build_order(conan_api, parser, subparser, *args):
     return install_order_serialized
 
 
-@conan_subcommand(formatters={"text": cli_build_order, "json": json_build_order})
+@conan_subcommand(formatters={"text": cli_build_order, "json": json_build_order,
+                              "html": format_build_order_html})
 def graph_build_order_merge(conan_api, parser, subparser, *args):
     """
     Merge more than 1 build-order file.
diff --git a/conan/cli/formatters/graph/build_order_html.py b/conan/cli/formatters/graph/build_order_html.py
new file mode 100644
index 00000000000..cdd1bbfc154
--- /dev/null
+++ b/conan/cli/formatters/graph/build_order_html.py
@@ -0,0 +1,266 @@
+from jinja2 import select_autoescape, Template
+
+from conan.api.output import cli_out_write
+
+build_order_html = r"""<html lang="en">
+    <head>
+        <script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.30.0/cytoscape.min.js"
+        integrity="sha512-zHc90yHSbkgx0bvVpDK/nVgxANlE+yKN/jKy91tZ4P/vId8AL7HyjSpZqHmEujWDWNwxYXcfaLdYWjAULl35MQ=="
+        crossorigin="anonymous"
+        referrerpolicy="no-referrer"></script>
+    </head>
+
+    <body>
+        <style>
+            body {
+                font: 14px helvetica neue, helvetica, arial, sans-serif;
+                display: flex;
+                margin: 0;
+                padding: 0;
+                height: 100vh;
+            }
+
+            .sidebar {
+                background: #f9f9f9;
+                border-right: 1px solid #ccc;
+                padding: 20px;
+                box-sizing: border-box;
+                overflow-y: auto;
+                font-size: 14px;
+                width: 400px;
+            }
+
+            .content {
+                flex-grow: 1;
+                display: flex;
+                flex-direction: column;
+                overflow: hidden;
+            }
+
+            #cy {
+                flex-grow: 1;
+            }
+
+            #node-info {
+                margin-top: 20px;
+                background: #f9f9f9;
+                padding: 10px;
+                font-family: monospace;
+                white-space: pre-wrap;
+                font-size: 12px;
+                word-wrap: break-word;
+            }
+
+            .legend {
+                margin-top: 20px;
+                font-size: 14px;
+            }
+
+            .legend-item {
+                display: flex;
+                align-items: center;
+                margin-bottom: 5px;
+            }
+
+            .legend-color {
+                width: 20px;
+                height: 20px;
+                margin-right: 10px;
+            }
+        </style>
+
+        <div class="sidebar">
+            <div class="legend">
+                <div class="legend-item">
+                    <div class="legend-color" style="background-color: #FFCC80;"></div>
+                    <span>All packages need to be built</span>
+                </div>
+                <div class="legend-item">
+                    <div class="legend-color" style="background-color: #FFEB3B;"></div>
+                    <span>Some packages need to be built</span>
+                </div>
+                <div class="legend-item">
+                    <div class="legend-color" style="background-color: #A5D6A7;"></div>
+                    <span>Cache</span>
+                </div>
+                <div class="legend-item">
+                    <div class="legend-color" style="background-color: #A5D6FF;"></div>
+                    <span>Download</span>
+                </div>
+            </div>
+            <div id="node-info">
+                <p>Click on a node to see details.</p>
+            </div>
+        </div>
+        <div class="content">
+            <div id="cy"></div>
+        </div>
+
+        <script type="text/javascript">
+            document.addEventListener("DOMContentLoaded", function() {
+                var buildOrderData = {{ build_order | tojson }};
+
+                var elements = [];
+                var edges = [];
+
+                var yOffset = 50;
+                var posX = 0;
+                var posY = 0;
+                var columns = 4;
+
+                buildOrderData.forEach((step, stepIndex) => {
+                    var stepId = 'step' + stepIndex;
+                    elements.push({
+                        data: { id: stepId, label: 'Step ' + (stepIndex + 1) },
+                        position: { x: posX, y: posY }
+                    });
+
+                    step.forEach((lib, libIndex) => {
+                        var libId = stepId + '_lib' + libIndex;
+                        var libLabel = lib.ref.split('#')[0];
+
+                        // Determine the type of the library
+                        var libData = Array.isArray(lib.packages) ? lib.packages[0][0] : lib;
+                        var libType = libData.binary || libData.build || libData.cache;
+
+                        var isAllBuild = true;
+                        var isSomeBuild = false;
+                        var shape = 'rectangle';
+                        var borderColor = '#00695C';
+
+                        if (Array.isArray(lib.packages)) {
+                            lib.packages.forEach(pkgArray => {
+                                pkgArray.forEach(pkg => {
+                                    if (pkg.binary === "Build") {
+                                        isSomeBuild = true;
+                                    } else {
+                                        isAllBuild = false;
+                                    }
+                                    if (pkg.context === "build") {
+                                        shape = 'cut-rectangle';
+                                        borderColor = '#0000FF';  // Different border color for build context
+                                    }
+                                });
+                            });
+                        }
+
+                        var nodeColor;
+                        if (isAllBuild) {
+                            nodeColor = "#FFCC80"; // Light orange for all build
+                        } else if (isSomeBuild) {
+                            nodeColor = "#FFEB3B"; // Yellow for some build
+                        } else if (libType === "Cache") {
+                            nodeColor = "#A5D6A7"; // Light green
+                        } else if (libType === "Download") {
+                            nodeColor = "#A5D6FF"; // Light blue
+                        } else {
+                            nodeColor = "#FFFFFF"; // Default color
+                        }
+
+                        if (libIndex % columns === 0) {
+                            posX = 0;
+                            posY += yOffset; // move to the next row
+                        }
+
+                        elements.push({
+                            data: { id: libId, parent: stepId, label: libLabel, info: lib, color: nodeColor, shape: shape, borderColor: borderColor },
+                            position: { x: posX + libLabel.length / 2.0 * 12, y: posY }
+                        });
+                        posX += libLabel.length * 12;
+                    });
+
+                    if (stepIndex > 0) {
+                        var prevStepId = 'step' + (stepIndex - 1);
+                        edges.push({ data: { id: prevStepId + '_to_' + stepId, source: prevStepId, target: stepId } });
+                    }
+
+                    posY += yOffset * 2;
+                    posX = 0;
+                });
+
+                var cy = cytoscape({
+                    container: document.getElementById('cy'),
+                    boxSelectionEnabled: false,
+                    style: [
+                        {
+                            selector: 'node[color][shape][borderColor]',
+                            style: {
+                                'shape': 'data(shape)',
+                                'content': 'data(label)',
+                                'text-valign': 'center',
+                                'text-halign': 'center',
+                                'background-color': 'data(color)',
+                                'border-color': 'data(borderColor)',
+                                'border-width': 1,
+                                'width': 'label',
+                                'height': 'label',
+                                'padding': '5px',
+                                'font-family': 'monospace',
+                                'font-size': '16px'
+                            }
+                        },
+                        {
+                            selector: ':parent',
+                            style: {
+                                'text-valign': 'top',
+                                'text-halign': 'center',
+                                'shape': 'round-rectangle',
+                                'background-opacity': 0.1,
+                                'border-color': '#004D40',
+                                'border-width': 2,
+                                'padding': 10,
+                                'font-family': 'monospace',
+                                'font-size': '16px'
+                            }
+                        },
+                        {
+                            selector: 'edge',
+                            style: {
+                                'curve-style': 'bezier',
+                                'target-arrow-shape': 'triangle',
+                                'line-color': '#004D40',
+                                'target-arrow-color': '#004D40',
+                                'width': 2
+                            }
+                        }
+                    ],
+                    elements: {
+                        nodes: elements,
+                        edges: edges
+                    },
+                    layout: {
+                        name: 'preset',
+                        padding: 5,
+                        fit: true
+                    }
+                });
+
+                // Add click event listener to nodes
+                cy.on('tap', 'node', function(evt){
+                    var node = evt.target;
+                    var info = node.data('info');
+                    var infoHtml = '';
+                    for (var key in info) {
+                        if (info.hasOwnProperty(key)) {
+                            infoHtml += '<p><strong>' + key + ':</strong> ' + JSON.stringify(info[key], null, 2) + '</p>';
+                        }
+                    }
+                    document.getElementById('node-info').innerHTML = infoHtml;
+                });
+            });
+        </script>
+    </body>
+</html>
+"""
+
+
+def _render_build_order(build_order, template):
+    from conans import __version__ as client_version
+    context = {'build_order': build_order, 'version': client_version, }
+    return template.render(context)
+
+
+def format_build_order_html(build_order):
+    build_order = build_order["order"] if isinstance(build_order, dict) else build_order
+    template = Template(build_order_html, autoescape=select_autoescape(['html', 'xml']))
+    cli_out_write(_render_build_order(build_order, template))
diff --git a/test/integration/command_v2/test_info_build_order.py b/test/integration/command_v2/test_info_build_order.py
index cf95afb5429..b735920e185 100644
--- a/test/integration/command_v2/test_info_build_order.py
+++ b/test/integration/command_v2/test_info_build_order.py
@@ -73,6 +73,14 @@ def test_info_build_order():
     assert bo_json["order_by"] == "recipe"
     assert bo_json["order"] == result
 
+    # test html format
+    c.run("graph build-order consumer --build=missing --format=html")
+    assert "<body>" in c.stdout
+    c.run("graph build-order consumer --order-by=recipe --format=html")
+    assert "<body>" in c.stdout
+    c.run("graph build-order consumer --order-by=configuration --format=html")
+    assert "<body>" in c.stdout
+
 
 def test_info_build_order_configuration():
     c = TestClient()
@@ -332,6 +340,11 @@ def test_info_build_order_merge_multi_product():
 
     assert bo_json == result
 
+    # test that html format for build-order-merge generates something
+    c.run("graph build-order-merge --file=bo1.json --file=bo2.json --format=html")
+    assert "<body>" in c.stdout
+
+
 
 def test_info_build_order_merge_multi_product_configurations():
     c = TestClient()


Code review comments:
diff_hunk: @@ -0,0 +1,266 @@
+from jinja2 import select_autoescape, Template
+
+from conan.api.output import cli_out_write
+
+build_order_html = r"""<html lang="en">
+    <head>
+        <script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.30.0/cytoscape.min.js"
+        integrity="sha512-zHc90yHSbkgx0bvVpDK/nVgxANlE+yKN/jKy91tZ4P/vId8AL7HyjSpZqHmEujWDWNwxYXcfaLdYWjAULl35MQ=="
+        crossorigin="anonymous"
+        referrerpolicy="no-referrer"></script>
+    </head>
+
+    <body>
+        <style>
+            body {
+                font: 14px helvetica neue, helvetica, arial, sans-serif;
+                display: flex;
+                margin: 0;
+                padding: 0;
+                height: 100vh;
+            }
+
+            .sidebar {
+                background: #f9f9f9;
+                border-right: 1px solid #ccc;
+                padding: 20px;
+                box-sizing: border-box;
+                overflow-y: auto;
+                font-size: 14px;
+                width: 400px;
+            }
+
+            .content {
+                flex-grow: 1;
+                display: flex;
+                flex-direction: column;
+                overflow: hidden;
+            }
+
+            #cy {
+                flex-grow: 1;
+            }
+
+            #node-info {
+                margin-top: 20px;
+                background: #f9f9f9;
+                padding: 10px;
+                font-family: monospace;
+                white-space: pre-wrap;
+                font-size: 12px;
+                word-wrap: break-word;
+            }
+
+            .legend {
+                margin-top: 20px;
+                font-size: 14px;
+            }
+
+            .legend-item {
+                display: flex;
+                align-items: center;
+                margin-bottom: 5px;
+            }
+
+            .legend-color {
+                width: 20px;
+                height: 20px;
+                margin-right: 10px;
+            }
+        </style>
+
+        <div class="sidebar">
+            <div class="legend">
+                <div class="legend-item">
+                    <div class="legend-color" style="background-color: #FFCC80;"></div>
+                    <span>All packages need to be built</span>
+                </div>
+                <div class="legend-item">
+                    <div class="legend-color" style="background-color: #FFEB3B;"></div>
+                    <span>Some packages need to be built</span>
+                </div>
+                <div class="legend-item">
+                    <div class="legend-color" style="background-color: #A5D6A7;"></div>
+                    <span>Cache</span>
+                </div>
+                <div class="legend-item">
+                    <div class="legend-color" style="background-color: #A5D6FF;"></div>
+                    <span>Download</span>
+                </div>
+            </div>
+            <div id="node-info">
+                <p>Click on a node to see details.</p>
+            </div>
+        </div>
+        <div class="content">
+            <div id="cy"></div>
+        </div>
+
+        <script type="text/javascript">
+            document.addEventListener("DOMContentLoaded", function() {
+                var buildOrderData = {{ build_order | tojson }};
+
+                var elements = [];
+                var edges = [];
+
+                var yOffset = 50;
+                var posX = 0;
+                var posY = 0;
+                var columns = 4;
+
+                buildOrderData.forEach((step, stepIndex) => {
+                    var stepId = 'step' + stepIndex;
+                    elements.push({
+                        data: { id: stepId, label: 'Step ' + (stepIndex + 1) },
+                        position: { x: posX, y: posY }
+                    });
+
+                    step.forEach((lib, libIndex) => {
+                        var libId = stepId + '_lib' + libIndex;
+                        var libLabel = lib.ref.split('#')[0];
+
+                        // Determine the type of the library
+                        var libData = Array.isArray(lib.packages) ? lib.packages[0][0] : lib;
+                        var libType = libData.binary || libData.build || libData.cache;
+
+                        var isAllBuild = true;
+                        var isSomeBuild = false;
+                        var shape = 'rectangle';
+                        var borderColor = '#00695C';
+
+                        if (Array.isArray(lib.packages)) {
+                            lib.packages.forEach(pkgArray => {
+                                pkgArray.forEach(pkg => {
+                                    if (pkg.binary === "Build") {
+                                        isSomeBuild = true;
+                                    } else {
+                                        isAllBuild = false;
+                                    }
+                                    if (pkg.context === "build") {
+                                        shape = 'cut-rectangle';
path: conan/cli/formatters/graph/build_order_html.py
text: @user1:
Should we follow the same legend as the `conan graph info` output?

@author:
I updated the colors and node type to be more coherent with the graph info, can you check?

diff_hunk: @@ -0,0 +1,266 @@
+from jinja2 import select_autoescape, Template
+
+from conan.api.output import cli_out_write
+
+build_order_html = r"""<html lang="en">
+    <head>
+        <script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.30.0/cytoscape.min.js"
+        integrity="sha512-zHc90yHSbkgx0bvVpDK/nVgxANlE+yKN/jKy91tZ4P/vId8AL7HyjSpZqHmEujWDWNwxYXcfaLdYWjAULl35MQ=="
+        crossorigin="anonymous"
+        referrerpolicy="no-referrer"></script>
+    </head>
+
+    <body>
+        <style>
+            body {
+                font: 14px helvetica neue, helvetica, arial, sans-serif;
+                display: flex;
+                margin: 0;
+                padding: 0;
+                height: 100vh;
+            }
+
+            .sidebar {
+                background: #f9f9f9;
+                border-right: 1px solid #ccc;
+                padding: 20px;
+                box-sizing: border-box;
+                overflow-y: auto;
+                font-size: 14px;
+                width: 400px;
+            }
+
+            .content {
+                flex-grow: 1;
+                display: flex;
+                flex-direction: column;
+                overflow: hidden;
+            }
+
+            #cy {
+                flex-grow: 1;
+            }
+
+            #node-info {
+                margin-top: 20px;
+                background: #f9f9f9;
+                padding: 10px;
+                font-family: monospace;
+                white-space: pre-wrap;
+                font-size: 12px;
+                word-wrap: break-word;
+            }
+
+            .legend {
+                margin-top: 20px;
+                font-size: 14px;
+            }
+
+            .legend-item {
+                display: flex;
+                align-items: center;
+                margin-bottom: 5px;
+            }
+
+            .legend-color {
+                width: 20px;
+                height: 20px;
+                margin-right: 10px;
+            }
+        </style>
+
+        <div class="sidebar">
+            <div class="legend">
+                <div class="legend-item">
+                    <div class="legend-color" style="background-color: #FFCC80;"></div>
path: conan/cli/formatters/graph/build_order_html.py
text: @user1:
Can this colour have a bit more contrast with "Cache" ones? It's a bit hard for my fellow colourblind people to distiguish them apart