@@ -65,6 +65,8 @@ ovs_pytests = \
ovs_flowviz = \
python/ovs/flowviz/__init__.py \
+ python/ovs/flowviz/console.py \
+ python/ovs/flowviz/format.py \
python/ovs/flowviz/main.py \
python/ovs/flowviz/odp/__init__.py \
python/ovs/flowviz/odp/cli.py \
new file mode 100644
@@ -0,0 +1,162 @@
+# 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 colorsys
+
+from rich.console import Console
+from rich.color import Color
+from rich.text import Text
+from rich.style import Style
+
+from ovs.flowviz.format import FlowFormatter, FlowBuffer
+
+
+def file_header(name):
+ return Text(f"### {name} ###")
+
+
+class ConsoleBuffer(FlowBuffer):
+ """ConsoleBuffer implements FlowBuffer to provide console-based text
+ formatting based on rich.Text.
+
+ Append functions accept a rich.Style.
+
+ Args:
+ rtext(rich.Text): Optional; text instance to reuse
+ """
+
+ def __init__(self, rtext):
+ self._text = rtext or Text()
+
+ @property
+ def text(self):
+ return self._text
+
+ def _append(self, string, style):
+ """Append to internal text."""
+ return self._text.append(string, style)
+
+ def append_key(self, kv, style):
+ """Append a key.
+ Args:
+ kv (KeyValue): the KeyValue instance to append
+ style (rich.Style): the style to use
+ """
+ return self._append(kv.meta.kstring, style)
+
+ def append_delim(self, kv, style):
+ """Append a delimiter.
+ Args:
+ kv (KeyValue): the KeyValue instance to append
+ style (rich.Style): the style to use
+ """
+ return self._append(kv.meta.delim, style)
+
+ def append_end_delim(self, kv, style):
+ """Append an end delimiter.
+ Args:
+ kv (KeyValue): the KeyValue instance to append
+ style (rich.Style): the style to use
+ """
+ return self._append(kv.meta.end_delim, style)
+
+ def append_value(self, kv, style):
+ """Append a value.
+ Args:
+ kv (KeyValue): the KeyValue instance to append
+ style (rich.Style): the style to use
+ """
+ return self._append(kv.meta.vstring, style)
+
+ def append_extra(self, extra, style):
+ """Append extra string.
+ Args:
+ kv (KeyValue): the KeyValue instance to append
+ style (rich.Style): the style to use
+ """
+ return self._append(extra, style)
+
+
+class ConsoleFormatter(FlowFormatter):
+ """ConsoleFormatter is a FlowFormatter that formats flows into the console
+ using rich.Console.
+
+ Args:
+ console (rich.Console): Optional, an existing console to use
+ max_value_len (int): Optional; max length of the printed values
+ kwargs (dict): Optional; Extra arguments to be passed down to
+ rich.console.Console()
+ """
+
+ def __init__(self, opts=None, console=None, **kwargs):
+ super(ConsoleFormatter, self).__init__()
+ self.style = self.style_from_opts(opts)
+ self.console = console or Console(color_system="256", **kwargs)
+
+ def style_from_opts(self, opts):
+ return self._style_from_opts(opts, "console", Style)
+
+ def print_flow(self, flow, highlighted=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
+ """
+
+ buf = ConsoleBuffer(Text())
+ self.format_flow(buf, flow, highlighted)
+ self.console.print(buf.text)
+
+ def format_flow(self, buf, flow, highlighted=None):
+ """Formats the flow into the provided buffer as a rich.Text.
+
+ Args:
+ buf (FlowBuffer): the flow buffer to append to
+ flow (ovs_dbg.OFPFlow): the flow to format
+ style (FlowStyle): Optional; style object to use
+ highlighted (list): Optional; list of KeyValues to highlight
+ """
+ return super(ConsoleFormatter, self).format_flow(
+ buf, flow, self.style, highlighted
+ )
+
+
+def heat_pallete(min_value, max_value):
+ """Generates a color pallete based on the 5-color heat pallete so that
+ for each value between min and max a color is returned that represents it's
+ relative size.
+ Args:
+ min_value (int): minimum value
+ max_value (int) maximum value
+ """
+ h_min = 0 # red
+ h_max = 220 / 360 # blue
+
+ def heat(value):
+ if max_value == min_value:
+ r, g, b = colorsys.hsv_to_rgb(h_max / 2, 1.0, 1.0)
+ else:
+ normalized = (int(value) - min_value) / (max_value - min_value)
+ hue = ((1 - normalized) + h_min) * (h_max - h_min)
+ r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
+ return Style(color=Color.from_rgb(r * 255, g * 255, b * 255))
+
+ return heat
+
+
+def default_highlight():
+ """Generates a default style for highlights."""
+ return Style(underline=True)
new file mode 100644
@@ -0,0 +1,371 @@
+# 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.
+
+"""Flow formatting framework.
+
+This file defines a simple flow formatting framework. It's comprised of 3
+classes: FlowStyle, FlowFormatter and FlowBuffer.
+
+The FlowStyle arranges opaque style objects in a dictionary that can be queried
+to determine what style a particular key-value should be formatted with.
+That way, a particular implementation can represent its style using their own
+object.
+
+The FlowBuffer is an abstract class and must be derived by particular
+implementations. It should know how to append parts of a flow using a style.
+Only here the type of the style is relevant.
+
+When asked to format a flow, the FlowFormatter will determine which style
+the flow must be formatted with and call FlowBuffer functions with each part
+of the flow and their corresponding style.
+"""
+
+
+class FlowStyle:
+ """A FlowStyle determines the KVStyle to use for each key value in a flow.
+
+ Styles are internally represented by a dictionary.
+ In order to determine the style for a "key", the following items in the
+ dictionary are fetched:
+ - key.highlighted.{key} (if key is found in hightlighted)
+ - key.highlighted (if key is found in hightlighted)
+ - key.{key}
+ - key
+ - default
+
+ In order to determine the style for a "value", the following items in the
+ dictionary are fetched:
+ - value.highlighted.{key} (if key is found in hightlighted)
+ - value.highlighted.type{value.__class__.__name__}
+ - value.highlighted
+ (if key is found in hightlighted)
+ - value.{key}
+ - value.type.{value.__class__.__name__}
+ - value
+ - default
+
+ The actual type of the style object stored for each item above is opaque
+ to this class and it depends on the particular FlowFormatter child class
+ that will handle them. Even callables can be stored, if so they will be
+ called with the value of the field that is to be formatted and the return
+ object will be used as style.
+
+ Additionally, the following style items can be defined:
+ - delim: for delimiters
+ - delim.highlighted: for delimiters of highlighted key-values
+ """
+
+ def __init__(self, initial=None):
+ self._styles = initial if initial is not None else dict()
+
+ def __len__(self):
+ return len(self._styles)
+
+ def set_flag_style(self, kvstyle):
+ self._styles["flag"] = kvstyle
+
+ def set_delim_style(self, kvstyle, highlighted=False):
+ if highlighted:
+ self._styles["delim.highlighted"] = kvstyle
+ else:
+ self._styles["delim"] = kvstyle
+
+ def set_default_key_style(self, kvstyle, highlighted=False):
+ if highlighted:
+ self._styles["key.highlighted"] = kvstyle
+ else:
+ self._styles["key"] = kvstyle
+
+ def set_default_value_style(self, kvstyle, highlighted=False):
+ if highlighted:
+ self._styles["value.highlighted"] = kvstyle
+ else:
+ self._styles["value"] = kvstyle
+
+ def set_key_style(self, key, kvstyle, highlighted=False):
+ if highlighted:
+ self._styles["key.highlighted.{}".format(key)] = kvstyle
+ else:
+ self._styles["key.{}".format(key)] = kvstyle
+
+ def set_value_style(self, key, kvstyle, highlighted=None):
+ if highlighted:
+ self._styles["value.highlighted.{}".format(key)] = kvstyle
+ else:
+ self._styles["value.{}".format(key)] = kvstyle
+
+ def set_value_type_style(self, name, kvstyle, highlighted=None):
+ if highlighted:
+ self._styles["value.highlighted.type.{}".format(name)] = kvstyle
+ else:
+ self._styles["value.type.{}".format(name)] = kvstyle
+
+ def get(self, key):
+ return self._styles.get(key)
+
+ def get_delim_style(self, highlighted=False):
+ delim_style_lookup = ["delim.highlighted"] if highlighted else []
+ delim_style_lookup.extend(["delim", "default"])
+ return next(
+ (
+ self._styles.get(s)
+ for s in delim_style_lookup
+ if self._styles.get(s)
+ ),
+ None,
+ )
+
+ def get_flag_style(self):
+ return self._styles.get("flag") or self._styles.get("default")
+
+ def get_key_style(self, kv, highlighted=False):
+ key = kv.key
+
+ key_style_lookup = (
+ ["key.highlighted.%s" % key, "key.highlighted"]
+ if highlighted
+ else []
+ )
+ key_style_lookup.extend(["key.%s" % key, "key", "default"])
+
+ style = next(
+ (
+ self._styles.get(s)
+ for s in key_style_lookup
+ if self._styles.get(s)
+ ),
+ None,
+ )
+ if callable(style):
+ return style(kv.meta.kstring)
+ return style
+
+ def get_value_style(self, kv, highlighted=False):
+ key = kv.key
+ value_type = kv.value.__class__.__name__.lower()
+ value_style_lookup = (
+ [
+ "value.highlighted.%s" % key,
+ "value.highlighted.type.%s" % value_type,
+ "value.highlighted",
+ ]
+ if highlighted
+ else []
+ )
+ value_style_lookup.extend(
+ [
+ "value.%s" % key,
+ "value.type.%s" % value_type,
+ "value",
+ "default",
+ ]
+ )
+
+ style = next(
+ (
+ self._styles.get(s)
+ for s in value_style_lookup
+ if self._styles.get(s)
+ ),
+ None,
+ )
+ if callable(style):
+ return style(kv.meta.vstring)
+ return style
+
+
+class FlowFormatter:
+ """FlowFormatter is a base class for Flow Formatters."""
+
+ def __init__(self):
+ self._highlighted = list()
+
+ def _style_from_opts(self, opts, opts_key, style_constructor):
+ """Create style object from options.
+
+ Args:
+ opts (dict): Options dictionary
+ opts_key (str): The options style key to extract
+ (e.g: console or html)
+ style_constructor(callable): A callable that creates a derived
+ style object
+ """
+ if not opts or not opts.get("style"):
+ return None
+
+ section_name = ".".join(["styles", opts.get("style")])
+ if section_name not in opts.get("config").sections():
+ return None
+
+ config = opts.get("config")[section_name]
+ style = {}
+ for key in config:
+ (_, console, style_full_key) = key.partition(opts_key + ".")
+ if not console:
+ continue
+
+ (style_key, _, prop) = style_full_key.rpartition(".")
+ if not prop or not style_key:
+ raise Exception("malformed style config: {}".format(key))
+
+ if not style.get(style_key):
+ style[style_key] = {}
+ style[style_key][prop] = config[key]
+
+ return FlowStyle({k: style_constructor(**v) for k, v in style.items()})
+
+ def format_flow(self, buf, flow, style_obj=None, highlighted=None):
+ """Formats the flow into the provided buffer.
+
+ Args:
+ buf (FlowBuffer): the flow buffer to append to
+ flow (ovs_dbg.OFPFlow): the flow to format
+ style_obj (FlowStyle): Optional; style to use
+ highlighted (list): Optional; list of KeyValues to highlight
+ """
+ last_printed_pos = 0
+
+ if style_obj:
+ 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"),
+ )
+ self.format_kv_list(
+ buf, section.data, section.string, style_obj, highlighted
+ )
+ last_printed_pos = section.pos + len(section.string)
+ 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):
+ """Format a KeyValue List.
+
+ Args:
+ buf (FlowBuffer): a FlowBuffer to append formatted KeyValues to
+ kv_list (list[KeyValue]: the KeyValue list to format
+ 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
+ """
+ for i, kv in enumerate(kv_list):
+ written = self.format_kv(
+ buf, kv, style_obj=style_obj, highlighted=highlighted
+ )
+
+ end = (
+ kv_list[i + 1].meta.kpos
+ if i < (len(kv_list) - 1)
+ else len(full_str)
+ )
+
+ buf.append_extra(
+ full_str[(kv.meta.kpos + written) : end].rstrip("\n\r"),
+ style=style_obj.get("default"),
+ )
+
+ def format_kv(self, buf, kv, style_obj, highlighted=None):
+ """Format a KeyValue
+
+ A formatted keyvalue has the following parts:
+ {key}{delim}{value}[{delim}]
+
+ Args:
+ buf (FlowBuffer): buffer to append the KeyValue to
+ kv (KeyValue): The KeyValue to print
+ style_obj (FlowStyle): The style object to use
+ highlighted (list): Optional; list of KeyValues to highlight
+
+ Returns the number of printed characters.
+ """
+ ret = 0
+ key = kv.meta.kstring
+ is_highlighted = (
+ key in [k.key for k in highlighted] if highlighted else False
+ )
+
+ key_style = style_obj.get_key_style(kv, is_highlighted)
+ buf.append_key(kv, key_style) # format value
+ ret += len(key)
+
+ if not kv.meta.vstring:
+ return ret
+
+ if kv.meta.delim not in ("\n", "\t", "\r", ""):
+ 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 kv.meta.end_delim:
+ buf.append_end_delim(kv, style_obj.get_delim_style(is_highlighted))
+ ret += len(kv.meta.end_delim)
+
+ return ret
+
+
+class FlowBuffer:
+ """A FlowBuffer is a base class for format buffers.
+
+ Childs must implement the following methods:
+ append_key(self, kv, style)
+ append_value(self, kv, style)
+ append_delim(self, delim, style)
+ append_end_delim(self, delim, style)
+ append_extra(self, extra, style)
+ """
+
+ def append_key(self, kv, style):
+ """Append a key.
+ Args:
+ kv (KeyValue): the KeyValue instance to append
+ style (Any): the style to use
+ """
+ raise NotImplementedError
+
+ def append_delim(self, kv, style):
+ """Append a delimiter.
+ Args:
+ kv (KeyValue): the KeyValue instance to append
+ style (Any): the style to use
+ """
+ raise NotImplementedError
+
+ def append_end_delim(self, kv, style):
+ """Append an end delimiter.
+ Args:
+ kv (KeyValue): the KeyValue instance to append
+ style (Any): the style to use
+ """
+ raise NotImplementedError
+
+ def append_value(self, kv, style):
+ """Append a value.
+ Args:
+ kv (KeyValue): the KeyValue instance to append
+ style (Any): the style to use
+ """
+ raise NotImplementedError
+
+ def append_extra(self, extra, style):
+ """Append extra string.
+ Args:
+ kv (KeyValue): the KeyValue instance to append
+ style (Any): the style to use
+ """
+ raise NotImplementedError
@@ -12,10 +12,30 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import configparser
import click
import os
from ovs.flow.filter import OFFilter
+from ovs.dirs import PKGDATADIR
+
+_default_config_file = "ovs-flowviz.conf"
+_default_config_path = next(
+ (
+ p
+ for p in [
+ os.path.join(
+ os.getenv("HOME"), ".config", "ovs", _default_config_file
+ ),
+ os.path.join(PKGDATADIR, _default_config_file),
+ os.path.abspath(
+ os.path.join(os.path.dirname(__file__), _default_config_file)
+ ),
+ ]
+ if os.path.exists(p)
+ ),
+ "",
+)
class Options(dict):
@@ -48,6 +68,20 @@ def validate_input(ctx, param, value):
@click.group(
context_settings=dict(help_option_names=["-h", "--help"]),
)
+@click.option(
+ "-c",
+ "--config",
+ help="Use config file",
+ type=click.Path(),
+ default=_default_config_path,
+ show_default=True,
+)
+@click.option(
+ "--style",
+ help="Select style (defined in config file)",
+ default=None,
+ show_default=True,
+)
@click.option(
"-i",
"--input",
@@ -69,8 +103,17 @@ def validate_input(ctx, param, value):
type=str,
show_default=False,
)
+@click.option(
+ "-l",
+ "--highlight",
+ help="Highlight flows that match the filter expression."
+ "Run 'ovs-flowviz filter' for a detailed description of the filtering "
+ "syntax",
+ type=str,
+ show_default=False,
+)
@click.pass_context
-def maincli(ctx, filename, filter):
+def maincli(ctx, config, style, filename, filter, highlight):
"""
OpenvSwitch flow visualization utility.
@@ -86,6 +129,19 @@ def maincli(ctx, filename, filter):
except Exception as e:
raise click.BadParameter("Wrong filter syntax: {}".format(e))
+ if highlight:
+ try:
+ ctx.obj["highlight"] = OFFilter(highlight)
+ except Exception as e:
+ raise click.BadParameter("Wrong filter syntax: {}".format(e))
+
+ config_file = config or _default_config_path
+ parser = configparser.ConfigParser()
+ parser.read(config_file)
+
+ ctx.obj["config"] = parser
+ ctx.obj["style"] = style
+
@maincli.command(hidden=True)
@click.pass_context
@@ -15,7 +15,10 @@
import click
from ovs.flowviz.main import maincli
-from ovs.flowviz.process import JSONDatapathProcessor
+from ovs.flowviz.process import (
+ ConsoleProcessor,
+ JSONDatapathProcessor,
+)
@maincli.group(subcommand_metavar="FORMAT")
@@ -32,3 +35,22 @@ def json(opts):
proc = JSONDatapathProcessor(opts)
proc.process()
print(proc.json_string())
+
+
+@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 console(opts, heat_map):
+ """Print the flows in the console with some style."""
+ proc = ConsoleProcessor(
+ opts, "odp", heat_map=["packets", "bytes"] if heat_map else []
+ )
+ proc.process()
+ proc.print()
@@ -15,7 +15,10 @@
import click
from ovs.flowviz.main import maincli
-from ovs.flowviz.process import JSONOpenFlowProcessor
+from ovs.flowviz.process import (
+ ConsoleProcessor,
+ JSONOpenFlowProcessor,
+)
@maincli.group(subcommand_metavar="FORMAT")
@@ -32,3 +35,24 @@ def json(opts):
proc = JSONOpenFlowProcessor(opts)
proc.process()
print(proc.json_string())
+
+
+@openflow.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 console(opts, heat_map):
+ """Print the flows in the console with some style."""
+ proc = ConsoleProcessor(
+ opts,
+ "ofp",
+ heat_map=["n_packets", "n_bytes"] if heat_map else [],
+ )
+ proc.process()
+ proc.print()
@@ -20,6 +20,14 @@ from ovs.flow.decoders import FlowEncoder
from ovs.flow.odp import ODPFlow
from ovs.flow.ofp import OFPFlow
+from ovs.flowviz.console import (
+ ConsoleFormatter,
+ default_highlight,
+ file_header,
+ heat_pallete,
+)
+from ovs.flowviz.format import FlowStyle
+
class FileProcessor(object):
"""Base class for file-based Flow processing. It is able to create flows
@@ -253,3 +261,84 @@ class JSONDatapathProcessor(FileProcessor):
return json.dumps(
thread_data(next(iter(self.data.values()))), **opts
)
+
+
+class ConsoleProcessor(FileProcessor):
+ """A generic Console Processor that prints flows into the console"""
+
+ def __init__(self, opts, flow_type, heat_map=[]):
+ super().__init__(opts, flow_type)
+ self.heat_map = heat_map
+ self.console = ConsoleFormatter(opts)
+ if not self.console.style and self.opts.get("highlight"):
+ # Add some style to highlights or else they won't be seen.
+ self.console.style = FlowStyle()
+ self.console.style.set_default_value_style(
+ default_highlight(), True
+ )
+ self.console.style.set_default_key_style(default_highlight(), True)
+
+ self.flows = dict() # Dict of flow-lists, one per file and thread.
+ self.min_max = dict() # Used for heat-map calculation.
+ self.curr_file = None
+ self.flows_list = None
+
+ def _init_list(self):
+ self.flows_list = list()
+ if len(self.heat_map) > 0:
+ self.min = [-1] * len(self.heat_map)
+ self.max = [0] * len(self.heat_map)
+
+ def _save_list(self, name):
+ if self.flows_list:
+ self.flows[name] = self.flows_list
+ self.flows_list = None
+ if len(self.heat_map) > 0:
+ self.min_max[name] = (self.min, self.max)
+
+ def start_file(self, name, filename):
+ self._init_list()
+ self.curr_file = name
+
+ def start_thread(self, name):
+ if not self.flows_list:
+ self._init_list()
+
+ def stop_thread(self, name):
+ full_name = self.curr_file + f" ({name})"
+ self._save_list(full_name)
+
+ def stop_file(self, name, filename):
+ self._save_list(name)
+
+ def process_flow(self, flow, name):
+ # Running calculation of min and max values for all the fields that
+ # take place in the heatmap.
+ for i, field in enumerate(self.heat_map):
+ val = flow.info.get(field)
+ if self.min[i] == -1 or val < self.min[i]:
+ self.min[i] = val
+ if val > self.max[i]:
+ self.max[i] = val
+
+ self.flows_list.append(flow)
+
+ def print(self):
+ for name, flows in self.flows.items():
+ self.console.console.print("\n")
+ self.console.console.print(file_header(name))
+
+ if len(self.heat_map) > 0 and len(self.flows) > 0:
+ for i, field in enumerate(self.heat_map):
+ (min_val, max_val) = self.min_max[name][i]
+ self.console.style.set_value_style(
+ field, heat_pallete(min_val, max_val)
+ )
+
+ for flow in flows:
+ high = None
+ if self.opts.get("highlight"):
+ result = self.opts.get("highlight").evaluate(flow)
+ if result:
+ high = result.kv
+ self.console.print_flow(flow, high)
@@ -105,9 +105,11 @@ setup_args = dict(
extras_require={':sys_platform == "win32"': ['pywin32 >= 1.0'],
'dns': ['unbound'],
'flow': flow_extras_require,
- 'flowviz': [*flow_extras_require, 'click'],
+ 'flowviz':
+ [*flow_extras_require, 'click', 'rich'],
},
scripts=["ovs/flowviz/ovs-flowviz"],
+ include_package_data=True,
)
try: