C-Dash: Add detailed views to comparison tables
[csit.git] / csit.infra.dash / app / cdash / comparisons / layout.py
index 452afad..3aa3239 100644 (file)
@@ -1,4 +1,4 @@
-# Copyright (c) 2023 Cisco and/or its affiliates.
+# Copyright (c) 2024 Cisco and/or its affiliates.
 # 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:
@@ -14,6 +14,8 @@
 """Plotly Dash HTML layout override.
 """
 
+
+import logging
 import pandas as pd
 import dash_bootstrap_components as dbc
 
@@ -23,13 +25,17 @@ from dash import Input, Output, State
 from dash.exceptions import PreventUpdate
 from dash.dash_table.Format import Format, Scheme
 from ast import literal_eval
+from yaml import load, FullLoader, YAMLError
+from copy import deepcopy
 
 from ..utils.constants import Constants as C
 from ..utils.control_panel import ControlPanel
 from ..utils.trigger import Trigger
 from ..utils.url_processing import url_decode
-from ..utils.utils import generate_options, gen_new_url
-from .tables import comparison_table, filter_table_data
+from ..utils.utils import generate_options, gen_new_url, navbar_report, \
+    filter_table_data, sort_table_data, show_iterative_graph_data, show_tooltip
+from .tables import comparison_table
+from ..report.graphs import graph_iterative
 
 
 # Control panel partameters and their default values.
@@ -53,7 +59,8 @@ CP_PARAMS = {
     "cmp-val-opt": list(),
     "cmp-val-dis": True,
     "cmp-val-val": str(),
-    "normalize-val": list()
+    "normalize-val": list(),
+    "outliers-val": list()
 }
 
 # List of comparable parameters.
@@ -74,26 +81,38 @@ class Layout:
             self,
             app: Flask,
             data_iterative: pd.DataFrame,
-            html_layout_file: str
+            html_layout_file: str,
+            graph_layout_file: str,
+            tooltip_file: str
         ) -> None:
         """Initialization:
         - save the input parameters,
         - prepare data for the control panel,
         - read HTML layout file,
+        - read graph layout file,
+        - read tooltips from the tooltip file.
 
         :param app: Flask application running the dash application.
         :param data_iterative: Iterative data to be used in comparison tables.
         :param html_layout_file: Path and name of the file specifying the HTML
             layout of the dash application.
+        :param tooltip_file: Path and name of the yaml file specifying the
+            tooltips.
+        :param graph_layout_file: Path and name of the file with layout of
+            plot.ly graphs.
         :type app: Flask
         :type data_iterative: pandas.DataFrame
         :type html_layout_file: str
+        :type graph_layout_file: str
+        :type tooltip_file: str
         """
 
         # Inputs
         self._app = app
-        self._html_layout_file = html_layout_file
         self._data = data_iterative
+        self._html_layout_file = html_layout_file
+        self._graph_layout_file = graph_layout_file
+        self._tooltip_file = tooltip_file
 
         # Get structure of tests:
         tbs = dict()
@@ -164,6 +183,33 @@ class Layout:
                 f"Not possible to open the file {self._html_layout_file}\n{err}"
             )
 
+        try:
+            with open(self._graph_layout_file, "r") as file_read:
+                self._graph_layout = load(file_read, Loader=FullLoader)
+        except IOError as err:
+            raise RuntimeError(
+                f"Not possible to open the file {self._graph_layout_file}\n"
+                f"{err}"
+            )
+        except YAMLError as err:
+            raise RuntimeError(
+                f"An error occurred while parsing the specification file "
+                f"{self._graph_layout_file}\n{err}"
+            )
+
+        try:
+            with open(self._tooltip_file, "r") as file_read:
+                self._tooltips = load(file_read, Loader=FullLoader)
+        except IOError as err:
+            logging.warning(
+                f"Not possible to open the file {self._tooltip_file}\n{err}"
+            )
+        except YAMLError as err:
+            logging.warning(
+                f"An error occurred while parsing the specification file "
+                f"{self._tooltip_file}\n{err}"
+            )
+
         # Callbacks:
         if self._app is not None and hasattr(self, "callbacks"):
             self.callbacks(self._app)
