Repository: conan-io/conan

Issue:
conan info graph output to html should include local versions of vis library
Conan version: 1.7.4

When running `conan info -g foo.html .` to generate a dependency graph, the generated HTML includes hard-coded links to cdnjs for `vis.min.js` and `vis.min.css`:

```html
<head>
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.18.1/vis.min.js"></script>
  <link href="https://cdnjs.cloudflare.com/ajax/libs/vis/4.18.1/vis.min.css" rel="stylesheet" type="text/css" />
</head>
```

In a corporate environment users may not have access to these URLs so the generated file is not useful. It would be nice if we could package these files in conan itself and output them alongside the generated html file if requested (via command-line flag, for example).

---

To help us debug your issue please explain:

- [x] I've read the [CONTRIBUTING guide](https://raw.githubusercontent.com/conan-io/conan/develop/.github/CONTRIBUTING.md).
- [x] I've specified the Conan version, operating system version and any tool that can be relevant.
- [x] I've explained the steps to reproduce the error or the motivation/use case of the question/suggestion.




Discussion:
I think it is doable, but feels a bit dirty. But yes, I understand the use case.
The implementation (packaging and distributing those files), needs to check for license compatibility, and it might be a bit dirty to package the files. But embedding the strings in python files is always possible, so they could directly be embedded in the html file, so it is self-contained.

To be discussed for 1.9.
Seems the simplest and most effective is to embed them as strings in py files.

Please check the LICENSES of the JS and CSS

Resolving patch:
diff --git a/conans/client/conan_command_output.py b/conans/client/conan_command_output.py
--- a/conans/client/conan_command_output.py
+++ b/conans/client/conan_command_output.py
@@ -4,6 +4,7 @@
 from conans.client.printer import Printer
 from conans.client.remote_registry import RemoteRegistry
 from conans.model.ref import ConanFileReference, PackageReference
+from conans.search.binary_html_table import html_binary_graph
 from conans.unicode import get_cwd
 from conans.util.files import save
 
@@ -85,13 +86,14 @@ def info(self, deps_graph, only, package_filter, show_paths):
         Printer(self.user_io.out).print_info(deps_graph,
                                              only, registry,
                                              node_times=self._read_dates(deps_graph),
-                                             path_resolver=self.client_cache, package_filter=package_filter,
+                                             path_resolver=self.client_cache,
+                                             package_filter=package_filter,
                                              show_paths=show_paths)
 
     def info_graph(self, graph_filename, deps_graph, cwd):
         if graph_filename.endswith(".html"):
             from conans.client.graph.grapher import ConanHTMLGrapher
-            grapher = ConanHTMLGrapher(deps_graph)
+            grapher = ConanHTMLGrapher(deps_graph, self.client_cache.conan_folder)
         else:
             from conans.client.graph.grapher import ConanGrapher
             grapher = ConanGrapher(deps_graph)
@@ -106,9 +108,8 @@ def print_search_references(self, search_info, pattern, raw, all_remotes_search)
         printer.print_search_recipes(search_info, pattern, raw, all_remotes_search)
 
     def print_search_packages(self, search_info, reference, packages_query, table,
-                                outdated=False):
+                              outdated=False):
         if table:
-            from conans.client.graph.grapher import html_binary_graph
             html_binary_graph(search_info, reference, table)
         else:
             printer = Printer(self.user_io.out)
diff --git a/conans/client/graph/grapher.py b/conans/client/graph/grapher.py
--- a/conans/client/graph/grapher.py
+++ b/conans/client/graph/grapher.py
@@ -1,104 +1,8 @@
-from collections import defaultdict, namedtuple
-
 from conans.client.graph.graph import BINARY_BUILD, BINARY_CACHE, BINARY_DOWNLOAD, BINARY_MISSING, \
     BINARY_UPDATE
 from conans.client.installer import build_id
 from conans.util.files import save
