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.
15 """Plotly Dash HTML layout override.
20 import dash_bootstrap_components as dbc
22 from flask import Flask
24 from dash import html, dash_table
25 from dash import callback_context, no_update, ALL
26 from dash import Input, Output, State
27 from dash.exceptions import PreventUpdate
28 from yaml import load, FullLoader, YAMLError
29 from ast import literal_eval
31 from ..utils.constants import Constants as C
32 from ..utils.control_panel import ControlPanel
33 from ..utils.trigger import Trigger
34 from ..utils.utils import gen_new_url, generate_options, navbar_trending, \
35 filter_table_data, show_trending_graph_data, show_iterative_graph_data, \
37 from ..utils.url_processing import url_decode
38 from .tables import search_table
39 from ..coverage.tables import coverage_tables
40 from ..report.graphs import graph_iterative
41 from ..trending.graphs import graph_trending
44 # Control panel partameters and their default values.
46 "datatype-val": str(),
48 "dut-dis": C.STYLE_DONT_DISPLAY,
50 "release-opt": list(),
51 "release-dis": C.STYLE_DONT_DISPLAY,
53 "help-dis": C.STYLE_DONT_DISPLAY,
55 "search-dis": C.STYLE_DONT_DISPLAY,
61 """The layout of the dash app and the callbacks.
67 html_layout_file: str,
68 graph_layout_file: str,
72 - save the input parameters,
73 - read and pre-process the data,
74 - prepare data for the control panel,
75 - read HTML layout file,
76 - read graph layout file,
77 - read tooltips from the tooltip file.
79 :param app: Flask application running the dash application.
80 :param data_trending: Pandas dataframe with trending data.
81 :param html_layout_file: Path and name of the file specifying the HTML
82 layout of the dash application.
83 :param graph_layout_file: Path and name of the file with layout of
85 :param tooltip_file: Path and name of the yaml file specifying the
88 :type data_trending: pandas.DataFrame
89 :type html_layout_file: str
90 :type graph_layout_file: str
91 :type tooltip_file: str
96 self._html_layout_file = html_layout_file
97 self._graph_layout_file = graph_layout_file
98 self._tooltip_file = tooltip_file
101 k: v for k, v in data.items() if not v.empty and k != "statistics"
104 for data_type, pd in self._data.items():
109 for _, row in pd.iterrows():
110 l_id = row["test_id"].split(".")
111 suite = l_id[-2].replace("2n1l-", "").replace("1n1l-", "").\
113 tb = "-".join(row["job"].split("-")[-2:])
114 nic = suite.split("-")[0]
115 for driver in C.DRIVERS:
123 if data_type in ("iterative", "coverage", ):
125 "_".join((row["release"], row["dut_type"],
126 row["dut_version"], tb, nic, drv, test))
130 "_".join((row["dut_type"], tb, nic, drv, test))
132 pd["full_id"] = full_id
134 # Get structure of tests:
136 for data_type, pd in self._data.items():
139 self._duts[data_type] = dict()
140 if data_type in ("iterative", "coverage", ):
141 cols = ["job", "dut_type", "dut_version", "release", "test_id"]
142 for _, row in pd[cols].drop_duplicates().iterrows():
143 dut = row["dut_type"]
144 if self._duts[data_type].get(dut, None) is None:
145 self._duts[data_type][dut] = list()
146 if row["release"] not in self._duts[data_type][dut]:
147 self._duts[data_type][dut].append(row["release"])
149 for dut in pd["dut_type"].unique():
150 if self._duts[data_type].get(dut, None) is None:
151 self._duts[data_type][dut] = list()
154 self._html_layout = str()
155 self._graph_layout = None
156 self._tooltips = dict()
159 with open(self._html_layout_file, "r") as file_read:
160 self._html_layout = file_read.read()
161 except IOError as err:
163 f"Not possible to open the file {self._html_layout_file}\n{err}"
167 with open(self._graph_layout_file, "r") as file_read:
168 self._graph_layout = load(file_read, Loader=FullLoader)
169 except IOError as err:
171 f"Not possible to open the file {self._graph_layout_file}\n"
174 except YAMLError as err:
176 f"An error occurred while parsing the specification file "
177 f"{self._graph_layout_file}\n{err}"
181 with open(self._tooltip_file, "r") as file_read:
182 self._tooltips = load(file_read, Loader=FullLoader)
183 except IOError as err:
185 f"Not possible to open the file {self._tooltip_file}\n{err}"
187 except YAMLError as err:
189 f"An error occurred while parsing the specification file "
190 f"{self._tooltip_file}\n{err}"
194 if self._app is not None and hasattr(self, "callbacks"):
195 self.callbacks(self._app)
198 def html_layout(self):
199 return self._html_layout
201 def add_content(self):
202 """Top level method which generated the web page.
205 - Store for user input data,
207 - Main area with control panel and ploting area.
209 If no HTML layout is provided, an error message is displayed instead.
211 :returns: The HTML div with the whole page.
214 if self.html_layout and self._duts:
219 dcc.Store(id="store"),
220 dcc.Store(id="store-table-data"),
221 dcc.Store(id="store-filtered-table-data"),
222 dcc.Location(id="url", refresh=False),
226 children=[navbar_trending((False, False, False, True))]
232 self._add_ctrl_col(),
233 self._add_plotting_col()
239 id="offcanvas-details",
240 title="Test Details",
245 delay_show=C.SPINNER_DELAY
250 id="offcanvas-metadata",
251 title="Detailed Information",
255 dbc.Row(id="metadata-tput-lat"),
256 dbc.Row(id="metadata-hdrh-graph")
259 delay_show=C.SPINNER_DELAY
263 id="offcanvas-documentation",
264 title="Documentation",
267 children=html.Iframe(
268 src=C.URL_DOC_TRENDING,
277 dbc.Alert("An Error Occured", color="danger"),
281 def _add_ctrl_col(self) -> dbc.Col:
282 """Add column with controls. It is placed on the left side.
284 :returns: Column with the control panel.
287 return dbc.Col(html.Div(self._add_ctrl_panel(), className="sticky-top"))
289 def _add_ctrl_panel(self) -> list:
290 """Add control panel.
292 :returns: Control panel.
297 class_name="g-0 p-1",
301 dbc.InputGroupText(show_tooltip(
307 id={"type": "ctrl-dd", "index": "datatype"},
308 placeholder="Select a Data Type...",
311 {"label": k, "value": k} \
312 for k in self._data.keys()
314 key=lambda d: d["label"]
321 style=C.STYLE_DISPLAY
324 class_name="g-0 p-1",
325 id={"type": "ctrl-row", "index": "dut"},
329 dbc.InputGroupText(show_tooltip(
335 id={"type": "ctrl-dd", "index": "dut"},
336 placeholder="Select a Device under Test..."
342 style=C.STYLE_DONT_DISPLAY
345 class_name="g-0 p-1",
346 id={"type": "ctrl-row", "index": "release"},
350 dbc.InputGroupText(show_tooltip(
356 id={"type": "ctrl-dd", "index": "release"},
357 placeholder="Select a Release..."
363 style=C.STYLE_DONT_DISPLAY
366 class_name="g-0 p-1",
367 id={"type": "ctrl-row", "index": "help"},
370 id={"type": "ctrl-dd", "index": "help"},
376 style=C.STYLE_DONT_DISPLAY
379 class_name="g-0 p-1",
380 id={"type": "ctrl-row", "index": "search"},
383 id={"type": "ctrl-dd", "index": "search"},
384 placeholder="Type a Regular Expression...",
389 style=C.STYLE_DONT_DISPLAY
393 def _add_plotting_col(self) -> dbc.Col:
394 """Add column with tables. It is placed on the right side.
396 :returns: Column with tables.
400 id="col-plotting-area",
406 class_name="g-0 p-0",
407 children=[C.PLACEHOLDER, ]
416 def _get_plotting_area(table: pd.DataFrame, url: str) -> list:
417 """Generate the plotting area with all its content.
419 :param table: Search table to be displayed.
420 :param url: URL to be displayed in a modal window.
421 :type table: pandas.DataFrame
423 :returns: List of rows with elements to be displayed in the plotting
435 class_name="g-0 p-1",
440 columns = [{"name": col, "id": col} for col in table.columns]
446 children=dash_table.DataTable(
447 id={"type": "table", "index": "search"},
449 data=table.to_dict("records"),
450 filter_action="custom",
451 sort_action="native",
456 style_cell={"textAlign": "left"}
473 "text-transform": "none",
474 "padding": "0rem 1rem"
479 dbc.ModalHeader(dbc.ModalTitle("URL")),
488 id="plot-btn-download",
489 children="Download Data",
493 "text-transform": "none",
494 "padding": "0rem 1rem"
497 dcc.Download(id="download-data")
500 "d-grid gap-0 d-md-flex justify-content-md-end"
506 children=C.PLACEHOLDER,
511 def callbacks(self, app):
512 """Callbacks for the whole application.
514 :param app: The application.
519 Output("store", "data"),
520 Output("store-table-data", "data"),
521 Output("store-filtered-table-data", "data"),
522 Output("plotting-area", "children"),
523 Output({"type": "table", "index": ALL}, "data"),
524 Output({"type": "ctrl-dd", "index": "datatype"}, "value"),
525 Output({"type": "ctrl-dd", "index": "dut"}, "options"),
526 Output({"type": "ctrl-row", "index": "dut"}, "style"),
527 Output({"type": "ctrl-dd", "index": "dut"}, "value"),
528 Output({"type": "ctrl-dd", "index": "release"}, "options"),
529 Output({"type": "ctrl-row", "index": "release"}, "style"),
530 Output({"type": "ctrl-dd", "index": "release"}, "value"),
531 Output({"type": "ctrl-row", "index": "help"}, "style"),
532 Output({"type": "ctrl-dd", "index": "help"}, "value"),
533 Output({"type": "ctrl-row", "index": "search"}, "style"),
534 Output({"type": "ctrl-dd", "index": "search"}, "value"),
535 State("store", "data"),
536 State("store-table-data", "data"),
537 State("store-filtered-table-data", "data"),
538 State({"type": "table", "index": ALL}, "data"),
539 Input("url", "href"),
540 Input({"type": "table", "index": ALL}, "filter_query"),
541 Input({"type": "ctrl-dd", "index": ALL}, "value"),
542 prevent_initial_call=True
544 def _update_application(
546 store_table_data: list,
552 """Update the application when the event is detected.
557 "control-panel": dict(),
561 ctrl_panel = ControlPanel(
563 store.get("control-panel", dict())
565 selection = store["selection"]
567 plotting_area = no_update
571 parsed_url = url_decode(href)
573 url_params = parsed_url["params"]
577 trigger = Trigger(callback_context.triggered)
578 if trigger.type == "url" and url_params:
580 selection = literal_eval(url_params["selection"][0])
582 dtype = selection["datatype"]
583 dut = selection["dut"]
584 if dtype == "trending":
586 rls_dis = C.STYLE_DONT_DISPLAY
588 rls_opts = generate_options(self._duts[dtype][dut])
589 rls_dis = C.STYLE_DISPLAY
591 "datatype-val": dtype,
593 generate_options(self._duts[dtype].keys()),
594 "dut-dis": C.STYLE_DISPLAY,
596 "release-opt": rls_opts,
597 "release-dis": rls_dis,
598 "release-val": selection["release"],
599 "help-dis": C.STYLE_DISPLAY,
600 "help-val": selection["help"],
601 "search-dis": C.STYLE_DISPLAY,
602 "search-val": selection["regexp"]
605 except (KeyError, IndexError, AttributeError, ValueError):
607 elif trigger.type == "ctrl-dd":
608 if trigger.idx == "datatype":
610 data_type = self._duts[trigger.value]
611 options = generate_options(data_type.keys())
612 disabled = C.STYLE_DISPLAY
615 disabled = C.STYLE_DONT_DISPLAY
617 "datatype-val": trigger.value,
621 "release-opt": list(),
622 "release-dis": C.STYLE_DONT_DISPLAY,
623 "release-val": str(),
624 "help-dis": C.STYLE_DONT_DISPLAY,
626 "search-dis": C.STYLE_DONT_DISPLAY,
629 elif trigger.idx == "dut":
631 data_type = ctrl_panel.get("datatype-val")
632 dut = self._duts[data_type][trigger.value]
633 if data_type != "trending":
634 options = generate_options(dut)
635 disabled = C.STYLE_DISPLAY
638 disabled = C.STYLE_DONT_DISPLAY
639 if data_type == "trending":
641 "dut-val": trigger.value,
642 "release-opt": list(),
643 "release-dis": C.STYLE_DONT_DISPLAY,
644 "release-val": str(),
645 "help-dis": disabled,
646 "help-val": "<testbed> <nic> <driver> " + \
647 "<framesize> <cores> <test>",
648 "search-dis": disabled,
653 "dut-val": trigger.value,
654 "release-opt": options,
655 "release-dis": disabled,
656 "release-val": str(),
657 "help-dis": C.STYLE_DONT_DISPLAY,
659 "search-dis": C.STYLE_DONT_DISPLAY,
662 elif trigger.idx == "release":
664 "release-val": trigger.value,
665 "help-dis": C.STYLE_DISPLAY,
666 "help-val": "<DUT version> <testbed> <nic> " + \
667 "<driver> <framesize> <core> <test>",
668 "search-dis": C.STYLE_DISPLAY,
671 elif trigger.idx == "search":
672 ctrl_panel.set({"search-val": trigger.value})
674 "datatype": ctrl_panel.get("datatype-val"),
675 "dut": ctrl_panel.get("dut-val"),
676 "release": ctrl_panel.get("release-val"),
677 "help": ctrl_panel.get("help-val"),
678 "regexp": ctrl_panel.get("search-val"),
681 elif trigger.type == "table" and trigger.idx == "search":
682 filtered_data = filter_table_data(
686 table_data = [filtered_data, ]
689 table = search_table(data=self._data, selection=selection)
690 plotting_area = Layout._get_plotting_area(
692 gen_new_url(parsed_url, {"selection": selection})
694 store_table_data = table.to_dict("records")
695 filtered_data = store_table_data
697 table_data = [store_table_data, ]
699 plotting_area = no_update
701 store["control-panel"] = ctrl_panel.panel
702 store["selection"] = selection
710 ret_val.extend(ctrl_panel.values)
715 Output("offcanvas-details", "is_open"),
716 Output("offcanvas-details", "children"),
717 State("store", "data"),
718 State("store-filtered-table-data", "data"),
719 Input({"type": "table", "index": ALL}, "active_cell"),
720 prevent_initial_call=True
722 def show_test_data(store, table, *_):
723 """Show offcanvas with graphs and tables based on selected test(s).
726 trigger = Trigger(callback_context.triggered)
727 if not trigger.value:
731 row = pd.DataFrame.from_records(table).\
732 iloc[[trigger.value["row"]]]
733 datatype = store["selection"]["datatype"]
734 dut = store["selection"]["dut"]
735 rls = store["selection"]["release"]
736 tb = row["Test Bed"].iloc[0]
737 nic = row["NIC"].iloc[0]
738 driver = row['Driver'].iloc[0]
739 test_name = row['Test'].iloc[0]
741 except(KeyError, IndexError, AttributeError, ValueError):
744 data = self._data[datatype]
745 if datatype == "trending":
746 df = pd.DataFrame(data.loc[data["dut_type"] == dut])
748 dutver = row["DUT Version"].iloc[0]
749 df = pd.DataFrame(data.loc[(
750 (data["dut_type"] == dut) &
751 (data["dut_version"] == dutver) &
752 (data["release"] == rls)
755 df = df[df.full_id.str.contains(
756 f".*{tb}.*{nic}.*{test_name}",
760 if datatype in ("trending", "iterative"):
761 l_test_id = df["test_id"].iloc[0].split(".")
765 area = ".".join(l_test_id[3:-2])
766 for drv in C.DRIVERS:
768 test = test_name.replace(f"{drv}-", "")
772 l_test = test.split("-")
773 testtype = l_test[-1]
774 if testtype == "ndrpdr":
775 testtype = ["ndr", "pdr"]
777 testtype = [testtype, ]
778 core = l_test[1] if l_test[1] else "8c"
779 test = "-".join(l_test[2: -1])
780 test_id = f"{tb}-{nic}-{driver}-{core}-{l_test[0]}-{test}"
782 class_name="g-0 p-0",
783 children=dbc.Alert(test_id, color="info"),
786 indexes = ("tput", "bandwidth", "lat")
787 if datatype == "trending":
788 for ttype in testtype:
790 "id": f"{dut}-{test_id}-{ttype}",
792 "phy": f"{tb}-{nic}-{driver}",
795 "framesize": l_test[0],
799 graphs = graph_trending(df, selected, self._graph_layout)
800 labels = ("Throughput", "Bandwidth", "Latency")
802 for graph, label, idx in zip(graphs, labels, indexes):
807 id={"type": "graph-trend", "index": idx},
814 dbc.Row(dbc.Tabs(tabs), class_name="g-0 p-0")
819 dbc.Row("No data.", class_name="g-0 p-0")
823 for ttype in testtype:
825 "id": f"{test_id}-{ttype}",
829 "phy": f"{tb}-{nic}-{driver}",
832 "framesize": l_test[0],
836 graphs = graph_iterative(df, selected, self._graph_layout)
838 for graph, idx in zip(graphs, indexes):
840 cols.append(dbc.Col(dcc.Graph(
842 id={"type": "graph-iter", "index": idx},
848 dbc.Row(class_name="g-0 p-0", children=cols)
851 elif datatype == "coverage":
852 ret_val = coverage_tables(
858 "phy": f"{tb}-{nic}-{driver}",
861 start_collapsed=False
869 Output("metadata-tput-lat", "children"),
870 Output("metadata-hdrh-graph", "children"),
871 Output("offcanvas-metadata", "is_open"),
872 Input({"type": "graph-trend", "index": ALL}, "clickData"),
873 Input({"type": "graph-iter", "index": ALL}, "clickData"),
874 prevent_initial_call=True
876 def _show_metadata_from_trend_graph(
880 """Generates the data for the offcanvas displayed when a particular
881 point in a graph is clicked on.
884 trigger = Trigger(callback_context.triggered)
885 if not trigger.value:
888 if trigger.type == "graph-trend":
889 return show_trending_graph_data(
890 trigger, trend_data, self._graph_layout)
891 elif trigger.type == "graph-iter":
892 return show_iterative_graph_data(
893 trigger, iter_data, self._graph_layout)
898 Output("plot-mod-url", "is_open"),
899 Input("plot-btn-url", "n_clicks"),
900 State("plot-mod-url", "is_open")
902 def toggle_plot_mod_url(n, is_open):
903 """Toggle the modal window with url.
910 Output("download-data", "data"),
911 State("store-filtered-table-data", "data"),
912 Input("plot-btn-download", "n_clicks"),
913 prevent_initial_call=True
915 def _download_search_data(selection, _):
916 """Download the data.
918 :param selection: Selected data in table format (records).
919 :type selection: dict
920 :returns: dict of data frame content (base64 encoded) and meta data
921 used by the Download component.
928 return dcc.send_data_frame(
929 pd.DataFrame.from_records(selection).to_csv,
930 C.SEARCH_DOWNLOAD_FILE_NAME
934 Output("offcanvas-documentation", "is_open"),
935 Input("btn-documentation", "n_clicks"),
936 State("offcanvas-documentation", "is_open")
938 def toggle_offcanvas_documentation(n_clicks, is_open):