@@ -194,9 +240,7 @@ class Layout:
                     dbc.Row(
                         id="row-navbar",
                         class_name="g-0",
-                        children=[
-                            self._add_navbar()
-                        ]
+                        children=[navbar_report((False, True, False, False)), ]
                     ),
                     dbc.Row(
                         id="row-main",
@@ -210,6 +254,43 @@ class Layout:
                             self._add_ctrl_col(),
                             self._add_plotting_col()
                         ]
+                    ),
+                    dbc.Spinner(
+                        dbc.Offcanvas(
+                            class_name="w-75",
+                            id="offcanvas-details",
+                            title="Test Details",
+                            placement="end",
+                            is_open=False,
+                            children=[]
+                        ),
+                        delay_show=C.SPINNER_DELAY
+                    ),
+                    dbc.Spinner(
+                        dbc.Offcanvas(
+                            class_name="w-50",
+                            id="offcanvas-metadata",
+                            title="Detailed Information",
+                            placement="end",
+                            is_open=False,
+                            children=[
+                                dbc.Row(id="metadata-tput-lat"),
+                                dbc.Row(id="metadata-hdrh-graph")
+                            ]
+                        ),
+                        delay_show=C.SPINNER_DELAY
+                    ),
+                    dbc.Offcanvas(
+                        class_name="w-75",
+                        id="offcanvas-documentation",
+                        title="Documentation",
+                        placement="end",
+                        is_open=False,
+                        children=html.Iframe(
+                            src=C.URL_DOC_REL_NOTES,
+                            width="100%",
+                            height="100%"
+                        )
                     )
                 ]
             )
@@ -226,31 +307,6 @@ class Layout:
                 ]
             )
 
