2
0
mirror of https://github.com/openvswitch/ovs synced 2025-08-31 14:25:26 +00:00

python: ovs: flowviz: Add console formatting.

Add a flow formatting framework and one implementation for console
printing using rich.

The flow formatting framework is a simple set of classes that can be
used to write different flow formatting implementations. It supports
styles to be described by any class, highlighting and config-file based
style definition.

The first flow formatting implementation is also introduced: the
ConsoleFormatter. It uses the an advanced rich-text printing library
[1].

The console printing supports:
- Heatmap: printing the packet/byte statistics of each flow in a color
  that represents its relative size: blue (low) -> red (high).
- Printing a banner with the file name and alias.
- Extensive style definition via config file.

This console format is added to both OpenFlow and Datapath flows.

Examples:
- Highlight drops in datapath flows:
$ ovs-flowviz -i flows.txt --highlight "drop" datapath console
- Quickly detect where most packets are going using heatmap and
  paginated output:
$ ovs-ofctl dump-flows br-int | ovs-flowviz openflow console -h

[1] https://rich.readthedocs.io/en/stable/introduction.html

Acked-by: Eelco Chaudron <echaudro@redhat.com>
Signed-off-by: Adrian Moreno <amorenoz@redhat.com>
Signed-off-by: Ilya Maximets <i.maximets@ovn.org>
This commit is contained in:
Adrian Moreno
2024-09-25 12:52:06 +02:00
committed by Ilya Maximets
parent e3149d4808
commit ec2646dd43
8 changed files with 732 additions and 4 deletions

View File

@@ -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 \

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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: