Using the existing FlowTree and HTMLFormatter, create an HTML tree visualization that also supports collapsing and expanding entire flow subtrees.
Examples: $ ovs-appcl dpctl/dump-flows | ovs-flowviz --highlight drop datapath html > /tmp/flows.html $ ovs-appcl dpctl/dump-flows | ovs-flowviz -f "output.port=3" datapath html > /tmp/flows.html Both light and dark styles are supported. Signed-off-by: Adrian Moreno <amore...@redhat.com> --- python/automake.mk | 1 + python/ovs/flowviz/html_format.py | 13 +- python/ovs/flowviz/odp/cli.py | 10 + python/ovs/flowviz/odp/html.py | 337 ++++++++++++++++++++++++++++++ 4 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 python/ovs/flowviz/odp/html.py diff --git a/python/automake.mk b/python/automake.mk index 9640b5886..d534b52d9 100644 --- a/python/automake.mk +++ b/python/automake.mk @@ -71,6 +71,7 @@ ovs_flowviz = \ python/ovs/flowviz/main.py \ python/ovs/flowviz/odp/__init__.py \ python/ovs/flowviz/odp/cli.py \ + python/ovs/flowviz/odp/html.py \ python/ovs/flowviz/odp/tree.py \ python/ovs/flowviz/ofp/__init__.py \ python/ovs/flowviz/ofp/cli.py \ diff --git a/python/ovs/flowviz/html_format.py b/python/ovs/flowviz/html_format.py index 3f3550da5..3293089e1 100644 --- a/python/ovs/flowviz/html_format.py +++ b/python/ovs/flowviz/html_format.py @@ -98,6 +98,14 @@ class HTMLBuffer(FlowBuffer): kv.meta.vstring, style.color if style else "", href ) + def append_value_omitted(self, kv): + """Append an omitted value. + Args: + kv (KeyValue): the KeyValue instance to append + """ + dots = "." * len(kv.meta.vstring) + return self._append(dots, "", "") + def append_extra(self, extra, style): """Append extra string. Args: @@ -125,14 +133,15 @@ class HTMLFormatter(FlowFormatter): self._style_from_opts(opts, "html", HTMLStyle) or FlowStyle() ) - def format_flow(self, buf, flow, highlighted=None): + def format_flow(self, buf, flow, highlighted=None, omitted=None): """Formats the flow into the provided buffer as a html object. Args: buf (FlowBuffer): the flow buffer to append to flow (ovs_dbg.OFPFlow): the flow to format highlighted (list): Optional; list of KeyValues to highlight + omitted (list): Optional; list of KeyValues to omit """ return super(HTMLFormatter, self).format_flow( - buf, flow, self.style, highlighted + buf, flow, self.style, highlighted, omitted ) diff --git a/python/ovs/flowviz/odp/cli.py b/python/ovs/flowviz/odp/cli.py index 36f5b3db2..73fadef95 100644 --- a/python/ovs/flowviz/odp/cli.py +++ b/python/ovs/flowviz/odp/cli.py @@ -15,6 +15,7 @@ import click from ovs.flowviz.main import maincli +from ovs.flowviz.odp.html import HTMLTreeProcessor from ovs.flowviz.odp.tree import ConsoleTreeProcessor from ovs.flowviz.process import ( ConsoleProcessor, @@ -74,3 +75,12 @@ def tree(opts, heat_map): ) processor.process() processor.print() + + +@datapath.command() +@click.pass_obj +def html(opts): + """Print the flows in an HTML list sorted by recirc_id.""" + processor = HTMLTreeProcessor(opts) + processor.process() + processor.print() diff --git a/python/ovs/flowviz/odp/html.py b/python/ovs/flowviz/odp/html.py new file mode 100644 index 000000000..48a2c82d0 --- /dev/null +++ b/python/ovs/flowviz/odp/html.py @@ -0,0 +1,337 @@ +# Copyright (c) 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ovs.flowviz.html_format import HTMLBuffer, HTMLFormatter +from ovs.flowviz.odp.tree import FlowTree +from ovs.flowviz.process import FileProcessor + + +class HTMLTree: + """Class capable of printing a FlowTree in HTML.""" + + BODY_STYLE = """ + <style> + body {{ + background-color: {bg}; + color: {fg}; + }} + </style>""" + + STYLE = """ + <style> + .recirc { + font-weight: bold; + font-family: monospace; + font-size: 1.1rem; + border: 3px solid #ccc; + width: fit-content; + block-size: fit-content; + } + + .block-matches { + font-family: monospace; + border: 1px solid #ccc; + width: fit-content; + block-size: fit-content; + margin-left: 1em; + padding: 4px; + } + + .block-actions { + font-family: monospace; + border: 2px solid #ccc; + width: fit-content; + block-size: fit-content; + margin-top: 0.5em; + margin-bottom : 2em; + margin-left: 1em; + padding: 4px; + } + + /* List styling */ + ul il { + list-style-type: disc; + } + + .flowlist > li::marker { + list-style: disc; + } + + .actions > li::marker { + content: "\\21B3"; + font-size: 1.5em; + } + + /* Caret styling */ + .caret { + cursor: pointer; + user-select: none; + } + + .caret::before { + content: "\\25B6"; + font-size: 1.2em; + display: inline-block; + margin-right: 5px;cursor: pointer; + } + + /* Rotate the caret/arrow icon when clicked on. */ + .caret-down::before { + transform: rotate(90deg); + } + + .nested { + display: none; + } + + .active { + display: block; + } + + .focused { + border: 2px solid #0008ff; + } + </style> + """ # noqa: E501 + + SCRIPT = """ + <script> + var caret = document.getElementsByClassName("caret"); + var blocks = document.getElementsByClassName("block-matches"); + var i; + + for (i = 0; i < caret.length; i++) { + caret[i].addEventListener("click", function() { + this.parentElement.querySelector(".nested").classList.toggle("active"); + this.classList.toggle("caret-down"); + }); + } + + // Set focus to a flow block, expanding all parent elements. + function setFocus(targetId) { + var target = document.getElementById(targetId); + var others = document.getElementsByClassName("focused"); + var i; + for (i = 0; i < others.length; i++) { + others[i].classList.remove("focused"); + } + if (target) { + var element = target; + while (element !== null) { + if (element.classList.contains("nested")) { + element.classList.add("active"); + } + if (element.previousElementSibling && element.previousElementSibling.classList.contains("caret")) { + element.previousElementSibling.classList.add("caret-down"); + } + element = element.parentElement; + } + } + target.classList.toggle("focused"); + } + + window.addEventListener('hashchange', function () { + var targetId = window.location.hash.substring(1); + setFocus(targetId); + }); + </script> + """ # noqa: E501 + + def __init__(self, name, flowtree, opts): + self.name = name.replace(" ", "_") + self.tree = flowtree + self.opts = opts + self.formatter = HTMLFormatter(opts) + + @classmethod + def head(cls): + html = "<head>" + html += cls.STYLE + html += "</head>" + + return html + + @classmethod + def begin_body(cls, opts): + style = HTMLFormatter(opts).style + bg = ( + style.get("background").color + if style.get("background") + else "#f0f0f0" + ) + fg = style.get("default").color if style.get("default") else "black" + return cls.BODY_STYLE.format(bg=bg, fg=fg) + + @classmethod + def end_body(cls): + return cls.SCRIPT + + def format(self): + html_obj = f"<div id=flow_list-{self.name}>" + + html_obj += '<ul class="active flowlist">' + for in_port in sorted(self.tree.recirc_nodes[0].keys()): + node = self.tree.recirc_nodes[0][in_port] + if node.visible: + html_obj += "<li>" + html_obj += self.format_recirc_node(node) + html_obj += "</li>" + + html_obj += "</ul>" + html_obj += "</div>" + return html_obj + + def format_recirc_node(self, node): + html_obj = '<div class="recirc">' + html_obj += "[recirc_id({}) in_port({})]".format( + hex(node.recirc), node.in_port + ) + html_obj += "</div>" + + html_obj += '<ul class="flowlist">' # nested + + for block in node.visible_blocks(): + html_block = "<li>" + html_block += self.format_block(block) + html_block += "</li>" + html_obj += html_block + + html_obj += "</ul>" + return html_obj + + def format_single_block(self, block): + block_id = "block_{}".format(block.flows[0].flow.id) + html_obj = f'<div id="{block_id}" class="block-matches">' + + omit_first = { + "actions": "all", + } + omit_rest = { + "actions": "all", + "match": [kv.key for _, kv in block.equal_match], + } + + for i, flow in enumerate(filter(lambda x: x.visible, block.flows)): + html_obj += '<div class="flow">' + + omit = omit_rest if i > 0 else omit_first + buf = HTMLBuffer() + hl = None + if self.opts.get("highlight"): + result = self.opts.get("highlight").evaluate(flow.flow) + if result: + hl = result.kv + + self.formatter.format_flow(buf, flow.flow, hl, omitted=omit) + html_obj += buf.text + html_obj += "</div>" + + html_obj += "</div>" # Match list. + + html_obj += '<ul class="actions"><li>' + html_obj += "<div>" # Match list. + if block.next_recirc_nodes: + html_obj += '<div class="caret block-actions">' + else: + html_obj += '<div class="block-actions">' + + omit = { + "match": "all", + "info": "all", + "ufid": "all", + "dp_extra_info": "all", + } + buf = HTMLBuffer() + buf.append_extra("actions: ", None) + + hl = None + if self.opts.get("highlight"): + result = self.opts.get("highlight").evaluate(block.flows[0].flow) + if result: + hl = result.kv + + self.formatter.format_flow(buf, block.flows[0].flow, hl, omitted=omit) + html_obj += buf.text + html_obj += "</div>" + return html_obj + + def format_block(self, block): + html_obj = self.format_single_block(block) + + html_obj += '<ul class="nested recirclist">' + for node in block.next_recirc_nodes: + if node.visible: + html_obj += "<li>" + html_obj += self.format_recirc_node(node) + html_obj += "</li>" + html_obj += "</ul>" + html_obj += "</li>" + html_obj += "</ul>" + return html_obj + + +class HTMLTreeProcessor(FileProcessor): + def __init__(self, opts): + super().__init__(opts, "odp") + self.trees = {} + self.opts = opts + self.tree = None + self.curr_file = "" + + def start_file(self, name, filename): + self.tree = FlowTree() + self.curr_file = name + + def start_thread(self, name): + if not self.tree: + self.tree = FlowTree() + + def stop_thread(self, name): + full_name = self.curr_file + f" ({name})" + if self.tree: + self.trees[full_name] = self.tree + self.tree = None + + def process_flow(self, flow, name): + self.tree.add(flow, self.opts.get("filter")) + + def process(self): + super().process(False) + + def stop_file(self, name, filename): + if self.tree: + self.trees[name] = self.tree + self.tree = None + + def print(self): + html_obj = "<html>" + html_obj += "<head>" + html_obj += HTMLTree.head() + html_obj += "</head>" + + html_obj += "<body>" + html_obj += HTMLTree.begin_body(self.opts) + + for name, tree in self.trees.items(): + tree.build() + html_tree = HTMLTree(name, tree, self.opts) + html_obj += "<div>" + html_obj += "<h2>{}</h2>".format(name) + html_obj += html_tree.format() + html_obj += "</div>" + + html_obj += HTMLTree.end_body() + html_obj += "</body>" + html_obj += "</html>" + print(html_obj) -- 2.45.2 _______________________________________________ dev mailing list d...@openvswitch.org https://mail.openvswitch.org/mailman/listinfo/ovs-dev