-    def _add_navbar(self):
-        """Add nav element with navigation panel. It is placed on the top.
-
-        :returns: Navigation bar.
-        :rtype: dbc.NavbarSimple
-        """
-        return dbc.NavbarSimple(
-            id="navbarsimple-main",
-            children=[
-                dbc.NavItem(
-                    dbc.NavLink(
-                        C.COMP_TITLE,
-                        disabled=True,
-                        external_link=True,
-                        href="#"
-                    )
-                )
-            ],
-            brand=C.BRAND,
-            brand_href="/",
-            brand_external_link=True,
-            class_name="p-2",
-            fluid=True
-        )
-
     def _add_ctrl_col(self) -> dbc.Col:
         """Add column with controls. It is placed on the left side.
 
@@ -301,7 +357,9 @@ class Layout:
                 children=[
                     dbc.InputGroup(
                         [
-                            dbc.InputGroupText("DUT"),
+                            dbc.InputGroupText(
+                                show_tooltip(self._tooltips, "help-dut", "DUT")
+                            ),
                             dbc.Select(
                                 id={"type": "ctrl-dd", "index": "dut"},
                                 placeholder="Select a Device under Test...",
@@ -323,7 +381,11 @@ class Layout:
                 children=[
                     dbc.InputGroup(
                         [
-                            dbc.InputGroupText("CSIT and DUT Version"),
+                            dbc.InputGroupText(show_tooltip(
+                                self._tooltips,
+                                "help-csit-dut",
+                                "CSIT and DUT Version"
+                            )),
                             dbc.Select(
                                 id={"type": "ctrl-dd", "index": "dutver"},
                                 placeholder="Select a CSIT and DUT Version...")
@@ -337,7 +399,11 @@ class Layout:
                 children=[
                     dbc.InputGroup(
                         [
-                            dbc.InputGroupText("Infra"),
+                            dbc.InputGroupText(show_tooltip(
+                                self._tooltips,
+                                "help-infra",
+                                "Infra"
+                            )),
                             dbc.Select(
                                 id={"type": "ctrl-dd", "index": "infra"},
                                 placeholder=\
@@ -353,7 +419,11 @@ class Layout:
                 children=[
                     dbc.InputGroup(
                         [
-                            dbc.InputGroupText("Frame Size"),
+                            dbc.InputGroupText(show_tooltip(
+                                self._tooltips,
+                                "help-framesize",
+                                "Frame Size"
+                            )),
                             dbc.Checklist(
                                 id={"type": "ctrl-cl", "index": "frmsize"},
                                 inline=True,
@@ -370,7 +440,11 @@ class Layout:
                 children=[
                     dbc.InputGroup(
                         [
-                            dbc.InputGroupText("Number of Cores"),
+                            dbc.InputGroupText(show_tooltip(
+                                self._tooltips,
+                                "help-cores",
+                                "Number of Cores"
+                            )),
                             dbc.Checklist(
                                 id={"type": "ctrl-cl", "index": "core"},
                                 inline=True,
@@ -387,7 +461,11 @@ class Layout:
                 children=[
                     dbc.InputGroup(
                         [
-                            dbc.InputGroupText("Measurement"),
+                            dbc.InputGroupText(show_tooltip(
+                                self._tooltips,
+                                "help-measurement",
+                                "Measurement"
+                            )),
                             dbc.Checklist(
                                 id={"type": "ctrl-cl", "index": "ttype"},
                                 inline=True,
@@ -407,7 +485,11 @@ class Layout:
                 children=[
                     dbc.InputGroup(
                         [
-                            dbc.InputGroupText("Parameter"),
+                            dbc.InputGroupText(show_tooltip(
+                                self._tooltips,
+                                "help-cmp-parameter",
+                                "Parameter"
+                            )),
                             dbc.Select(
                                 id={"type": "ctrl-dd", "index": "cmpprm"},
                                 placeholder="Select a Parameter..."
@@ -422,7 +504,11 @@ class Layout:
                 children=[
                     dbc.InputGroup(
                         [
-                            dbc.InputGroupText("Value"),
+                            dbc.InputGroupText(show_tooltip(
+                                self._tooltips,
+                                "help-cmp-value",
+                                "Value"
+                            )),
                             dbc.Select(
                                 id={"type": "ctrl-dd", "index": "cmpval"},
                                 placeholder="Select a Value..."
@@ -434,21 +520,33 @@ class Layout:
             )
         ]
 
-        normalize = [
+        processing = [
             dbc.Row(
                 class_name="g-0 p-1",
                 children=[
                     dbc.InputGroup(
-                        dbc.Checklist(
-                            id="normalize",
-                            options=[{
-                                "value": "normalize",
-                                "label": "Normalize to 2GHz CPU frequency"
-                            }],
-                            value=[],
-                            inline=True,
-                            class_name="ms-2"
-                        ),
+                        children = [
+                            dbc.Checklist(
+                                id="normalize",
+                                options=[{
+                                    "value": "normalize",
+                                    "label": "Normalize to 2GHz CPU frequency"
+                                }],
+                                value=[],
+                                inline=True,
+                                class_name="ms-2"
+                            ),
+                            dbc.Checklist(
+                                id="outliers",
+                                options=[{
+                                    "value": "outliers",
+                                    "label": "Remove Extreme Outliers"
+                                }],
+                                value=[],
+                                inline=True,
+                                class_name="ms-2"
+                            )
+                        ],
                         style={"align-items": "center"},
                         size="sm"
                     )
@@ -493,10 +591,10 @@ class Layout:
                 dbc.Card(
                     [
                         dbc.CardHeader(
-                            html.H5("Normalization")
+                            html.H5("Data Manipulations")
                         ),
                         dbc.CardBody(
-                            children=normalize,
+                            children=processing,
                             class_name="g-0 p-0"
                         )
                     ],
@@ -515,7 +613,7 @@ class Layout:
         ) -> list:
         """Generate the plotting area with all its content.
 
-        :param title: The title of the comparison table..
+        :param title: The title of the comparison table.
         :param table: Comparison table to be displayed.
         :param url: URL to be displayed in the modal window.
         :type title: str
@@ -575,7 +673,7 @@ class Layout:
                             editable=False,
                             filter_action="custom",
                             filter_query="",
-                            sort_action="native",
+                            sort_action="custom",
                             sort_mode="multi",
                             selected_columns=[],
                             selected_rows=[],
