Message ID | 20240710170504.2162803-9-amorenoz@redhat.com |
---|---|
State | Changes Requested |
Headers | show |
Series | Add flow visualization utility. | expand |
Context | Check | Description |
---|---|---|
ovsrobot/apply-robot | success | apply and check: success |
ovsrobot/github-robot-_Build_and_Test | success | github build: passed |
ovsrobot/intel-ovs-compilation | fail | test: fail |
On 10 Jul 2024, at 19:04, Adrian Moreno wrote: > Datapath flows can be arranged into a "tree"-like structure based on > recirculation ids and input ports. > > A recirculation group is composed of flows sharing the same "recirc_id" > and "in_port" match. Within that group, flows are arranged in blocks of > flows that have the same action list. Finally, if an action associated > with one of this "blocks" contains a "recirc" action, the recirculation > group is shown underneath. > > When filtering, instead of blindly dropping non-matching flows, drop all > the "subtrees" that don't have any matching flow. > > Examples: > $ ovs-flowviz -i dpflows.txt --style dark datapath tree | less -R > $ ovs-flowviz -i dpflows.txt --filter "output.port=eth0" datapath tree > > This patch adds the logic to build this structure in a format-agnostic > object called FlowTree and adds support for formatting it in the > console. > > Console format supports: > - head-maps formatting of statistics > - hash-based pallete of recirculation ids: each recirculation id is > assigned a unique color to easily follow the sequence of related > actions. > > Signed-off-by: Adrian Moreno <amorenoz@redhat.com> Thanks for sending the v5, the changes look good to me with one potential small spelling error. You can add my ACK on the next rebase version. //Eelco Acked-by: Eelco Chaudron <echaudro@redhat.com> > --- > python/automake.mk | 1 + > python/ovs/flow/kv.py | 9 + > python/ovs/flowviz/console.py | 41 ++- > python/ovs/flowviz/format.py | 60 +++- > python/ovs/flowviz/odp/cli.py | 20 ++ > python/ovs/flowviz/odp/tree.py | 512 +++++++++++++++++++++++++++++++++ > 6 files changed, 625 insertions(+), 18 deletions(-) > create mode 100644 python/ovs/flowviz/odp/tree.py > > diff --git a/python/automake.mk b/python/automake.mk > index 0487494d0..b3fef9bed 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/tree.py \ > python/ovs/flowviz/ofp/__init__.py \ > python/ovs/flowviz/ofp/cli.py \ > python/ovs/flowviz/ofp/html.py \ > diff --git a/python/ovs/flow/kv.py b/python/ovs/flow/kv.py > index f7d7be0cf..3afbf9fce 100644 > --- a/python/ovs/flow/kv.py > +++ b/python/ovs/flow/kv.py > @@ -67,6 +67,15 @@ class KeyValue(object): > def __repr__(self): > return "{}('{}')".format(self.__class__.__name__, self) > > + def __eq__(self, other): > + if isinstance(other, self.__class__): > + return self.key == other.key and self.value == other.value > + else: > + return False > + > + def __ne__(self, other): > + return not self.__eq__(other) > + > > class KVDecoders(object): > """KVDecoders class is used by KVParser to select how to decode the value > diff --git a/python/ovs/flowviz/console.py b/python/ovs/flowviz/console.py > index c8a78ec11..ab91512fe 100644 > --- a/python/ovs/flowviz/console.py > +++ b/python/ovs/flowviz/console.py > @@ -13,6 +13,8 @@ > # limitations under the License. > > import colorsys > +import itertools > +import zlib > > from rich.console import Console > from rich.color import Color > @@ -79,6 +81,14 @@ class ConsoleBuffer(FlowBuffer): > """ > return self._append(kv.meta.vstring, style) > > + 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, None) > + > def append_extra(self, extra, style): > """Append extra string. > Args: > @@ -107,20 +117,21 @@ class ConsoleFormatter(FlowFormatter): > def style_from_opts(self, opts): > return self._style_from_opts(opts, "console", Style) > > - def print_flow(self, flow, highlighted=None): > + def print_flow(self, flow, highlighted=None, omitted=None): > """Prints a flow to the console. > > Args: > flow (ovs_dbg.OFPFlow): the flow to print > style (dict): Optional; style dictionary to use > highlighted (list): Optional; list of KeyValues to highlight > + omitted (list): Optional; list of KeyValues to omit > """ > > buf = ConsoleBuffer(Text()) > - self.format_flow(buf, flow, highlighted) > - self.console.print(buf.text) > + self.format_flow(buf, flow, highlighted, omitted) > + self.console.print(buf.text, soft_wrap=True) > > - 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 rich.Text. > > Args: > @@ -128,9 +139,10 @@ class ConsoleFormatter(FlowFormatter): > flow (ovs_dbg.OFPFlow): the flow to format > style (FlowStyle): Optional; style object to use > highlighted (list): Optional; list of KeyValues to highlight > + omitted (list): Optional; list of KeyValues to omit > """ > return super(ConsoleFormatter, self).format_flow( > - buf, flow, self.style, highlighted > + buf, flow, self.style, highlighted, omitted > ) > > > @@ -157,6 +169,25 @@ def heat_pallete(min_value, max_value): > return heat > > > +def hash_pallete(hue, saturation, value): > + """Generates a color pallete with the cartesian product > + of the hsv values provided and returns a callable that assigns a color for > + each value hash > + """ > + HSV_tuples = itertools.product(hue, saturation, value) > + RGB_tuples = map(lambda x: colorsys.hsv_to_rgb(*x), HSV_tuples) > + styles = [ > + Style(color=Color.from_rgb(r * 255, g * 255, b * 255)) > + for r, g, b in RGB_tuples > + ] > + > + def get_style(string): > + hash_val = zlib.crc32(bytes(str(string), "utf-8")) > + return styles[hash_val % len(styles)] > + > + return get_style > + > + > def default_highlight(): > """Generates a default style for highlights.""" > return Style(underline=True) > diff --git a/python/ovs/flowviz/format.py b/python/ovs/flowviz/format.py > index 70af2fa26..67711a92f 100644 > --- a/python/ovs/flowviz/format.py > +++ b/python/ovs/flowviz/format.py > @@ -225,7 +225,8 @@ class FlowFormatter: > > return FlowStyle({k: style_constructor(**v) for k, v in style.items()}) > > - def format_flow(self, buf, flow, style_obj=None, highlighted=None): > + def format_flow(self, buf, flow, style_obj=None, highlighted=None, > + omitted=None): > """Formats the flow into the provided buffer. > > Args: > @@ -233,25 +234,41 @@ class FlowFormatter: > flow (ovs_dbg.OFPFlow): the flow to format > style_obj (FlowStyle): Optional; style to use > highlighted (list): Optional; list of KeyValues to highlight > + omitted (list): Optional; dict of keys to omit indexed by section > + name. > """ > last_printed_pos = 0 > + first = True > > - if style_obj: > + if style_obj or omitted: > style_obj = style_obj or FlowStyle() > for section in sorted(flow.sections, key=lambda x: x.pos): > - buf.append_extra( > - flow.orig[last_printed_pos : section.pos], > - style=style_obj.get("default"), > - ) > + section_omitted = (omitted or {}).get(section.name) > + if isinstance(section_omitted, str) and \ > + section_omitted == "all": > + last_printed_pos += section.pos + len(section.string) > + continue > + > + # Do not print leading extra strings (e.g: spaces and commas) > + # if it's the first section that gets printed. > + if not first: > + buf.append_extra( > + flow.orig[last_printed_pos : section.pos], > + style=style_obj.get("default"), > + ) > + > self.format_kv_list( > - buf, section.data, section.string, style_obj, highlighted > + buf, section.data, section.string, style_obj, highlighted, > + section_omitted > ) > last_printed_pos = section.pos + len(section.string) > + first = False > else: > # Don't pay the cost of formatting each section one by one. > buf.append_extra(flow.orig.strip(), None) > > - def format_kv_list(self, buf, kv_list, full_str, style_obj, highlighted): > + def format_kv_list(self, buf, kv_list, full_str, style_obj, highlighted, > + omitted=None): > """Format a KeyValue List. > > Args: > @@ -260,10 +277,14 @@ class FlowFormatter: > full_str (str): the full string containing all k-v > style_obj (FlowStyle): a FlowStyle object to use > highlighted (list): Optional; list of KeyValues to highlight > + highlighted (list): Optional; list of KeyValues to highlight > + omitted (list): Optional; list of keys to omit > """ > for i, kv in enumerate(kv_list): > + key_omitted = kv.key in omitted if omitted else False > written = self.format_kv( > - buf, kv, style_obj=style_obj, highlighted=highlighted > + buf, kv, style_obj=style_obj, highlighted=highlighted, > + omitted=key_omitted > ) > > end = ( > @@ -277,7 +298,7 @@ class FlowFormatter: > style=style_obj.get("default"), > ) > > - def format_kv(self, buf, kv, style_obj, highlighted=None): > + def format_kv(self, buf, kv, style_obj, highlighted=None, omitted=False): > """Format a KeyValue > > A formatted keyvalue has the following parts: > @@ -288,6 +309,7 @@ class FlowFormatter: > kv (KeyValue): The KeyValue to print > style_obj (FlowStyle): The style object to use > highlighted (list): Optional; list of KeyValues to highlight > + omitted(boolean): Whether the value shall be omitted. > > Returns the number of printed characters. > """ > @@ -308,9 +330,14 @@ class FlowFormatter: > buf.append_delim(kv, style_obj.get_delim_style(is_highlighted)) > ret += len(kv.meta.delim) > > - value_style = style_obj.get_value_style(kv, is_highlighted) > - buf.append_value(kv, value_style) # format value > - ret += len(kv.meta.vstring) > + if omitted: > + buf.append_value_omitted(kv) > + ret += len(kv.meta.vstring) > + > + else: > + value_style = style_obj.get_value_style(kv, is_highlighted) > + buf.append_value(kv, value_style) # format value > + ret += len(kv.meta.vstring) > > if kv.meta.end_delim: > buf.append_end_delim(kv, style_obj.get_delim_style(is_highlighted)) > @@ -362,6 +389,13 @@ class FlowBuffer: > """ > raise NotImplementedError > > + def append_value_omitted(self, kv): > + """Append an omitted value. > + Args: > + kv (KeyValue): the KeyValue instance to append > + """ > + raise NotImplementedError > + > def append_extra(self, extra, style): > """Append extra string. > Args: > diff --git a/python/ovs/flowviz/odp/cli.py b/python/ovs/flowviz/odp/cli.py > index 2b82d02fe..36f5b3db2 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.tree import ConsoleTreeProcessor > from ovs.flowviz.process import ( > ConsoleProcessor, > JSONDatapathProcessor, > @@ -54,3 +55,22 @@ def console(opts, heat_map): > ) > proc.process() > proc.print() > + > + > +@datapath.command() > +@click.option( > + "-h", > + "--heat-map", > + is_flag=True, > + default=False, > + show_default=True, > + help="Create heat-map with packet and byte counters", > +) > +@click.pass_obj > +def tree(opts, heat_map): > + """Print the flows in a tree based on the 'recirc_id'.""" > + processor = ConsoleTreeProcessor( > + opts, heat_map=["packets", "bytes"] if heat_map else [] > + ) > + processor.process() > + processor.print() > diff --git a/python/ovs/flowviz/odp/tree.py b/python/ovs/flowviz/odp/tree.py > new file mode 100644 > index 000000000..48b44660d > --- /dev/null > +++ b/python/ovs/flowviz/odp/tree.py > @@ -0,0 +1,512 @@ > +# 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. > + > +import sys > + > +from rich.style import Style > +from rich.console import Group > +from rich.panel import Panel > +from rich.text import Text > +from rich.tree import Tree > + > +from ovs.compat.sortedcontainers import SortedList > +from ovs.flowviz.console import ( > + ConsoleFormatter, > + ConsoleBuffer, > + hash_pallete, > + heat_pallete, > + file_header, > +) > +from ovs.flowviz.process import ( > + FileProcessor, > +) > + > + > +class TreeFlow(object): > + """A flow within a Tree.""" > + > + def __init__(self, flow, filter=None): > + self._flow = flow > + self._visible = True > + if filter: > + self._matches = filter.evaluate(flow) > + else: > + self._matches = True > + > + @property > + def flow(self): > + return self._flow > + > + @property > + def visible(self): > + return self._visible > + > + @visible.setter > + def visible(self, new_visible): > + self._visible = new_visible > + > + @property > + def matches(self): > + return self._matches > + > + > +class FlowBlock(object): > + """A block of flows in a Tree. Flows are arranged together in a block > + if they have the same action. > + """ > + > + def __init__(self, tflow): > + """Create a FlowBlock based on a flow. > + Args: > + flow: TreeFlow > + """ > + self._flows = SortedList([], self.__key) > + self._next_recirc_nodes = SortedList([], key=lambda x: -x.pkts) > + self._actions = tflow.flow.actions_kv > + self._sum_pkts = tflow.flow.info.get("packets") or 0 > + self._visible = False > + > + self._flows.add(tflow) > + > + self._equal_match = [ > + (i, kv) > + for i, kv in enumerate(tflow.flow.match_kv) > + if kv.key not in ["in_port", "recirc_id"] > + ] > + > + in_port = tflow.flow.match.get("in_port") > + self._next_recirc_inport = [ > + (recirc, in_port) for recirc in self._get_next_recirc(tflow.flow) > + ] > + > + @property > + def flows(self): > + return self._flows > + > + @property > + def pkts(self): > + return self._sum_pkts > + > + @property > + def visible(self): > + return self._visible > + > + @property > + def equal_match(self): > + return self._equal_match > + > + @property > + def next_recirc_nodes(self): > + return self._next_recirc_nodes > + > + def add_if_belongs(self, tflow): > + """Add TreeFlow to block if it belongs here.""" > + if not self._belongs(tflow): > + return False > + > + to_del = [] > + for i, (orig_i, kv) in enumerate(self.equal_match): > + if orig_i >= len(tflow.flow.match_kv): > + kv_i = None > + else: > + kv_i = tflow.flow.match_kv[orig_i] > + > + if kv_i != kv: > + to_del.append(i) > + > + for i in sorted(to_del, reverse=True): > + del self.equal_match[i] > + > + self._sum_pkts += tflow.flow.info.get("packets") or 0 > + self._flows.add(tflow) > + return True > + > + def build(self, recirc_nodes): > + """Populates next_recirc_nodes given a dictionary of RecircNode objects > + indexed by recirc_id and in_port. > + """ > + for recirc, in_port in self._next_recirc_inport: > + try: > + self._next_recirc_nodes.add(recirc_nodes[recirc][in_port]) > + except KeyError: > + print( > + f"mising [recirc_id {hex(recirc)} inport {in_port}]. " > + "Flow tree will be incomplete.", > + file=sys.stderr, > + ) > + > + def compute_visible(self): > + """Determines if the block should be visible. > + A FlowBlock is visible if any of its flows is. > + > + If any of the nested RecircNodes is visible, all flows should be > + visible. If not, only the ones that matches should. Guess matches, should be match. > + """ > + nested_recirc_visible = False > + > + for recirc in self._next_recirc_nodes: > + recirc.compute_visible() > + if recirc.visible: > + nested_recirc_visible = True > + > + for tflow in self._flows: > + tflow.visible = True if nested_recirc_visible else tflow.matches > + if tflow.visible: > + self._visible = True > + > + def _belongs(self, tflow): > + if len(tflow.flow.actions_kv) != len(self._actions): > + return False > + return all( > + [a == b for a, b in zip(tflow.flow.actions_kv, self._actions)] > + ) > + > + def __key(self, f): > + return -(f.flow.info.get("packets") or 0) > + > + def _get_next_recirc(self, flow): > + """Get the next recirc_ids from a Flow. > + > + The recirc_id is obtained from actions such as recirc, but also > + complex actions such as check_pkt_len and sample > + Args: > + flow (ODPFlow): flow to get the recirc_id from. > + Returns: > + set of next recirculation ids. > + """ > + > + # Helper function to find a recirc in a dictionary of actions. > + def find_in_list(actions_list): > + recircs = [] > + for item in actions_list: > + (action, value) = next(iter(item.items())) > + if action == "recirc": > + recircs.append(value) > + elif action == "check_pkt_len": > + recircs.extend(find_in_list(value.get("gt"))) > + recircs.extend(find_in_list(value.get("le"))) > + elif action == "clone": > + recircs.extend(find_in_list(value)) > + elif action == "sample": > + recircs.extend(find_in_list(value.get("actions"))) > + return recircs > + > + recircs = [] > + recircs.extend(find_in_list(flow.actions)) > + > + return set(recircs) > + > + > +class RecircNode(object): > + def __init__(self, recirc, in_port, heat_map=[]): > + self._recirc = recirc > + self._in_port = in_port > + self._visible = False > + self._sum_pkts = 0 > + self._heat_map_fields = heat_map > + self._min = dict.fromkeys(self._heat_map_fields, -1) > + self._max = dict.fromkeys(self._heat_map_fields, 0) > + > + self._blocks = [] > + self._sorted_blocks = SortedList([], key=lambda x: -x.pkts) > + > + @property > + def recirc(self): > + return self._recirc > + > + @property > + def in_port(self): > + return self._in_port > + > + @property > + def visible(self): > + return self._visible > + > + @property > + def pkts(self): > + """Returns the blocks sorted by pkts. > + Should not be called before running build().""" > + return self._sum_pkts > + > + @property > + def min(self): > + return self._min > + > + @property > + def max(self): > + return self._max > + > + def visible_blocks(self): > + """Returns visible blocks sorted by pkts. > + Should not be called before running build().""" > + return filter(lambda x: x.visible, self._sorted_blocks) > + > + def add_flow(self, tflow): > + assert tflow.flow.match.get("recirc_id") == self.recirc > + assert tflow.flow.match.get("in_port") == self.in_port > + > + self._sum_pkts += tflow.flow.info.get("packets") or 0 > + > + # Accumulate minimum and maximum values for later use in heat-map. > + for field in self._heat_map_fields: > + val = tflow.flow.info.get(field) > + if self._min[field] == -1 or val < self._min[field]: > + self._min[field] = val > + if val > self._max[field]: > + self._max[field] = val > + > + for b in self._blocks: > + if b.add_if_belongs(tflow): > + return > + > + self._blocks.append(FlowBlock(tflow)) > + > + def build(self, recirc_nodes): > + """Builds the recirculation links of nested blocks. > + > + Args: > + recirc_nodes: Dictionary of RecircNode objects indexed by > + recirc_id and in_port. > + """ > + for block in self._blocks: > + block.build(recirc_nodes) > + self._sorted_blocks.add(block) > + > + def compute_visible(self): > + """Determine if the RecircNode should be visible. > + A RecircNode is visible if any of its blocks is. > + """ > + for block in self._blocks: > + block.compute_visible() > + if block.visible: > + self._visible = True > + > + > +class FlowTree: > + """A Flow tree is a a class that processes datapath flows into a tree based > + on recirculation ids. > + > + Args: > + flows (list[ODPFlow]): Optional, initial list of flows > + heat_map_fields (list[str]): Optional, info fields to calculate > + maximum and minimum values. > + """ > + > + def __init__(self, flows=None, heat_map_fields=[]): > + self._recirc_nodes = {} > + self._all_recirc_nodes = [] > + self._heat_map_fields = heat_map_fields > + if flows: > + for flow in flows: > + self.add(flow) > + > + @property > + def recirc_nodes(self): > + """Recirculation nodes in a double-dictionary. > + First-level key: recirc_id. Second-level key: in_port. > + """ > + return self._recirc_nodes > + > + @property > + def all_recirc_nodes(self): > + """All Recirculation nodes in a list.""" > + return self._all_recirc_nodes > + > + def add(self, flow, filter=None): > + """Add a flow""" > + rid = flow.match.get("recirc_id") or 0 > + in_port = flow.match.get("in_port") or 0 > + > + if not self._recirc_nodes.get(rid): > + self._recirc_nodes[rid] = {} > + > + if not self._recirc_nodes.get(rid).get(in_port): > + node = RecircNode(rid, in_port, heat_map=self._heat_map_fields) > + self._recirc_nodes[rid][in_port] = node > + self._all_recirc_nodes.append(node) > + > + self._recirc_nodes[rid][in_port].add_flow(TreeFlow(flow, filter)) > + > + def build(self): > + """Build the flow tree.""" > + for node in self._all_recirc_nodes: > + node.build(self._recirc_nodes) > + > + # Once recirculation links have been built. Determine what should stay > + # visible recursively starting by recirc_id = 0. > + for _, node in self._recirc_nodes.get(0).items(): > + node.compute_visible() > + > + def min_max(self): > + """Return a dictionary, indexed by the heat_map_fields, of minimum > + and maximum values. > + """ > + min_vals = {field: [] for field in self._heat_map_fields} > + max_vals = {field: [] for field in self._heat_map_fields} > + > + if not self._heat_map_fields: > + return None > + > + for node in self._all_recirc_nodes: > + if not node.visible: > + continue > + for field in self._heat_map_fields: > + min_vals[field].append(node.min[field]) > + max_vals[field].append(node.max[field]) > + > + return { > + field: ( > + min(min_vals[field]) if min_vals[field] else 0, > + max(max_vals[field]) if max_vals[field] else 0, > + ) > + for field in self._heat_map_fields > + } > + > + > +class ConsoleTreeProcessor(FileProcessor): > + def __init__(self, opts, heat_map=[]): > + super().__init__(opts, "odp") > + self.trees = {} > + self.ofconsole = ConsoleFormatter(self.opts) > + self.style = self.ofconsole.style > + self.heat_map = heat_map > + self.tree = None > + self.curr_file = "" > + > + if self.style: > + # Generate a color pallete for recirc ids. > + self.recirc_style_gen = hash_pallete( > + hue=[x / 50 for x in range(0, 50)], > + saturation=[0.7], > + value=[0.8], > + ) > + > + self.style.set_default_value_style(Style(color="grey66")) > + self.style.set_key_style("output", Style(color="green")) > + self.style.set_value_style("output", Style(color="green")) > + self.style.set_value_style("recirc", self.recirc_style_gen) > + self.style.set_value_style("recirc_id", self.recirc_style_gen) > + > + def start_file(self, name, filename): > + self.tree = FlowTree(heat_map_fields=self.heat_map) > + self.curr_file = name > + > + def start_thread(self, name): > + if not self.tree: > + self.tree = FlowTree(heat_map_fields=self.heat_map) > + > + 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): > + for name, tree in self.trees.items(): > + self.ofconsole.console.print("\n") > + self.ofconsole.console.print(file_header(name)) > + > + tree.build() > + if self.style: > + min_max = tree.min_max() > + for field in self.heat_map: > + min_val, max_val = min_max[field] > + self.style.set_value_style( > + field, heat_pallete(min_val, max_val) > + ) > + > + self.print_tree(tree) > + > + def print_tree(self, tree): > + root = Tree("Datapath Flows (logical)") > + # Start by shoing recirc_id = 0 > + for in_port in sorted(tree.recirc_nodes[0].keys()): > + node = tree.recirc_nodes[0][in_port] > + if node.visible: > + self.print_recirc_node(root, node) > + > + self.ofconsole.console.print(root) > + > + def print_recirc_node(self, parent, node): > + if self.ofconsole.style: > + recirc_style = self.recirc_style_gen(hex(node.recirc)) > + else: > + recirc_style = None > + > + node_text = Text( > + "[recirc_id({}) in_port({})]".format( > + hex(node.recirc), node.in_port > + ), > + style=recirc_style, > + ) > + console_node = parent.add( > + Panel.fit(node_text), guide_style=recirc_style > + ) > + > + for block in node.visible_blocks(): > + self.print_block(block, console_node) > + > + def print_block(self, block, parent): > + # Print the flow matches and the statistics. > + flow_text = [] > + 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)): > + omit = omit_rest if i > 0 else omit_first > + buf = ConsoleBuffer(Text()) > + self.ofconsole.format_flow(buf, flow.flow, omitted=omit) > + flow_text.append(buf.text) > + > + # Print the action associated with the block. > + omit = { > + "match": "all", > + "info": "all", > + "ufid": "all", > + "dp_extra_info": "all", > + } > + act_buf = ConsoleBuffer(Text()) > + act_buf.append_extra("actions: ", Style(bold=(self.style is not None))) > + > + self.ofconsole.format_flow(act_buf, block.flows[0].flow, omitted=omit) > + > + flows_node = parent.add( > + Panel(Group(*flow_text)), guide_style=Style(color="default") > + ) > + action_node = flows_node.add( > + Panel.fit( > + act_buf.text, border_style="green" if self.style else "default" > + ), > + guide_style=Style(color="default"), > + ) > + > + # Nested to the action, print the next recirc nodes. > + for node in block.next_recirc_nodes: > + if node.visible: > + self.print_recirc_node(action_node, node) > -- > 2.45.2 > > _______________________________________________ > dev mailing list > dev@openvswitch.org > https://mail.openvswitch.org/mailman/listinfo/ovs-dev
diff --git a/python/automake.mk b/python/automake.mk index 0487494d0..b3fef9bed 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/tree.py \ python/ovs/flowviz/ofp/__init__.py \ python/ovs/flowviz/ofp/cli.py \ python/ovs/flowviz/ofp/html.py \ diff --git a/python/ovs/flow/kv.py b/python/ovs/flow/kv.py index f7d7be0cf..3afbf9fce 100644 --- a/python/ovs/flow/kv.py +++ b/python/ovs/flow/kv.py @@ -67,6 +67,15 @@ class KeyValue(object): def __repr__(self): return "{}('{}')".format(self.__class__.__name__, self) + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.key == other.key and self.value == other.value + else: + return False + + def __ne__(self, other): + return not self.__eq__(other) + class KVDecoders(object): """KVDecoders class is used by KVParser to select how to decode the value diff --git a/python/ovs/flowviz/console.py b/python/ovs/flowviz/console.py index c8a78ec11..ab91512fe 100644 --- a/python/ovs/flowviz/console.py +++ b/python/ovs/flowviz/console.py @@ -13,6 +13,8 @@ # limitations under the License. import colorsys +import itertools +import zlib from rich.console import Console from rich.color import Color @@ -79,6 +81,14 @@ class ConsoleBuffer(FlowBuffer): """ return self._append(kv.meta.vstring, style) + 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, None) + def append_extra(self, extra, style): """Append extra string. Args: @@ -107,20 +117,21 @@ class ConsoleFormatter(FlowFormatter): def style_from_opts(self, opts): return self._style_from_opts(opts, "console", Style) - def print_flow(self, flow, highlighted=None): + def print_flow(self, flow, highlighted=None, omitted=None): """Prints a flow to the console. Args: flow (ovs_dbg.OFPFlow): the flow to print style (dict): Optional; style dictionary to use highlighted (list): Optional; list of KeyValues to highlight + omitted (list): Optional; list of KeyValues to omit """ buf = ConsoleBuffer(Text()) - self.format_flow(buf, flow, highlighted) - self.console.print(buf.text) + self.format_flow(buf, flow, highlighted, omitted) + self.console.print(buf.text, soft_wrap=True) - 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 rich.Text. Args: @@ -128,9 +139,10 @@ class ConsoleFormatter(FlowFormatter): flow (ovs_dbg.OFPFlow): the flow to format style (FlowStyle): Optional; style object to use highlighted (list): Optional; list of KeyValues to highlight + omitted (list): Optional; list of KeyValues to omit """ return super(ConsoleFormatter, self).format_flow( - buf, flow, self.style, highlighted + buf, flow, self.style, highlighted, omitted ) @@ -157,6 +169,25 @@ def heat_pallete(min_value, max_value): return heat +def hash_pallete(hue, saturation, value): + """Generates a color pallete with the cartesian product + of the hsv values provided and returns a callable that assigns a color for + each value hash + """ + HSV_tuples = itertools.product(hue, saturation, value) + RGB_tuples = map(lambda x: colorsys.hsv_to_rgb(*x), HSV_tuples) + styles = [ + Style(color=Color.from_rgb(r * 255, g * 255, b * 255)) + for r, g, b in RGB_tuples + ] + + def get_style(string): + hash_val = zlib.crc32(bytes(str(string), "utf-8")) + return styles[hash_val % len(styles)] + + return get_style + + def default_highlight(): """Generates a default style for highlights.""" return Style(underline=True) diff --git a/python/ovs/flowviz/format.py b/python/ovs/flowviz/format.py index 70af2fa26..67711a92f 100644 --- a/python/ovs/flowviz/format.py +++ b/python/ovs/flowviz/format.py @@ -225,7 +225,8 @@ class FlowFormatter: return FlowStyle({k: style_constructor(**v) for k, v in style.items()}) - def format_flow(self, buf, flow, style_obj=None, highlighted=None): + def format_flow(self, buf, flow, style_obj=None, highlighted=None, + omitted=None): """Formats the flow into the provided buffer. Args: @@ -233,25 +234,41 @@ class FlowFormatter: flow (ovs_dbg.OFPFlow): the flow to format style_obj (FlowStyle): Optional; style to use highlighted (list): Optional; list of KeyValues to highlight + omitted (list): Optional; dict of keys to omit indexed by section + name. """ last_printed_pos = 0 + first = True - if style_obj: + if style_obj or omitted: style_obj = style_obj or FlowStyle() for section in sorted(flow.sections, key=lambda x: x.pos): - buf.append_extra( - flow.orig[last_printed_pos : section.pos], - style=style_obj.get("default"), - ) + section_omitted = (omitted or {}).get(section.name) + if isinstance(section_omitted, str) and \ + section_omitted == "all": + last_printed_pos += section.pos + len(section.string) + continue + + # Do not print leading extra strings (e.g: spaces and commas) + # if it's the first section that gets printed. + if not first: + buf.append_extra( + flow.orig[last_printed_pos : section.pos], + style=style_obj.get("default"), + ) + self.format_kv_list( - buf, section.data, section.string, style_obj, highlighted + buf, section.data, section.string, style_obj, highlighted, + section_omitted ) last_printed_pos = section.pos + len(section.string) + first = False else: # Don't pay the cost of formatting each section one by one. buf.append_extra(flow.orig.strip(), None) - def format_kv_list(self, buf, kv_list, full_str, style_obj, highlighted): + def format_kv_list(self, buf, kv_list, full_str, style_obj, highlighted, + omitted=None): """Format a KeyValue List. Args: @@ -260,10 +277,14 @@ class FlowFormatter: full_str (str): the full string containing all k-v style_obj (FlowStyle): a FlowStyle object to use highlighted (list): Optional; list of KeyValues to highlight + highlighted (list): Optional; list of KeyValues to highlight + omitted (list): Optional; list of keys to omit """ for i, kv in enumerate(kv_list): + key_omitted = kv.key in omitted if omitted else False written = self.format_kv( - buf, kv, style_obj=style_obj, highlighted=highlighted + buf, kv, style_obj=style_obj, highlighted=highlighted, + omitted=key_omitted ) end = ( @@ -277,7 +298,7 @@ class FlowFormatter: style=style_obj.get("default"), ) - def format_kv(self, buf, kv, style_obj, highlighted=None): + def format_kv(self, buf, kv, style_obj, highlighted=None, omitted=False): """Format a KeyValue A formatted keyvalue has the following parts: @@ -288,6 +309,7 @@ class FlowFormatter: kv (KeyValue): The KeyValue to print style_obj (FlowStyle): The style object to use highlighted (list): Optional; list of KeyValues to highlight + omitted(boolean): Whether the value shall be omitted. Returns the number of printed characters. """ @@ -308,9 +330,14 @@ class FlowFormatter: buf.append_delim(kv, style_obj.get_delim_style(is_highlighted)) ret += len(kv.meta.delim) - value_style = style_obj.get_value_style(kv, is_highlighted) - buf.append_value(kv, value_style) # format value - ret += len(kv.meta.vstring) + if omitted: + buf.append_value_omitted(kv) + ret += len(kv.meta.vstring) + + else: + value_style = style_obj.get_value_style(kv, is_highlighted) + buf.append_value(kv, value_style) # format value + ret += len(kv.meta.vstring) if kv.meta.end_delim: buf.append_end_delim(kv, style_obj.get_delim_style(is_highlighted)) @@ -362,6 +389,13 @@ class FlowBuffer: """ raise NotImplementedError + def append_value_omitted(self, kv): + """Append an omitted value. + Args: + kv (KeyValue): the KeyValue instance to append + """ + raise NotImplementedError + def append_extra(self, extra, style): """Append extra string. Args: diff --git a/python/ovs/flowviz/odp/cli.py b/python/ovs/flowviz/odp/cli.py index 2b82d02fe..36f5b3db2 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.tree import ConsoleTreeProcessor from ovs.flowviz.process import ( ConsoleProcessor, JSONDatapathProcessor, @@ -54,3 +55,22 @@ def console(opts, heat_map): ) proc.process() proc.print() + + +@datapath.command() +@click.option( + "-h", + "--heat-map", + is_flag=True, + default=False, + show_default=True, + help="Create heat-map with packet and byte counters", +) +@click.pass_obj +def tree(opts, heat_map): + """Print the flows in a tree based on the 'recirc_id'.""" + processor = ConsoleTreeProcessor( + opts, heat_map=["packets", "bytes"] if heat_map else [] + ) + processor.process() + processor.print() diff --git a/python/ovs/flowviz/odp/tree.py b/python/ovs/flowviz/odp/tree.py new file mode 100644 index 000000000..48b44660d --- /dev/null +++ b/python/ovs/flowviz/odp/tree.py @@ -0,0 +1,512 @@ +# 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. + +import sys + +from rich.style import Style +from rich.console import Group +from rich.panel import Panel +from rich.text import Text +from rich.tree import Tree + +from ovs.compat.sortedcontainers import SortedList +from ovs.flowviz.console import ( + ConsoleFormatter, + ConsoleBuffer, + hash_pallete, + heat_pallete, + file_header, +) +from ovs.flowviz.process import ( + FileProcessor, +) + + +class TreeFlow(object): + """A flow within a Tree.""" + + def __init__(self, flow, filter=None): + self._flow = flow + self._visible = True + if filter: + self._matches = filter.evaluate(flow) + else: + self._matches = True + + @property + def flow(self): + return self._flow + + @property + def visible(self): + return self._visible + + @visible.setter + def visible(self, new_visible): + self._visible = new_visible + + @property + def matches(self): + return self._matches + + +class FlowBlock(object): + """A block of flows in a Tree. Flows are arranged together in a block + if they have the same action. + """ + + def __init__(self, tflow): + """Create a FlowBlock based on a flow. + Args: + flow: TreeFlow + """ + self._flows = SortedList([], self.__key) + self._next_recirc_nodes = SortedList([], key=lambda x: -x.pkts) + self._actions = tflow.flow.actions_kv + self._sum_pkts = tflow.flow.info.get("packets") or 0 + self._visible = False + + self._flows.add(tflow) + + self._equal_match = [ + (i, kv) + for i, kv in enumerate(tflow.flow.match_kv) + if kv.key not in ["in_port", "recirc_id"] + ] + + in_port = tflow.flow.match.get("in_port") + self._next_recirc_inport = [ + (recirc, in_port) for recirc in self._get_next_recirc(tflow.flow) + ] + + @property + def flows(self): + return self._flows + + @property + def pkts(self): + return self._sum_pkts + + @property + def visible(self): + return self._visible + + @property + def equal_match(self): + return self._equal_match + + @property + def next_recirc_nodes(self): + return self._next_recirc_nodes + + def add_if_belongs(self, tflow): + """Add TreeFlow to block if it belongs here.""" + if not self._belongs(tflow): + return False + + to_del = [] + for i, (orig_i, kv) in enumerate(self.equal_match): + if orig_i >= len(tflow.flow.match_kv): + kv_i = None + else: + kv_i = tflow.flow.match_kv[orig_i] + + if kv_i != kv: + to_del.append(i) + + for i in sorted(to_del, reverse=True): + del self.equal_match[i] + + self._sum_pkts += tflow.flow.info.get("packets") or 0 + self._flows.add(tflow) + return True + + def build(self, recirc_nodes): + """Populates next_recirc_nodes given a dictionary of RecircNode objects + indexed by recirc_id and in_port. + """ + for recirc, in_port in self._next_recirc_inport: + try: + self._next_recirc_nodes.add(recirc_nodes[recirc][in_port]) + except KeyError: + print( + f"mising [recirc_id {hex(recirc)} inport {in_port}]. " + "Flow tree will be incomplete.", + file=sys.stderr, + ) + + def compute_visible(self): + """Determines if the block should be visible. + A FlowBlock is visible if any of its flows is. + + If any of the nested RecircNodes is visible, all flows should be + visible. If not, only the ones that matches should. + """ + nested_recirc_visible = False + + for recirc in self._next_recirc_nodes: + recirc.compute_visible() + if recirc.visible: + nested_recirc_visible = True + + for tflow in self._flows: + tflow.visible = True if nested_recirc_visible else tflow.matches + if tflow.visible: + self._visible = True + + def _belongs(self, tflow): + if len(tflow.flow.actions_kv) != len(self._actions): + return False + return all( + [a == b for a, b in zip(tflow.flow.actions_kv, self._actions)] + ) + + def __key(self, f): + return -(f.flow.info.get("packets") or 0) + + def _get_next_recirc(self, flow): + """Get the next recirc_ids from a Flow. + + The recirc_id is obtained from actions such as recirc, but also + complex actions such as check_pkt_len and sample + Args: + flow (ODPFlow): flow to get the recirc_id from. + Returns: + set of next recirculation ids. + """ + + # Helper function to find a recirc in a dictionary of actions. + def find_in_list(actions_list): + recircs = [] + for item in actions_list: + (action, value) = next(iter(item.items())) + if action == "recirc": + recircs.append(value) + elif action == "check_pkt_len": + recircs.extend(find_in_list(value.get("gt"))) + recircs.extend(find_in_list(value.get("le"))) + elif action == "clone": + recircs.extend(find_in_list(value)) + elif action == "sample": + recircs.extend(find_in_list(value.get("actions"))) + return recircs + + recircs = [] + recircs.extend(find_in_list(flow.actions)) + + return set(recircs) + + +class RecircNode(object): + def __init__(self, recirc, in_port, heat_map=[]): + self._recirc = recirc + self._in_port = in_port + self._visible = False + self._sum_pkts = 0 + self._heat_map_fields = heat_map + self._min = dict.fromkeys(self._heat_map_fields, -1) + self._max = dict.fromkeys(self._heat_map_fields, 0) + + self._blocks = [] + self._sorted_blocks = SortedList([], key=lambda x: -x.pkts) + + @property + def recirc(self): + return self._recirc + + @property + def in_port(self): + return self._in_port + + @property + def visible(self): + return self._visible + + @property + def pkts(self): + """Returns the blocks sorted by pkts. + Should not be called before running build().""" + return self._sum_pkts + + @property + def min(self): + return self._min + + @property + def max(self): + return self._max + + def visible_blocks(self): + """Returns visible blocks sorted by pkts. + Should not be called before running build().""" + return filter(lambda x: x.visible, self._sorted_blocks) + + def add_flow(self, tflow): + assert tflow.flow.match.get("recirc_id") == self.recirc + assert tflow.flow.match.get("in_port") == self.in_port + + self._sum_pkts += tflow.flow.info.get("packets") or 0 + + # Accumulate minimum and maximum values for later use in heat-map. + for field in self._heat_map_fields: + val = tflow.flow.info.get(field) + if self._min[field] == -1 or val < self._min[field]: + self._min[field] = val + if val > self._max[field]: + self._max[field] = val + + for b in self._blocks: + if b.add_if_belongs(tflow): + return + + self._blocks.append(FlowBlock(tflow)) + + def build(self, recirc_nodes): + """Builds the recirculation links of nested blocks. + + Args: + recirc_nodes: Dictionary of RecircNode objects indexed by + recirc_id and in_port. + """ + for block in self._blocks: + block.build(recirc_nodes) + self._sorted_blocks.add(block) + + def compute_visible(self): + """Determine if the RecircNode should be visible. + A RecircNode is visible if any of its blocks is. + """ + for block in self._blocks: + block.compute_visible() + if block.visible: + self._visible = True + + +class FlowTree: + """A Flow tree is a a class that processes datapath flows into a tree based + on recirculation ids. + + Args: + flows (list[ODPFlow]): Optional, initial list of flows + heat_map_fields (list[str]): Optional, info fields to calculate + maximum and minimum values. + """ + + def __init__(self, flows=None, heat_map_fields=[]): + self._recirc_nodes = {} + self._all_recirc_nodes = [] + self._heat_map_fields = heat_map_fields + if flows: + for flow in flows: + self.add(flow) + + @property + def recirc_nodes(self): + """Recirculation nodes in a double-dictionary. + First-level key: recirc_id. Second-level key: in_port. + """ + return self._recirc_nodes + + @property + def all_recirc_nodes(self): + """All Recirculation nodes in a list.""" + return self._all_recirc_nodes + + def add(self, flow, filter=None): + """Add a flow""" + rid = flow.match.get("recirc_id") or 0 + in_port = flow.match.get("in_port") or 0 + + if not self._recirc_nodes.get(rid): + self._recirc_nodes[rid] = {} + + if not self._recirc_nodes.get(rid).get(in_port): + node = RecircNode(rid, in_port, heat_map=self._heat_map_fields) + self._recirc_nodes[rid][in_port] = node + self._all_recirc_nodes.append(node) + + self._recirc_nodes[rid][in_port].add_flow(TreeFlow(flow, filter)) + + def build(self): + """Build the flow tree.""" + for node in self._all_recirc_nodes: + node.build(self._recirc_nodes) + + # Once recirculation links have been built. Determine what should stay + # visible recursively starting by recirc_id = 0. + for _, node in self._recirc_nodes.get(0).items(): + node.compute_visible() + + def min_max(self): + """Return a dictionary, indexed by the heat_map_fields, of minimum + and maximum values. + """ + min_vals = {field: [] for field in self._heat_map_fields} + max_vals = {field: [] for field in self._heat_map_fields} + + if not self._heat_map_fields: + return None + + for node in self._all_recirc_nodes: + if not node.visible: + continue + for field in self._heat_map_fields: + min_vals[field].append(node.min[field]) + max_vals[field].append(node.max[field]) + + return { + field: ( + min(min_vals[field]) if min_vals[field] else 0, + max(max_vals[field]) if max_vals[field] else 0, + ) + for field in self._heat_map_fields + } + + +class ConsoleTreeProcessor(FileProcessor): + def __init__(self, opts, heat_map=[]): + super().__init__(opts, "odp") + self.trees = {} + self.ofconsole = ConsoleFormatter(self.opts) + self.style = self.ofconsole.style + self.heat_map = heat_map + self.tree = None + self.curr_file = "" + + if self.style: + # Generate a color pallete for recirc ids. + self.recirc_style_gen = hash_pallete( + hue=[x / 50 for x in range(0, 50)], + saturation=[0.7], + value=[0.8], + ) + + self.style.set_default_value_style(Style(color="grey66")) + self.style.set_key_style("output", Style(color="green")) + self.style.set_value_style("output", Style(color="green")) + self.style.set_value_style("recirc", self.recirc_style_gen) + self.style.set_value_style("recirc_id", self.recirc_style_gen) + + def start_file(self, name, filename): + self.tree = FlowTree(heat_map_fields=self.heat_map) + self.curr_file = name + + def start_thread(self, name): + if not self.tree: + self.tree = FlowTree(heat_map_fields=self.heat_map) + + 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): + for name, tree in self.trees.items(): + self.ofconsole.console.print("\n") + self.ofconsole.console.print(file_header(name)) + + tree.build() + if self.style: + min_max = tree.min_max() + for field in self.heat_map: + min_val, max_val = min_max[field] + self.style.set_value_style( + field, heat_pallete(min_val, max_val) + ) + + self.print_tree(tree) + + def print_tree(self, tree): + root = Tree("Datapath Flows (logical)") + # Start by shoing recirc_id = 0 + for in_port in sorted(tree.recirc_nodes[0].keys()): + node = tree.recirc_nodes[0][in_port] + if node.visible: + self.print_recirc_node(root, node) + + self.ofconsole.console.print(root) + + def print_recirc_node(self, parent, node): + if self.ofconsole.style: + recirc_style = self.recirc_style_gen(hex(node.recirc)) + else: + recirc_style = None + + node_text = Text( + "[recirc_id({}) in_port({})]".format( + hex(node.recirc), node.in_port + ), + style=recirc_style, + ) + console_node = parent.add( + Panel.fit(node_text), guide_style=recirc_style + ) + + for block in node.visible_blocks(): + self.print_block(block, console_node) + + def print_block(self, block, parent): + # Print the flow matches and the statistics. + flow_text = [] + 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)): + omit = omit_rest if i > 0 else omit_first + buf = ConsoleBuffer(Text()) + self.ofconsole.format_flow(buf, flow.flow, omitted=omit) + flow_text.append(buf.text) + + # Print the action associated with the block. + omit = { + "match": "all", + "info": "all", + "ufid": "all", + "dp_extra_info": "all", + } + act_buf = ConsoleBuffer(Text()) + act_buf.append_extra("actions: ", Style(bold=(self.style is not None))) + + self.ofconsole.format_flow(act_buf, block.flows[0].flow, omitted=omit) + + flows_node = parent.add( + Panel(Group(*flow_text)), guide_style=Style(color="default") + ) + action_node = flows_node.add( + Panel.fit( + act_buf.text, border_style="green" if self.style else "default" + ), + guide_style=Style(color="default"), + ) + + # Nested to the action, print the next recirc nodes. + for node in block.next_recirc_nodes: + if node.visible: + self.print_recirc_node(action_node, node)
Datapath flows can be arranged into a "tree"-like structure based on recirculation ids and input ports. A recirculation group is composed of flows sharing the same "recirc_id" and "in_port" match. Within that group, flows are arranged in blocks of flows that have the same action list. Finally, if an action associated with one of this "blocks" contains a "recirc" action, the recirculation group is shown underneath. When filtering, instead of blindly dropping non-matching flows, drop all the "subtrees" that don't have any matching flow. Examples: $ ovs-flowviz -i dpflows.txt --style dark datapath tree | less -R $ ovs-flowviz -i dpflows.txt --filter "output.port=eth0" datapath tree This patch adds the logic to build this structure in a format-agnostic object called FlowTree and adds support for formatting it in the console. Console format supports: - head-maps formatting of statistics - hash-based pallete of recirculation ids: each recirculation id is assigned a unique color to easily follow the sequence of related actions. Signed-off-by: Adrian Moreno <amorenoz@redhat.com> --- python/automake.mk | 1 + python/ovs/flow/kv.py | 9 + python/ovs/flowviz/console.py | 41 ++- python/ovs/flowviz/format.py | 60 +++- python/ovs/flowviz/odp/cli.py | 20 ++ python/ovs/flowviz/odp/tree.py | 512 +++++++++++++++++++++++++++++++++ 6 files changed, 625 insertions(+), 18 deletions(-) create mode 100644 python/ovs/flowviz/odp/tree.py