X-Git-Url: https://gerrit.fd.io/r/gitweb?a=blobdiff_plain;f=csit.infra.dash%2Fapp%2Fcdash%2Fcomparisons%2Flayout.py;h=3aa32399cff0516b43719f9e0d48c8f8810fbf0c;hb=230044632667a3eb7794218a6ba3e2fa2c9b71b4;hp=452afad1af9e296b6115969bb21236a11525d5ba;hpb=a7ed9061afe084648969a669f0c38bf567583a08;p=csit.git diff --git a/csit.infra.dash/app/cdash/comparisons/layout.py b/csit.infra.dash/app/cdash/comparisons/layout.py index 452afad1af..3aa32399cf 100644 --- a/csit.infra.dash/app/cdash/comparisons/layout.py +++ b/csit.infra.dash/app/cdash/comparisons/layout.py @@ -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