@@ -617,7 +715,18 @@ class Layout:
                             ),
                             dbc.Button(
                                 id="plot-btn-download",
-                                children="Download Data",
+                                children="Download Table",
+                                class_name="me-1",
+                                color="info",
+                                style={
+                                    "text-transform": "none",
+                                    "padding": "0rem 1rem"
+                                }
+                            ),
+                            dcc.Download(id="download-iterative-data"),
+                            dbc.Button(
+                                id="plot-btn-download-raw",
+                                children="Download Raw Data",
                                 class_name="me-1",
                                 color="info",
                                 style={
@@ -625,7 +734,7 @@ class Layout:
                                     "padding": "0rem 1rem"
                                 }
                             ),
-                            dcc.Download(id="download-iterative-data")
+                            dcc.Download(id="download-raw-data")
                         ],
                         className=\
                             "d-grid gap-0 d-md-flex justify-content-md-end"
@@ -673,17 +782,22 @@ class Layout:
                 Output({"type": "ctrl-dd", "index": "cmpval"}, "options"),
                 Output({"type": "ctrl-dd", "index": "cmpval"}, "disabled"),
                 Output({"type": "ctrl-dd", "index": "cmpval"}, "value"),
-                Output("normalize", "value")
+                Output("normalize", "value"),
+                Output("outliers", "value")
             ],
             [
                 State("store-control-panel", "data"),
                 State("store-selected", "data"),
-                State("store-table-data", "data")
+                State("store-table-data", "data"),
+                State("store-filtered-table-data", "data"),
+                State({"type": "table", "index": ALL}, "data")
             ],
             [
                 Input("url", "href"),
                 Input("normalize", "value"),
+                Input("outliers", "value"),
                 Input({"type": "table", "index": ALL}, "filter_query"),
+                Input({"type": "table", "index": ALL}, "sort_by"),
                 Input({"type": "ctrl-dd", "index": ALL}, "value"),
                 Input({"type": "ctrl-cl", "index": ALL}, "value"),
                 Input({"type": "ctrl-btn", "index": ALL}, "n_clicks")
@@ -693,9 +807,11 @@ class Layout:
                 control_panel: dict,
                 selected: dict,
                 store_table_data: list,
+                filtered_data: list,
+                table_data: list,
                 href: str,
                 normalize: list,
-                table_filter: str,
+                outliers: bool,
                 *_
             ) -> tuple:
             """Update the application when the event is detected.
@@ -722,8 +838,6 @@ class Layout:
 
             on_draw = False
             plotting_area = no_update
-            table_data = list()
-            filtered_data = None
 
             trigger = Trigger(callback_context.triggered)
             if trigger.type == "url" and url_params:
@@ -733,11 +847,15 @@ class Layout:
                     r_sel = selected["reference"]["selection"]
                     c_sel = selected["compare"]
                     normalize = literal_eval(url_params["norm"][0])
+                    try:  # Necessary for backward compatibility
+                        outliers = literal_eval(url_params["outliers"][0])
+                    except (KeyError, IndexError, AttributeError):
+                        outliers = list()
                     process_url = bool(
                         (selected["reference"]["set"] == True) and
                         (c_sel["set"] == True)
                     )
-                except (KeyError, IndexError):
+                except (KeyError, IndexError, AttributeError):
                     pass
                 if process_url:
                     ctrl_panel.set({
@@ -767,7 +885,8 @@ class Layout:
                                 [r_sel["infra"]]["ttype"]
                         ),
                         "ttype-val": r_sel["ttype"],
-                        "normalize-val": normalize
+                        "normalize-val": normalize,
+                        "outliers-val": outliers
                     })
                     opts = list()
                     for itm, label in CMP_PARAMS.items():
@@ -796,6 +915,9 @@ class Layout:
             elif trigger.type == "normalize":
                 ctrl_panel.set({"normalize-val": normalize})
                 on_draw = True
+            elif trigger.type == "outliers":
+                ctrl_panel.set({"outliers-val": outliers})
+                on_draw = True
             elif trigger.type == "ctrl-dd":
                 if trigger.idx == "dut":
                     try:
@@ -946,25 +1068,43 @@ class Layout:
                         "cmp-val-val": str()
                     })
             elif trigger.type == "table" and trigger.idx == "comparison":
-                table_data = filter_table_data(
-                    store_table_data,
-                    table_filter[0]
-                )
-                filtered_data = table_data
-                table_data = [table_data, ]
+                if trigger.parameter == "filter_query":
+                    filtered_data = filter_table_data(
+                        store_table_data,
+                        trigger.value
+                    )
+                elif trigger.parameter == "sort_by":
+                    filtered_data = sort_table_data(
+                        store_table_data,
+                        trigger.value
+                    )
+                table_data = [filtered_data, ]
 
             if all((on_draw, selected["reference"]["set"],
                     selected["compare"]["set"], )):
-                title, table = comparison_table(self._data, selected, normalize)
+                title, table = comparison_table(
+                    data=self._data,
+                    selected=selected,
+                    normalize=normalize,
+                    format="html",
+                    remove_outliers=outliers
+                )
                 plotting_area = self._get_plotting_area(
                     title=title,
                     table=table,
                     url=gen_new_url(
                         parsed_url,
-                        params={"selected": selected, "norm": normalize}
+                        params={
+                            "selected": selected,
+                            "norm": normalize,
+                            "outliers": outliers
+                        }
                     )
                 )
                 store_table_data = table.to_dict("records")
+                filtered_data = store_table_data
+                if table_data:
+                    table_data = [store_table_data, ]
 
             ret_val = [
                 ctrl_panel.panel,
@@ -1014,10 +1154,186 @@ class Layout:
 
             if not table_data:
                 raise PreventUpdate
-            
+
             if filtered_table_data:
                 table = pd.DataFrame.from_records(filtered_table_data)
             else:
                 table = pd.DataFrame.from_records(table_data)
 
             return dcc.send_data_frame(table.to_csv, C.COMP_DOWNLOAD_FILE_NAME)
+
+        @app.callback(
+            Output("download-raw-data", "data"),
+            State("store-selected", "data"),
+            Input("plot-btn-download-raw", "n_clicks"),
+            prevent_initial_call=True
+        )
+        def _download_raw_comparison_data(selected: dict, _: int) -> dict:
+            """Download the data.
+
+            :param selected: Selected tests.
+            :type selected: dict
+            :returns: dict of data frame content (base64 encoded) and meta data
+                used by the Download component.
+            :rtype: dict
+            """
+
+            if not selected:
+                raise PreventUpdate
+
+            _, table = comparison_table(
+                    data=self._data,
+                    selected=selected,
+                    normalize=False,
+                    remove_outliers=False,
+                    raw_data=True
+                )
+
+            return dcc.send_data_frame(
+                table.dropna(how="all", axis=1).to_csv,
+                f"raw_{C.COMP_DOWNLOAD_FILE_NAME}"
+            )
+
+        @app.callback(
+            Output("offcanvas-documentation", "is_open"),
+            Input("btn-documentation", "n_clicks"),
+            State("offcanvas-documentation", "is_open")
+        )
+        def toggle_offcanvas_documentation(n_clicks, is_open):
+            if n_clicks:
+                return not is_open
+            return is_open
+
+        @app.callback(
+            Output("offcanvas-details", "is_open"),
+            Output("offcanvas-details", "children"),
+            State("store-selected", "data"),
+            State("store-filtered-table-data", "data"),
+            State("normalize", "value"),
+            State("outliers", "value"),
+            Input({"type": "table", "index": ALL}, "active_cell"),
+            prevent_initial_call=True
+        )
+        def show_test_data(cp_sel, table, normalize, outliers, *_):
+            """Show offcanvas with graphs and tables based on selected test(s).
+            """
+
+            trigger = Trigger(callback_context.triggered)
+            if not all((trigger.value, cp_sel["reference"]["set"], \
+                        cp_sel["compare"]["set"])):
+                raise PreventUpdate
+
+            try:
+                test_name = pd.DataFrame.from_records(table).\
+                    iloc[[trigger.value["row"]]]["Test Name"].iloc[0]
+                dut = cp_sel["reference"]["selection"]["dut"]
+                rls, dutver = cp_sel["reference"]["selection"]["dutver"].\
+                    split("-", 1)
+                phy = cp_sel["reference"]["selection"]["infra"]
+                framesize, core, test_id = test_name.split("-", 2)
+                test, ttype = test_id.rsplit("-", 1)
+                ttype = "pdr" if ttype == "latency" else ttype
+                l_phy = phy.split("-")
+                tb = "-".join(l_phy[:2])
+                nic = l_phy[2]
+                stype = "ndrpdr" if ttype in ("ndr", "pdr") else ttype
+            except(KeyError, IndexError, AttributeError, ValueError):
+                raise PreventUpdate
+
+            df = pd.DataFrame(self._data.loc[(
+                    (self._data["dut_type"] == dut) &
+                    (self._data["dut_version"] == dutver) &
+                    (self._data["release"] == rls)
+                )])
+            df = df[df.job.str.endswith(tb)]
+            df = df[df.test_id.str.contains(
+                f"{nic}.*{test}-{stype}", regex=True
+            )]
+            if df.empty:
+                raise PreventUpdate
+
+            l_test_id = df["test_id"].iloc[0].split(".")
+            area = ".".join(l_test_id[3:-2])
+
+            r_sel = {
+                "id": f"{test}-{ttype}",
+                "rls": rls,
+                "dut": dut,
+                "dutver": dutver,
+                "phy": phy,
+                "area": area,
+                "test": test,
+                "framesize": framesize,
+                "core": core,
+                "testtype": ttype
+            }
+
+            c_sel = deepcopy(r_sel)
+            param = cp_sel["compare"]["parameter"]
+            val = cp_sel["compare"]["value"].lower()
+            if param == "dutver":
+                c_sel["rls"], c_sel["dutver"] = val.split("-", 1)
+            elif param == "ttype":
+                c_sel["id"] = f"{test}-{val}"
+                c_sel["testtype"] = val
+            elif param == "infra":
+                c_sel["phy"] = val
+            else:
+                c_sel[param] = val
+
+            r_sel["id"] = "-".join(
+                (r_sel["phy"], r_sel["framesize"], r_sel["core"], r_sel["id"])
+            )
+            c_sel["id"] = "-".join(
+                (c_sel["phy"], c_sel["framesize"], c_sel["core"], c_sel["id"])
+            )
+            selected = [r_sel, c_sel]
+
+            indexes = ("tput", "bandwidth", "lat")
+            graphs = graph_iterative(
+                self._data,
+                selected,
+                self._graph_layout,
+                bool(normalize),
+                bool(outliers)
+            )
+            cols = list()
+            for graph, idx in zip(graphs, indexes):
+                if graph:
+                    cols.append(dbc.Col(dcc.Graph(
+                        figure=graph,
+                        id={"type": "graph-iter", "index": idx},
+                    )))
+            if not cols:
+                cols="No data."
+            ret_val = [
+                dbc.Row(
+                    class_name="g-0 p-0",
+                    children=dbc.Alert(test, color="info"),
+                ),
+                dbc.Row(class_name="g-0 p-0", children=cols)
+            ]
+
+            return True, ret_val
+
+        @app.callback(
+            Output("metadata-tput-lat", "children"),
+            Output("metadata-hdrh-graph", "children"),
+            Output("offcanvas-metadata", "is_open"),
+            Input({"type": "graph-iter", "index": ALL}, "clickData"),
+            prevent_initial_call=True
+        )
+        def _show_metadata_from_graph(iter_data: dict) -> tuple:
+            """Generates the data for the offcanvas displayed when a particular
+            point in a graph is clicked on.
+            """
+
+            trigger = Trigger(callback_context.triggered)
+            if not trigger.value:
+                raise PreventUpdate
+
+            if trigger.type == "graph-iter":
+                return show_iterative_graph_data(
+                    trigger, iter_data, self._graph_layout)
+            else:
+                raise PreventUpdate