-
-
-def html_binary_graph(search_info, reference, table_filename):
-    result = ["""<style>
-    table, th, td {
-    border: 1px solid black;
-    border-collapse: collapse;
-}
-.selected {
-    border: 3px solid black;
-}
-</style>
-<script type="text/javascript">
-    function handleEvent(id) {
-        selected = document.getElementsByClassName('selected');
-        if (selected[0]) selected[0].className = '';
-        cell = document.getElementById(id);
-        cell.className = 'selected';
-        elem = document.getElementById('SelectedPackage');
-        elem.innerHTML = id;
-    }
-
-</script>
-<h1>%s</h1>
-    """ % str(reference)]
-
-    for remote_info in search_info:
-        if remote_info["remote"] != 'None':
-            result.append("<h2>'%s':</h2>" % str(remote_info["remote"]))
-
-        ordered_packages = remote_info["items"][0]["packages"]
-        binary = namedtuple("Binary", "ID outdated")
-        columns = set()
-        table = defaultdict(dict)
-        for package in ordered_packages:
-            package_id = package["id"]
-            settings = package["settings"]
-            if settings:
-                row_name = "%s %s %s" % (settings.get("os", "None"), settings.get("compiler", "None"),
-                                         settings.get("compiler.version", "None"))
-                column_name = []
-                for setting, value in settings.items():
-                    if setting.startswith("compiler."):
-                        if not setting.startswith("compiler.version"):
-                            row_name += " (%s)" % value
-                    elif setting not in ("os", "compiler"):
-                        column_name.append(value)
-                column_name = " ".join(column_name)
-            else:
-                row_name = "NO settings"
-                column_name = ""
-
-            options = package["options"]
-            if options:
-                for k, v in options.items():
-                    column_name += "<br>%s=%s" % (k, v)
-
-            column_name = column_name or "NO options"
-            columns.add(column_name)
-            # Always compare outdated with local recipe, simplification,
-            # if a remote check is needed install recipe first
-            if "outdated" in package:
-                outdated = package["outdated"]
-            else:
-                outdated = None
-            table[row_name][column_name] = binary(package_id, outdated)
-
-        headers = sorted(columns)
-        result.append("<table>")
-        result.append("<tr>")
-        result.append("<th></th>")
-        for header in headers:
-            result.append("<th>%s</th>" % header)
-        result.append("</tr>")
-        for row, columns in sorted(table.items()):
-            result.append("<tr>")
-            result.append("<td>%s</td>" % row)
-            for header in headers:
-                col = columns.get(header, "")
-                if col:
-                    color = "#ffff00" if col.outdated else "#00ff00"
-                    result.append('<td bgcolor=%s id="%s" onclick=handleEvent("%s")></td>' % (color, col.ID, col.ID))
-                else:
-                    result.append("<td></td>")
-            result.append("</tr>")
-        result.append("</table>")
-
-    result.append('<br>Selected: <div id="SelectedPackage"></div>')
-
-    result.append("<br>Legend<br>"
-                  "<table><tr><td bgcolor=#ffff00>&nbsp;&nbsp;&nbsp;&nbsp;</td><td>Outdated from recipe</td></tr>"
-                  "<tr><td bgcolor=#00ff00>&nbsp;&nbsp;&nbsp;&nbsp;</td><td>Updated</td></tr>"
-                  "<tr><td>&nbsp;&nbsp;&nbsp;&nbsp;</td><td>Non existing</td></tr></table>")
-    html_contents = "\n".join(result)
-    save(table_filename, html_contents)
+import os
 
 
 class ConanGrapher(object):
@@ -123,8 +27,18 @@ def graph_file(self, output_filename):
 
 
 class ConanHTMLGrapher(object):
-    def __init__(self, deps_graph):
+    def __init__(self, deps_graph, cache_folder):
         self._deps_graph = deps_graph
+        self._cache_folder = cache_folder
+
+    def _visjs_paths(self):
+        visjs = "https://cdnjs.cloudflare.com/ajax/libs/vis/4.18.1/vis.min.js"
+        viscss = "https://cdnjs.cloudflare.com/ajax/libs/vis/4.18.1/vis.min.css"
+        visjs_path = os.path.join(self._cache_folder, "vis.min.js")
+        visjs = visjs_path if os.path.exists(visjs_path) else visjs
+        viscss_path = os.path.join(self._cache_folder, "vis.min.css")
+        viscss = viscss_path if os.path.exists(viscss_path) else viscss
+        return visjs, viscss
 
     def graph_file(self, filename):
         save(filename, self.graph())
