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
36 from ..utils.url_processing import url_decode
37 from .tables import search_table
38 from ..coverage.tables import coverage_tables
39 from ..report.graphs import graph_iterative
40 from ..trending.graphs import graph_trending
43 # Control panel partameters and their default values.
45 "datatype-val": str(),
47 "dut-dis": C.STYLE_DONT_DISPLAY,
49 "release-opt": list(),
50 "release-dis": C.STYLE_DONT_DISPLAY,
52 "help-dis": C.STYLE_DONT_DISPLAY,
54 "search-dis": C.STYLE_DONT_DISPLAY,
60 """The layout of the dash app and the callbacks.
66 html_layout_file: str,
67 graph_layout_file: str,
71 - save the input parameters,
72 - read and pre-process the data,
73 - prepare data for the control panel,
74 - read HTML layout file,
75 - read graph layout file,
76 - read tooltips from the tooltip file.
78 :param app: Flask application running the dash application.
79 :param data_trending: Pandas dataframe with trending data.
80 :param html_layout_file: Path and name of the file specifying the HTML
81 layout of the dash application.
82 :param graph_layout_file: Path and name of the file with layout of
84 :param tooltip_file: Path and name of the yaml file specifying the
87 :type data_trending: pandas.DataFrame
88 :type html_layout_file: str
89 :type graph_layout_file: str
90 :type tooltip_file: str
95 self._html_layout_file = html_layout_file
96 self._graph_layout_file = graph_layout_file
97 self._tooltip_file = tooltip_file
100 k: v for k, v in data.items() if not v.empty and k != "statistics"
103 for data_type, pd in self._data.items():
108 for _, row in pd.iterrows():
109 l_id = row["test_id"].split(".")
110 suite = l_id[-2].replace("2n1l-", "").replace("1n1l-", "").\
112 tb = "-".join(row["job"].split("-")[-2:])
113 nic = suite.split("-")[0]
114 for driver in C.DRIVERS:
122 if data_type in ("iterative", "coverage", ):
124 "_".join((row["release"], row["dut_type"],
125 row["dut_version"], tb, nic, drv, test))
129 "_".join((row["dut_type"], tb, nic, drv, test))
131 pd["full_id"] = full_id
133 # Get structure of tests:
135 for data_type, pd in self._data.items():
138 self._duts[data_type] = dict()
139 if data_type in ("iterative", "coverage", ):
140 cols = ["job", "dut_type", "dut_version", "release", "test_id"]
141 for _, row in pd[cols].drop_duplicates().iterrows():
142 dut = row["dut_type"]
143 if self._duts[data_type].get(dut, None) is None:
144 self._duts[data_type][dut] = list()
145 if row["release"] not in self._duts[data_type][dut]:
146 self._duts[data_type][dut].append(row["release"])
148 for dut in pd["dut_type"].unique():
149 if self._duts[data_type].get(dut, None) is None:
150 self._duts[data_type][dut] = list()
153 self._html_layout = str()
154 self._graph_layout = None
155 self._tooltips = dict()
158 with open(self._html_layout_file, "r") as file_read:
159 self._html_layout = file_read.read()
160 except IOError as err:
162 f"Not possible to open the file {self._html_layout_file}\n{err}"
166 with open(self._graph_layout_file, "r") as file_read:
167 self._graph_layout = load(file_read, Loader=FullLoader)
168 except IOError as err:
170 f"Not possible to open the file {self._graph_layout_file}\n"
173 except YAMLError as err:
175 f"An error occurred while parsing the specification file "
176 f"{self._graph_layout_file}\n{err}"
180 with open(self._tooltip_file, "r") as file_read:
181 self._tooltips = load(file_read, Loader=FullLoader)
182 except IOError as err:
184 f"Not possible to open the file {self._tooltip_file}\n{err}"
186 except YAMLError as err:
188 f"An error occurred while parsing the specification file "
189 f"{self._tooltip_file}\n{err}"
193 if self._app is not None and hasattr(self, "callbacks"):
194 self.callbacks(self._app)
197 def html_layout(self):
198 return self._html_layout
200 def add_content(self):
201 """Top level method which generated the web page.
204 - Store for user input data,
206 - Main area with control panel and ploting area.
208 If no HTML layout is provided, an error message is displayed instead.
210 :returns: The HTML div with the whole page.
213 if self.html_layout and self._duts:
218 dcc.Store(id="store"),
219 dcc.Store(id="store-table-data"),
220 dcc.Store(id="store-filtered-table-data"),
221 dcc.Location(id="url", refresh=False),
225 children=[navbar_trending((False, False, False, True))]
231 self._add_ctrl_col(),
232 self._add_plotting_col()
238 id="offcanvas-details",
239 title="Test Details",
244 delay_show=C.SPINNER_DELAY
249 id="offcanvas-metadata",
250 title="Detailed Information",
254 dbc.Row(id="metadata-tput-lat"),
255 dbc.Row(id="metadata-hdrh-graph")
258 delay_show=C.SPINNER_DELAY
262 id="offcanvas-documentation",
263 title="Documentation",
266 children=html.Iframe(
267 src=C.URL_DOC_TRENDING,
276 dbc.Alert("An Error Occured", color="danger"),
280 def _add_ctrl_col(self) -> dbc.Col:
281 """Add column with controls. It is placed on the left side.
283 :returns: Column with the control panel.
286 return dbc.Col(html.Div(self._add_ctrl_panel(), className="sticky-top"))
288 def _add_ctrl_panel(self) -> list:
289 """Add control panel.
291 :returns: Control panel.
296 class_name="g-0 p-1",
300 dbc.InputGroupText("Data Type"),
302 id={"type": "ctrl-dd", "index": "datatype"},
303 placeholder="Select a Data Type...",
306 {"label": k, "value": k} \
307 for k in self._data.keys()
309 key=lambda d: d["label"]
316 style=C.STYLE_DISPLAY
319 class_name="g-0 p-1",
320 id={"type": "ctrl-row", "index": "dut"},
324 dbc.InputGroupText("DUT"),
326 id={"type": "ctrl-dd", "index": "dut"},
327 placeholder="Select a Device under Test..."
333 style=C.STYLE_DONT_DISPLAY
336 class_name="g-0 p-1",
337 id={"type": "ctrl-row", "index": "release"},
341 dbc.InputGroupText("Release"),
343 id={"type": "ctrl-dd", "index": "release"},
344 placeholder="Select a Release..."
350 style=C.STYLE_DONT_DISPLAY
353 class_name="g-0 p-1",
354 id={"type": "ctrl-row", "index": "help"},
357 id={"type": "ctrl-dd", "index": "help"},
363 style=C.STYLE_DONT_DISPLAY
366 class_name="g-0 p-1",
367 id={"type": "ctrl-row", "index": "search"},
370 id={"type": "ctrl-dd", "index": "search"},
371 placeholder="Type a Regular Expression...",
376 style=C.STYLE_DONT_DISPLAY
380 def _add_plotting_col(self) -> dbc.Col:
381 """Add column with tables. It is placed on the right side.
383 :returns: Column with tables.
387 id="col-plotting-area",
393 class_name="g-0 p-0",
394 children=[C.PLACEHOLDER, ]
403 def _get_plotting_area(table: pd.DataFrame, url: str) -> list:
404 """Generate the plotting area with all its content.
406 :param table: Search table to be displayed.
407 :param url: URL to be displayed in a modal window.
408 :type table: pandas.DataFrame
410 :returns: List of rows with elements to be displayed in the plotting
422 class_name="g-0 p-1",
427 columns = [{"name": col, "id": col} for col in table.columns]
433 children=dash_table.DataTable(
434 id={"type": "table", "index": "search"},
436 data=table.to_dict("records"),
437 filter_action="custom",
438 sort_action="native",
443 style_cell={"textAlign": "left"}
460 "text-transform": "none",
461 "padding": "0rem 1rem"
466 dbc.ModalHeader(dbc.ModalTitle("URL")),
475 id="plot-btn-download",
476 children="Download Data",
480 "text-transform": "none",
481 "padding": "0rem 1rem"
484 dcc.Download(id="download-data")
487 "d-grid gap-0 d-md-flex justify-content-md-end"
493 children=C.PLACEHOLDER,
498 def callbacks(self, app):
499 """Callbacks for the whole application.
501 :param app: The application.
506 Output("store", "data"),
507 Output("store-table-data", "data"),
508 Output("store-filtered-table-data", "data"),
509 Output("plotting-area", "children"),
510 Output({"type": "table", "index": ALL}, "data"),
511 Output({"type": "ctrl-dd", "index": "datatype"}, "value"),
512 Output({"type": "ctrl-dd", "index": "dut"}, "options"),
513 Output({"type": "ctrl-row", "index": "dut"}, "style"),
514 Output({"type": "ctrl-dd", "index": "dut"}, "value"),
515 Output({"type": "ctrl-dd", "index": "release"}, "options"),
516 Output({"type": "ctrl-row", "index": "release"}, "style"),
517 Output({"type": "ctrl-dd", "index": "release"}, "value"),
518 Output({"type": "ctrl-row", "index": "help"}, "style"),
519 Output({"type": "ctrl-dd", "index": "help"}, "value"),
520 Output({"type": "ctrl-row", "index": "search"}, "style"),
521 Output({"type": "ctrl-dd", "index": "search"}, "value"),
522 State("store", "data"),
523 State("store-table-data", "data"),
524 State("store-filtered-table-data", "data"),
525 State({"type": "table", "index": ALL}, "data"),
526 Input("url", "href"),
527 Input({"type": "table", "index": ALL}, "filter_query"),
528 Input({"type": "ctrl-dd", "index": ALL}, "value"),
529 prevent_initial_call=True
531 def _update_application(
533 store_table_data: list,
539 """Update the application when the event is detected.
544 "control-panel": dict(),
548 ctrl_panel = ControlPanel(
550 store.get("control-panel", dict())
552 selection = store["selection"]
554 plotting_area = no_update
558 parsed_url = url_decode(href)
560 url_params = parsed_url["params"]
564 trigger = Trigger(callback_context.triggered)
565 if trigger.type == "url" and url_params:
567 selection = literal_eval(url_params["selection"][0])
569 dtype = selection["datatype"]
570 dut = selection["dut"]
571 if dtype == "trending":
573 rls_dis = C.STYLE_DONT_DISPLAY
575 rls_opts = generate_options(self._duts[dtype][dut])
576 rls_dis = C.STYLE_DISPLAY
578 "datatype-val": dtype,
580 generate_options(self._duts[dtype].keys()),
581 "dut-dis": C.STYLE_DISPLAY,
583 "release-opt": rls_opts,
584 "release-dis": rls_dis,
585 "release-val": selection["release"],
586 "help-dis": C.STYLE_DISPLAY,
587 "help-val": selection["help"],
588 "search-dis": C.STYLE_DISPLAY,
589 "search-val": selection["regexp"]
592 except (KeyError, IndexError, AttributeError, ValueError):
594 elif trigger.type == "ctrl-dd":
595 if trigger.idx == "datatype":
597 data_type = self._duts[trigger.value]
598 options = generate_options(data_type.keys())
599 disabled = C.STYLE_DISPLAY
602 disabled = C.STYLE_DONT_DISPLAY
604 "datatype-val": trigger.value,
608 "release-opt": list(),
609 "release-dis": C.STYLE_DONT_DISPLAY,
610 "release-val": str(),
611 "help-dis": C.STYLE_DONT_DISPLAY,
613 "search-dis": C.STYLE_DONT_DISPLAY,
616 elif trigger.idx == "dut":
618 data_type = ctrl_panel.get("datatype-val")
619 dut = self._duts[data_type][trigger.value]
620 if data_type != "trending":
621 options = generate_options(dut)
622 disabled = C.STYLE_DISPLAY
625 disabled = C.STYLE_DONT_DISPLAY
626 if data_type == "trending":
628 "dut-val": trigger.value,
629 "release-opt": list(),
630 "release-dis": C.STYLE_DONT_DISPLAY,
631 "release-val": str(),
632 "help-dis": disabled,
633 "help-val": "<testbed> <nic> <driver> " + \
634 "<framesize> <cores> <test>",
635 "search-dis": disabled,
640 "dut-val": trigger.value,
641 "release-opt": options,
642 "release-dis": disabled,
643 "release-val": str(),
644 "help-dis": C.STYLE_DONT_DISPLAY,
646 "search-dis": C.STYLE_DONT_DISPLAY,
649 elif trigger.idx == "release":
651 "release-val": trigger.value,
652 "help-dis": C.STYLE_DISPLAY,
653 "help-val": "<DUT version> <testbed> <nic> " + \
654 "<driver> <framesize> <core> <test>",
655 "search-dis": C.STYLE_DISPLAY,
658 elif trigger.idx == "search":
659 ctrl_panel.set({"search-val": trigger.value})
661 "datatype": ctrl_panel.get("datatype-val"),
662 "dut": ctrl_panel.get("dut-val"),
663 "release": ctrl_panel.get("release-val"),
664 "help": ctrl_panel.get("help-val"),
665 "regexp": ctrl_panel.get("search-val"),
668 elif trigger.type == "table" and trigger.idx == "search":
669 filtered_data = filter_table_data(
673 table_data = [filtered_data, ]
676 table = search_table(data=self._data, selection=selection)
677 plotting_area = Layout._get_plotting_area(
679 gen_new_url(parsed_url, {"selection": selection})
681 store_table_data = table.to_dict("records")
682 filtered_data = store_table_data
684 table_data = [store_table_data, ]
686 plotting_area = no_update
688 store["control-panel"] = ctrl_panel.panel
689 store["selection"] = selection
697 ret_val.extend(ctrl_panel.values)
702 Output("offcanvas-details", "is_open"),
703 Output("offcanvas-details", "children"),
704 State("store", "data"),
705 State("store-filtered-table-data", "data"),
706 Input({"type": "table", "index": ALL}, "active_cell"),
707 prevent_initial_call=True
709 def show_test_data(store, table, *_):
710 """Show offcanvas with graphs and tables based on selected test(s).
713 trigger = Trigger(callback_context.triggered)
714 if not trigger.value:
718 row = pd.DataFrame.from_records(table).\
719 iloc[[trigger.value["row"]]]
720 datatype = store["selection"]["datatype"]
721 dut = store["selection"]["dut"]
722 rls = store["selection"]["release"]
723 tb = row["Test Bed"].iloc[0]
724 nic = row["NIC"].iloc[0]
725 driver = row['Driver'].iloc[0]
726 test_name = row['Test'].iloc[0]
728 except(KeyError, IndexError, AttributeError, ValueError):
731 data = self._data[datatype]
732 if datatype == "trending":
733 df = pd.DataFrame(data.loc[data["dut_type"] == dut])
735 dutver = row["DUT Version"].iloc[0]
736 df = pd.DataFrame(data.loc[(
737 (data["dut_type"] == dut) &
738 (data["dut_version"] == dutver) &
739 (data["release"] == rls)
742 df = df[df.full_id.str.contains(
743 f".*{tb}.*{nic}.*{test_name}",
747 if datatype in ("trending", "iterative"):
748 l_test_id = df["test_id"].iloc[0].split(".")
752 area = ".".join(l_test_id[3:-2])
753 for drv in C.DRIVERS:
755 test = test_name.replace(f"{drv}-", "")
759 l_test = test.split("-")
760 testtype = l_test[-1]
761 if testtype == "ndrpdr":
762 testtype = ["ndr", "pdr"]
764 testtype = [testtype, ]
765 core = l_test[1] if l_test[1] else "8c"
766 test = "-".join(l_test[2: -1])
767 test_id = f"{tb}-{nic}-{driver}-{core}-{l_test[0]}-{test}"
769 class_name="g-0 p-0",
770 children=dbc.Alert(test_id, color="info"),
773 indexes = ("tput", "bandwidth", "lat")
774 if datatype == "trending":
775 for ttype in testtype:
777 "id": f"{dut}-{test_id}-{ttype}",
779 "phy": f"{tb}-{nic}-{driver}",
782 "framesize": l_test[0],
786 graphs = graph_trending(df, selected, self._graph_layout)
787 labels = ("Throughput", "Bandwidth", "Latency")
789 for graph, label, idx in zip(graphs, labels, indexes):
794 id={"type": "graph-trend", "index": idx},
801 dbc.Row(dbc.Tabs(tabs), class_name="g-0 p-0")
806 dbc.Row("No data.", class_name="g-0 p-0")
810 for ttype in testtype:
812 "id": f"{test_id}-{ttype}",
816 "phy": f"{tb}-{nic}-{driver}",
819 "framesize": l_test[0],
823 graphs = graph_iterative(df, selected, self._graph_layout)
825 for graph, idx in zip(graphs, indexes):
827 cols.append(dbc.Col(dcc.Graph(
829 id={"type": "graph-iter", "index": idx},
835 dbc.Row(class_name="g-0 p-0", children=cols)
838 elif datatype == "coverage":
839 ret_val = coverage_tables(
845 "phy": f"{tb}-{nic}-{driver}",
848 start_collapsed=False
856 Output("metadata-tput-lat", "children"),
857 Output("metadata-hdrh-graph", "children"),
858 Output("offcanvas-metadata", "is_open"),
859 Input({"type": "graph-trend", "index": ALL}, "clickData"),
860 Input({"type": "graph-iter", "index": ALL}, "clickData"),
861 prevent_initial_call=True
863 def _show_metadata_from_trend_graph(
867 """Generates the data for the offcanvas displayed when a particular
868 point in a graph is clicked on.
871 trigger = Trigger(callback_context.triggered)
872 if not trigger.value:
875 if trigger.type == "graph-trend":
876 return show_trending_graph_data(
877 trigger, trend_data, self._graph_layout)
878 elif trigger.type == "graph-iter":
879 return show_iterative_graph_data(
880 trigger, iter_data, self._graph_layout)
885 Output("plot-mod-url", "is_open"),
886 Input("plot-btn-url", "n_clicks"),
887 State("plot-mod-url", "is_open")
889 def toggle_plot_mod_url(n, is_open):
890 """Toggle the modal window with url.
897 Output("download-data", "data"),
898 State("store-filtered-table-data", "data"),
899 Input("plot-btn-download", "n_clicks"),
900 prevent_initial_call=True
902 def _download_search_data(selection, _):
903 """Download the data.
905 :param selection: Selected data in table format (records).
906 :type selection: dict
907 :returns: dict of data frame content (base64 encoded) and meta data
908 used by the Download component.
915 return dcc.send_data_frame(
916 pd.DataFrame.from_records(selection).to_csv,
917 C.SEARCH_DOWNLOAD_FILE_NAME
921 Output("offcanvas-documentation", "is_open"),
922 Input("btn-documentation", "n_clicks"),
923 State("offcanvas-documentation", "is_open")
925 def toggle_offcanvas_documentation(n_clicks, is_open):