1 # Copyright (c) 2024 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
6 # http://www.apache.org/licenses/LICENSE-2.0
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
14 """Plotly Dash HTML layout override.
20 import dash_bootstrap_components as dbc
22 from flask import Flask
23 from dash import dcc, html, dash_table, callback_context, no_update, ALL
24 from dash import Input, Output, State
25 from dash.exceptions import PreventUpdate
26 from dash.dash_table.Format import Format, Scheme
27 from ast import literal_eval
28 from yaml import load, FullLoader, YAMLError
30 from ..utils.constants import Constants as C
31 from ..utils.control_panel import ControlPanel
32 from ..utils.trigger import Trigger
33 from ..utils.url_processing import url_decode
34 from ..utils.utils import generate_options, gen_new_url, navbar_report, \
35 filter_table_data, show_tooltip
36 from .tables import comparison_table
39 # Control panel partameters and their default values.
50 "frmsize-opt": list(),
51 "frmsize-val": list(),
54 "cmp-par-opt": list(),
57 "cmp-val-opt": list(),
60 "normalize-val": list(),
61 "outliers-val": list()
64 # List of comparable parameters.
66 "dutver": "Release and Version",
67 "infra": "Infrastructure",
68 "frmsize": "Frame Size",
69 "core": "Number of Cores",
70 "ttype": "Measurement"
75 """The layout of the dash app and the callbacks.
81 data_iterative: pd.DataFrame,
82 html_layout_file: str,
86 - save the input parameters,
87 - prepare data for the control panel,
88 - read HTML layout file,
90 :param app: Flask application running the dash application.
91 :param data_iterative: Iterative data to be used in comparison tables.
92 :param html_layout_file: Path and name of the file specifying the HTML
93 layout of the dash application.
94 :param tooltip_file: Path and name of the yaml file specifying the
97 :type data_iterative: pandas.DataFrame
98 :type html_layout_file: str
99 :type tooltip_file: str
104 self._data = data_iterative
105 self._html_layout_file = html_layout_file
106 self._tooltip_file = tooltip_file
108 # Get structure of tests:
111 "job", "test_id", "test_type", "dut_type", "dut_version", "tg_type",
114 for _, row in self._data[cols].drop_duplicates().iterrows():
115 lst_job = row["job"].split("-")
117 dver = f"{row['release']}-{row['dut_version']}"
118 tbed = "-".join(lst_job[-2:])
119 lst_test_id = row["test_id"].split(".")
121 suite = lst_test_id[-2].replace("2n1l-", "").replace("1n1l-", "").\
123 test = lst_test_id[-1]
124 nic = suite.split("-")[0]
125 for driver in C.DRIVERS:
127 drv = driver.replace("-", "_")
128 test = test.replace(f"{driver}-", "")
132 infra = "-".join((tbed, nic, drv))
133 lst_test = test.split("-")
135 core = lst_test[1] if lst_test[1] else "8C"
137 if tbs.get(dut, None) is None:
139 if tbs[dut].get(dver, None) is None:
140 tbs[dut][dver] = dict()
141 if tbs[dut][dver].get(infra, None) is None:
142 tbs[dut][dver][infra] = dict()
143 tbs[dut][dver][infra]["core"] = list()
144 tbs[dut][dver][infra]["fsize"] = list()
145 tbs[dut][dver][infra]["ttype"] = list()
146 if core.upper() not in tbs[dut][dver][infra]["core"]:
147 tbs[dut][dver][infra]["core"].append(core.upper())
148 if fsize.upper() not in tbs[dut][dver][infra]["fsize"]:
149 tbs[dut][dver][infra]["fsize"].append(fsize.upper())
150 if row["test_type"] == "mrr":
151 if "MRR" not in tbs[dut][dver][infra]["ttype"]:
152 tbs[dut][dver][infra]["ttype"].append("MRR")
153 elif row["test_type"] == "ndrpdr":
154 if "NDR" not in tbs[dut][dver][infra]["ttype"]:
155 tbs[dut][dver][infra]["ttype"].extend(
156 ("NDR", "PDR", "Latency")
158 elif row["test_type"] == "hoststack" and \
159 row["tg_type"] in ("iperf", "vpp"):
160 if "BPS" not in tbs[dut][dver][infra]["ttype"]:
161 tbs[dut][dver][infra]["ttype"].append("BPS")
162 elif row["test_type"] == "hoststack" and row["tg_type"] == "ab":
163 if "CPS" not in tbs[dut][dver][infra]["ttype"]:
164 tbs[dut][dver][infra]["ttype"].extend(("CPS", "RPS", ))
168 self._html_layout = str()
170 with open(self._html_layout_file, "r") as file_read:
171 self._html_layout = file_read.read()
172 except IOError as err:
174 f"Not possible to open the file {self._html_layout_file}\n{err}"
178 with open(self._tooltip_file, "r") as file_read:
179 self._tooltips = load(file_read, Loader=FullLoader)
180 except IOError as err:
182 f"Not possible to open the file {self._tooltip_file}\n{err}"
184 except YAMLError as err:
186 f"An error occurred while parsing the specification file "
187 f"{self._tooltip_file}\n{err}"
191 if self._app is not None and hasattr(self, "callbacks"):
192 self.callbacks(self._app)
195 def html_layout(self):
196 return self._html_layout
198 def add_content(self):
199 """Top level method which generated the web page.
202 - Store for user input data,
204 - Main area with control panel and ploting area.
206 If no HTML layout is provided, an error message is displayed instead.
208 :returns: The HTML div with the whole page.
212 if self.html_layout and self._tbs:
220 children=[navbar_report((False, True, False, False)), ]
226 dcc.Store(id="store-control-panel"),
227 dcc.Store(id="store-selected"),
228 dcc.Store(id="store-table-data"),
229 dcc.Store(id="store-filtered-table-data"),
230 dcc.Location(id="url", refresh=False),
231 self._add_ctrl_col(),
232 self._add_plotting_col()
237 id="offcanvas-documentation",
238 title="Documentation",
241 children=html.Iframe(
242 src=C.URL_DOC_REL_NOTES,
262 def _add_ctrl_col(self) -> dbc.Col:
263 """Add column with controls. It is placed on the left side.
265 :returns: Column with the control panel.
270 children=self._add_ctrl_panel(),
271 className="sticky-top"
275 def _add_plotting_col(self) -> dbc.Col:
276 """Add column with plots. It is placed on the right side.
278 :returns: Column with plots.
282 id="col-plotting-area",
288 class_name="g-0 p-0",
299 def _add_ctrl_panel(self) -> list:
300 """Add control panel.
302 :returns: Control panel.
308 class_name="g-0 p-1",
313 show_tooltip(self._tooltips, "help-dut", "DUT")
316 id={"type": "ctrl-dd", "index": "dut"},
317 placeholder="Select a Device under Test...",
320 {"label": k, "value": k} \
321 for k in self._tbs.keys()
323 key=lambda d: d["label"]
332 class_name="g-0 p-1",
336 dbc.InputGroupText(show_tooltip(
339 "CSIT and DUT Version"
342 id={"type": "ctrl-dd", "index": "dutver"},
343 placeholder="Select a CSIT and DUT Version...")
350 class_name="g-0 p-1",
354 dbc.InputGroupText(show_tooltip(
360 id={"type": "ctrl-dd", "index": "infra"},
362 "Select a Physical Test Bed Topology..."
370 class_name="g-0 p-1",
374 dbc.InputGroupText(show_tooltip(
380 id={"type": "ctrl-cl", "index": "frmsize"},
385 style={"align-items": "center"},
391 class_name="g-0 p-1",
395 dbc.InputGroupText(show_tooltip(
401 id={"type": "ctrl-cl", "index": "core"},
406 style={"align-items": "center"},
412 class_name="g-0 p-1",
416 dbc.InputGroupText(show_tooltip(
422 id={"type": "ctrl-cl", "index": "ttype"},
427 style={"align-items": "center"},
436 class_name="g-0 p-1",
440 dbc.InputGroupText(show_tooltip(
442 "help-cmp-parameter",
446 id={"type": "ctrl-dd", "index": "cmpprm"},
447 placeholder="Select a Parameter..."
455 class_name="g-0 p-1",
459 dbc.InputGroupText(show_tooltip(
465 id={"type": "ctrl-dd", "index": "cmpval"},
466 placeholder="Select a Value..."
477 class_name="g-0 p-1",
484 "value": "normalize",
485 "label": "Normalize to 2GHz CPU frequency"
495 "label": "Remove Extreme Outliers"
502 style={"align-items": "center"},
514 html.H5("Reference Value")
530 html.H5("Compared Value")
546 html.H5("Data Manipulations")
561 def _get_plotting_area(
566 """Generate the plotting area with all its content.
568 :param title: The title of the comparison table.
569 :param table: Comparison table to be displayed.
570 :param url: URL to be displayed in the modal window.
572 :type table: pandas.DataFrame
574 :returns: List of rows with elements to be displayed in the plotting
583 "No data for comparison.",
586 class_name="g-0 p-1",
592 for idx, col in enumerate(table.columns):
602 l_col = col.rsplit(" ", 2)
604 "name": [l_col[0], " ".join(l_col[-2:])],
609 "format": Format(precision=2, scheme=Scheme.fixed)
614 children=html.H5(title),
620 children=dash_table.DataTable(
621 id={"type": "table", "index": "comparison"},
623 data=table.to_dict("records"),
624 merge_duplicate_headers=True,
626 filter_action="custom",
628 sort_action="native",
633 style_cell={"textAlign": "right"},
634 style_cell_conditional=[{
635 "if": {"column_id": "Test Name"},
654 "text-transform": "none",
655 "padding": "0rem 1rem"
660 dbc.ModalHeader(dbc.ModalTitle("URL")),
669 id="plot-btn-download",
670 children="Download Table",
674 "text-transform": "none",
675 "padding": "0rem 1rem"
678 dcc.Download(id="download-iterative-data"),
680 id="plot-btn-download-raw",
681 children="Download Raw Data",
685 "text-transform": "none",
686 "padding": "0rem 1rem"
689 dcc.Download(id="download-raw-data")
692 "d-grid gap-0 d-md-flex justify-content-md-end"
698 children=C.PLACEHOLDER,
703 def callbacks(self, app):
704 """Callbacks for the whole application.
706 :param app: The application.
712 Output("store-control-panel", "data"),
713 Output("store-selected", "data"),
714 Output("store-table-data", "data"),
715 Output("store-filtered-table-data", "data"),
716 Output("plotting-area", "children"),
717 Output({"type": "table", "index": ALL}, "data"),
718 Output({"type": "ctrl-dd", "index": "dut"}, "value"),
719 Output({"type": "ctrl-dd", "index": "dutver"}, "options"),
720 Output({"type": "ctrl-dd", "index": "dutver"}, "disabled"),
721 Output({"type": "ctrl-dd", "index": "dutver"}, "value"),
722 Output({"type": "ctrl-dd", "index": "infra"}, "options"),
723 Output({"type": "ctrl-dd", "index": "infra"}, "disabled"),
724 Output({"type": "ctrl-dd", "index": "infra"}, "value"),
725 Output({"type": "ctrl-cl", "index": "core"}, "options"),
726 Output({"type": "ctrl-cl", "index": "core"}, "value"),
727 Output({"type": "ctrl-cl", "index": "frmsize"}, "options"),
728 Output({"type": "ctrl-cl", "index": "frmsize"}, "value"),
729 Output({"type": "ctrl-cl", "index": "ttype"}, "options"),
730 Output({"type": "ctrl-cl", "index": "ttype"}, "value"),
731 Output({"type": "ctrl-dd", "index": "cmpprm"}, "options"),
732 Output({"type": "ctrl-dd", "index": "cmpprm"}, "disabled"),
733 Output({"type": "ctrl-dd", "index": "cmpprm"}, "value"),
734 Output({"type": "ctrl-dd", "index": "cmpval"}, "options"),
735 Output({"type": "ctrl-dd", "index": "cmpval"}, "disabled"),
736 Output({"type": "ctrl-dd", "index": "cmpval"}, "value"),
737 Output("normalize", "value"),
738 Output("outliers", "value")
741 State("store-control-panel", "data"),
742 State("store-selected", "data"),
743 State("store-table-data", "data"),
744 State("store-filtered-table-data", "data"),
745 State({"type": "table", "index": ALL}, "data")
748 Input("url", "href"),
749 Input("normalize", "value"),
750 Input("outliers", "value"),
751 Input({"type": "table", "index": ALL}, "filter_query"),
752 Input({"type": "ctrl-dd", "index": ALL}, "value"),
753 Input({"type": "ctrl-cl", "index": ALL}, "value"),
754 Input({"type": "ctrl-btn", "index": ALL}, "n_clicks")
757 def _update_application(
760 store_table_data: list,
769 """Update the application when the event is detected.
772 ctrl_panel = ControlPanel(CP_PARAMS, control_panel)
785 parsed_url = url_decode(href)
787 url_params = parsed_url["params"]
792 plotting_area = no_update
794 trigger = Trigger(callback_context.triggered)
795 if trigger.type == "url" and url_params:
798 selected = literal_eval(url_params["selected"][0])
799 r_sel = selected["reference"]["selection"]
800 c_sel = selected["compare"]
801 normalize = literal_eval(url_params["norm"][0])
802 try: # Necessary for backward compatibility
803 outliers = literal_eval(url_params["outliers"][0])
804 except (KeyError, IndexError, AttributeError):
807 (selected["reference"]["set"] == True) and
808 (c_sel["set"] == True)
810 except (KeyError, IndexError, AttributeError):
814 "dut-val": r_sel["dut"],
815 "dutver-opt": generate_options(
816 self._tbs[r_sel["dut"]].keys()
819 "dutver-val": r_sel["dutver"],
820 "infra-opt": generate_options(
821 self._tbs[r_sel["dut"]][r_sel["dutver"]].keys()
824 "infra-val": r_sel["infra"],
825 "core-opt": generate_options(
826 self._tbs[r_sel["dut"]][r_sel["dutver"]]\
827 [r_sel["infra"]]["core"]
829 "core-val": r_sel["core"],
830 "frmsize-opt": generate_options(
831 self._tbs[r_sel["dut"]][r_sel["dutver"]]\
832 [r_sel["infra"]]["fsize"]
834 "frmsize-val": r_sel["frmsize"],
835 "ttype-opt": generate_options(
836 self._tbs[r_sel["dut"]][r_sel["dutver"]]\
837 [r_sel["infra"]]["ttype"]
839 "ttype-val": r_sel["ttype"],
840 "normalize-val": normalize,
841 "outliers-val": outliers
844 for itm, label in CMP_PARAMS.items():
845 if len(ctrl_panel.get(f"{itm}-opt")) > 1:
846 opts.append({"label": label, "value": itm})
849 "cmp-par-dis": False,
850 "cmp-par-val": c_sel["parameter"]
853 for itm in ctrl_panel.get(f"{c_sel['parameter']}-opt"):
854 set_val = ctrl_panel.get(f"{c_sel['parameter']}-val")
855 if isinstance(set_val, list):
856 if itm["value"] not in set_val:
859 if itm["value"] != set_val:
863 "cmp-val-dis": False,
864 "cmp-val-val": c_sel["value"]
867 elif trigger.type == "normalize":
868 ctrl_panel.set({"normalize-val": normalize})
870 elif trigger.type == "outliers":
871 ctrl_panel.set({"outliers-val": outliers})
873 elif trigger.type == "ctrl-dd":
874 if trigger.idx == "dut":
876 opts = generate_options(self._tbs[trigger.value].keys())
882 "dut-val": trigger.value,
884 "dutver-dis": disabled,
891 "frmsize-opt": list(),
892 "frmsize-val": list(),
895 "cmp-par-opt": list(),
897 "cmp-par-val": str(),
898 "cmp-val-opt": list(),
902 elif trigger.idx == "dutver":
904 dut = ctrl_panel.get("dut-val")
905 dver = self._tbs[dut][trigger.value]
906 opts = generate_options(dver.keys())
912 "dutver-val": trigger.value,
914 "infra-dis": disabled,
918 "frmsize-opt": list(),
919 "frmsize-val": list(),
922 "cmp-par-opt": list(),
924 "cmp-par-val": str(),
925 "cmp-val-opt": list(),
929 elif trigger.idx == "infra":
930 dut = ctrl_panel.get("dut-val")
931 dver = ctrl_panel.get("dutver-val")
932 if all((dut, dver, trigger.value, )):
933 driver = self._tbs[dut][dver][trigger.value]
935 "infra-val": trigger.value,
936 "core-opt": generate_options(driver["core"]),
938 "frmsize-opt": generate_options(driver["fsize"]),
939 "frmsize-val": list(),
940 "ttype-opt": generate_options(driver["ttype"]),
942 "cmp-par-opt": list(),
944 "cmp-par-val": str(),
945 "cmp-val-opt": list(),
949 elif trigger.idx == "cmpprm":
950 value = trigger.value
952 for itm in ctrl_panel.get(f"{value}-opt"):
953 set_val = ctrl_panel.get(f"{value}-val")
954 if isinstance(set_val, list):
955 if itm["value"] == "Latency":
957 if itm["value"] not in set_val:
960 if itm["value"] != set_val:
963 "cmp-par-val": value,
965 "cmp-val-dis": False,
968 elif trigger.idx == "cmpval":
969 ctrl_panel.set({"cmp-val-val": trigger.value})
970 selected["reference"] = {
973 "dut": ctrl_panel.get("dut-val"),
974 "dutver": ctrl_panel.get("dutver-val"),
975 "infra": ctrl_panel.get("infra-val"),
976 "core": ctrl_panel.get("core-val"),
977 "frmsize": ctrl_panel.get("frmsize-val"),
978 "ttype": ctrl_panel.get("ttype-val")
981 selected["compare"] = {
983 "parameter": ctrl_panel.get("cmp-par-val"),
984 "value": trigger.value
987 elif trigger.type == "ctrl-cl":
988 ctrl_panel.set({f"{trigger.idx}-val": trigger.value})
989 if all((ctrl_panel.get("core-val"),
990 ctrl_panel.get("frmsize-val"),
991 ctrl_panel.get("ttype-val"), )):
992 if "Latency" in ctrl_panel.get("ttype-val"):
993 ctrl_panel.set({"ttype-val": ["Latency", ]})
995 for itm, label in CMP_PARAMS.items():
996 if "Latency" in ctrl_panel.get("ttype-val") and \
999 if len(ctrl_panel.get(f"{itm}-opt")) > 1:
1000 if isinstance(ctrl_panel.get(f"{itm}-val"), list):
1001 if len(ctrl_panel.get(f"{itm}-opt")) == \
1002 len(ctrl_panel.get(f"{itm}-val")):
1004 opts.append({"label": label, "value": itm})
1006 "cmp-par-opt": opts,
1007 "cmp-par-dis": False,
1008 "cmp-par-val": str(),
1009 "cmp-val-opt": list(),
1010 "cmp-val-dis": True,
1011 "cmp-val-val": str()
1015 "cmp-par-opt": list(),
1016 "cmp-par-dis": True,
1017 "cmp-par-val": str(),
1018 "cmp-val-opt": list(),
1019 "cmp-val-dis": True,
1020 "cmp-val-val": str()
1022 elif trigger.type == "table" and trigger.idx == "comparison":
1023 filtered_data = filter_table_data(
1027 table_data = [filtered_data, ]
1029 if all((on_draw, selected["reference"]["set"],
1030 selected["compare"]["set"], )):
1031 title, table = comparison_table(
1034 normalize=normalize,
1036 remove_outliers=outliers
1038 plotting_area = self._get_plotting_area(
1044 "selected": selected,
1046 "outliers": outliers
1050 store_table_data = table.to_dict("records")
1051 filtered_data = store_table_data
1053 table_data = [store_table_data, ]
1063 ret_val.extend(ctrl_panel.values)
1067 Output("plot-mod-url", "is_open"),
1068 Input("plot-btn-url", "n_clicks"),
1069 State("plot-mod-url", "is_open")
1071 def toggle_plot_mod_url(n, is_open):
1072 """Toggle the modal window with url.
1079 Output("download-iterative-data", "data"),
1080 State("store-table-data", "data"),
1081 State("store-filtered-table-data", "data"),
1082 Input("plot-btn-download", "n_clicks"),
1083 prevent_initial_call=True
1085 def _download_comparison_data(
1087 filtered_table_data: list,
1090 """Download the data.
1092 :param table_data: Original unfiltered table data.
1093 :param filtered_table_data: Filtered table data.
1094 :type table_data: list
1095 :type filtered_table_data: list
1096 :returns: dict of data frame content (base64 encoded) and meta data
1097 used by the Download component.
1104 if filtered_table_data:
1105 table = pd.DataFrame.from_records(filtered_table_data)
1107 table = pd.DataFrame.from_records(table_data)
1109 return dcc.send_data_frame(table.to_csv, C.COMP_DOWNLOAD_FILE_NAME)
1112 Output("download-raw-data", "data"),
1113 State("store-selected", "data"),
1114 Input("plot-btn-download-raw", "n_clicks"),
1115 prevent_initial_call=True
1117 def _download_raw_comparison_data(selected: dict, _: int) -> dict:
1118 """Download the data.
1120 :param selected: Selected tests.
1121 :type selected: dict
1122 :returns: dict of data frame content (base64 encoded) and meta data
1123 used by the Download component.
1130 _, table = comparison_table(
1134 remove_outliers=False,
1138 return dcc.send_data_frame(
1139 table.dropna(how="all", axis=1).to_csv,
1140 f"raw_{C.COMP_DOWNLOAD_FILE_NAME}"
1144 Output("offcanvas-documentation", "is_open"),
1145 Input("btn-documentation", "n_clicks"),
1146 State("offcanvas-documentation", "is_open")
1148 def toggle_offcanvas_documentation(n_clicks, is_open):