@@ -144,7 +58,8 @@ def graph(self):
                 for name, data in [("id", conanfile.info.package_id()),
                                    ("build_id", build_id(conanfile)),
                                    ("url", '<a href="{url}">{url}</a>'.format(url=conanfile.url)),
-                                   ("homepage", '<a href="{url}">{url}</a>'.format(url=conanfile.homepage)),
+                                   ("homepage",
+                                    '<a href="{url}">{url}</a>'.format(url=conanfile.homepage)),
                                    ("license", conanfile.license),
                                    ("author", conanfile.author),
                                    ("topics", str(conanfile.topics))]:
@@ -165,7 +80,8 @@ def graph(self):
                      BINARY_BUILD: "Khaki",
                      BINARY_MISSING: "OrangeRed",
                      BINARY_UPDATE: "SeaGreen"}.get(node.binary, "White")
-            nodes.append("{id: %d, label: '%s', shape: '%s', color: {background: '%s'}, fulllabel: '%s'}"
+            nodes.append("{id: %d, label: '%s', shape: '%s', "
+                         "color: {background: '%s'}, fulllabel: '%s'}"
                          % (i, label, shape, color, fulllabel))
         nodes = ",\n".join(nodes)
 
@@ -177,13 +93,15 @@ def graph(self):
                 edges.append("{ from: %d, to: %d }" % (src, dst))
         edges = ",\n".join(edges)
 
-        return self._template.replace("%NODES%", nodes).replace("%EDGES%", edges)
+        result = self._template.replace("%NODES%", nodes).replace("%EDGES%", edges)
+        visjs, viscss = self._visjs_paths()
+        return result.replace("%VISJS%", visjs).replace("%VISCSS%", viscss)
 
     _template = """<html>
 
 <head>
-  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.18.1/vis.min.js"></script>
-  <link href="https://cdnjs.cloudflare.com/ajax/libs/vis/4.18.1/vis.min.css" rel="stylesheet" type="text/css" />
+  <script type="text/javascript" src="%VISJS%"></script>
+  <link href="%VISCSS%" rel="stylesheet" type="text/css"/>
 </head>
 
 <body>
@@ -216,7 +134,9 @@ def graph(self):
     <div id="mynetwork" style="float:left; width: 75%;"></div>
     <div style="float:right;width:25%;">
       <div id="details"  style="padding:10;" class="noPrint">Package info: no package selected</div>
-      <button onclick="javascript:showhideclass('controls')" class="button noPrint">Show / hide graph controls.</button>
+      <button onclick="javascript:showhideclass('controls')" class="button noPrint">
+          Show / hide graph controls
+      </button>
       <div id="controls" class="controls" style="padding:5; display:none"></div>
     </div>
   </div>
diff --git a/conans/search/binary_html_table.py b/conans/search/binary_html_table.py
new file mode 100644
--- /dev/null
+++ b/conans/search/binary_html_table.py
@@ -0,0 +1,100 @@
+from collections import namedtuple, defaultdict
+from conans.util.files import save
+
+
+def html_binary_graph(search_info, reference, table_filename):
+    result = ["""<style>
+    table, th, td {
+    border: 1px solid black;
+    border-collapse: collapse;
+}
+.selected {
+    border: 3px solid black;
+}
+</style>
+<script type="text/javascript">
+    function handleEvent(id) {
+        selected = document.getElementsByClassName('selected');
+        if (selected[0]) selected[0].className = '';
+        cell = document.getElementById(id);
+        cell.className = 'selected';
+        elem = document.getElementById('SelectedPackage');
+        elem.innerHTML = id;
+    }
+
+</script>
+<h1>%s</h1>
+    """ % str(reference)]
+
+    for remote_info in search_info:
+        if remote_info["remote"] != 'None':
+            result.append("<h2>'%s':</h2>" % str(remote_info["remote"]))
+
+        ordered_packages = remote_info["items"][0]["packages"]
+        binary = namedtuple("Binary", "ID outdated")
+        columns = set()
+        table = defaultdict(dict)
+        for package in ordered_packages:
+            package_id = package["id"]
+            settings = package["settings"]
+            if settings:
+                row_name = "%s %s %s" % (settings.get("os", "None"),
+                                         settings.get("compiler", "None"),
+                                         settings.get("compiler.version", "None"))
+                column_name = []
+                for setting, value in settings.items():
+                    if setting.startswith("compiler."):
+                        if not setting.startswith("compiler.version"):
+                            row_name += " (%s)" % value
+                    elif setting not in ("os", "compiler"):
+                        column_name.append(value)
+                column_name = " ".join(column_name)
+            else:
+                row_name = "NO settings"
+                column_name = ""
+
+            options = package["options"]
+            if options:
+                for k, v in options.items():
+                    column_name += "<br>%s=%s" % (k, v)
+
+            column_name = column_name or "NO options"
+            columns.add(column_name)
+            # Always compare outdated with local recipe, simplification,
+            # if a remote check is needed install recipe first
+            if "outdated" in package:
+                outdated = package["outdated"]
+            else:
+                outdated = None
+            table[row_name][column_name] = binary(package_id, outdated)
+
+        headers = sorted(columns)
+        result.append("<table>")
+        result.append("<tr>")
+        result.append("<th></th>")
+        for header in headers:
+            result.append("<th>%s</th>" % header)
+        result.append("</tr>")
+        for row, columns in sorted(table.items()):
+            result.append("<tr>")
+            result.append("<td>%s</td>" % row)
+            for header in headers:
+                col = columns.get(header, "")
+                if col:
+                    color = "#ffff00" if col.outdated else "#00ff00"
+                    result.append('<td bgcolor=%s id="%s" onclick=handleEvent("%s")></td>'
+                                  % (color, col.ID, col.ID))
+                else:
+                    result.append("<td></td>")
+            result.append("</tr>")
+        result.append("</table>")
+
+    result.append('<br>Selected: <div id="SelectedPackage"></div>')
+
+    result.append("<br>Legend<br>"
+                  "<table><tr><td bgcolor=#ffff00>&nbsp;&nbsp;&nbsp;&nbsp;</td"
+                  "<td>Outdated from recipe</td></tr>"
+                  "<tr><td bgcolor=#00ff00>&nbsp;&nbsp;&nbsp;&nbsp;</td><td>Updated</td></tr>"
+                  "<tr><td>&nbsp;&nbsp;&nbsp;&nbsp;</td><td>Non existing</td></tr></table>")
+    html_contents = "\n".join(result)
+    save(table_filename, html_contents)


Test patch:
diff --git a/conans/test/functional/command/info_test.py b/conans/test/functional/command/info_test.py
--- a/conans/test/functional/command/info_test.py
+++ b/conans/test/functional/command/info_test.py
@@ -7,7 +7,7 @@
 from conans.paths import CONANFILE
 from conans.test.utils.cpp_test_files import cpp_hello_conan_files
 from conans.test.utils.tools import TestClient
-from conans.util.files import load
+from conans.util.files import load, save
 
 
 class InfoTest(unittest.TestCase):
@@ -197,6 +197,20 @@ def create_export(test_deps, name):
         self.assertIn("{ from: 0, to: 1 }", html)
         self.assertIn("id: 0, label: 'Hello0/0.1@PROJECT'", html)
 
+    def graph_html_embedded_visj_test(self):
+        client = TestClient()
+        visjs_path = os.path.join(client.client_cache.conan_folder, "vis.min.js")
+        viscss_path = os.path.join(client.client_cache.conan_folder, "vis.min.css")
+        save(visjs_path, "")
+        save(viscss_path, "")
+        client.save({"conanfile.txt": ""})
+        client.run("info . --graph=file.html")
+        html = load(os.path.join(client.current_folder, "file.html"))
+        self.assertIn("<body>", html)
+        self.assertNotIn("cloudflare", html)
+        self.assertIn(visjs_path, html)
+        self.assertIn(viscss_path, html)
+
     def info_build_requires_test(self):
         client = TestClient()
         conanfile = """from conans import ConanFile