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, sort_table_data, show_trending_graph_data, \
36 show_iterative_graph_data, show_tooltip
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="custom",
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": "table", "index": ALL}, "sort_by"),
542 Input({"type": "ctrl-dd", "index": ALL}, "value"),
543 prevent_initial_call=True
545 def _update_application(
547 store_table_data: list,
553 """Update the application when the event is detected.
558 "control-panel": dict(),
562 ctrl_panel = ControlPanel(
564 store.get("control-panel", dict())
566 selection = store["selection"]
568 plotting_area = no_update
572 parsed_url = url_decode(href)
574 url_params = parsed_url["params"]
578 trigger = Trigger(callback_context.triggered)
579 if trigger.type == "url" and url_params:
581 selection = literal_eval(url_params["selection"][0])
583 dtype = selection["datatype"]
584 dut = selection["dut"]
585 if dtype == "trending":
587 rls_dis = C.STYLE_DONT_DISPLAY
589 rls_opts = generate_options(self._duts[dtype][dut])
590 rls_dis = C.STYLE_DISPLAY
592 "datatype-val": dtype,
594 generate_options(self._duts[dtype].keys()),
595 "dut-dis": C.STYLE_DISPLAY,
597 "release-opt": rls_opts,
598 "release-dis": rls_dis,
599 "release-val": selection["release"],
600 "help-dis": C.STYLE_DISPLAY,
601 "help-val": selection["help"],
602 "search-dis": C.STYLE_DISPLAY,
603 "search-val": selection["regexp"]
606 except (KeyError, IndexError, AttributeError, ValueError):
608 elif trigger.type == "ctrl-dd":
609 if trigger.idx == "datatype":
611 data_type = self._duts[trigger.value]
612 options = generate_options(data_type.keys())
613 disabled = C.STYLE_DISPLAY
616 disabled = C.STYLE_DONT_DISPLAY
618 "datatype-val": trigger.value,
622 "release-opt": list(),
623 "release-dis": C.STYLE_DONT_DISPLAY,
624 "release-val": str(),
625 "help-dis": C.STYLE_DONT_DISPLAY,
627 "search-dis": C.STYLE_DONT_DISPLAY,
630 elif trigger.idx == "dut":
632 data_type = ctrl_panel.get("datatype-val")
633 dut = self._duts[data_type][trigger.value]
634 if data_type != "trending":
635 options = generate_options(dut)
636 disabled = C.STYLE_DISPLAY
639 disabled = C.STYLE_DONT_DISPLAY
640 if data_type == "trending":
642 "dut-val": trigger.value,
643 "release-opt": list(),
644 "release-dis": C.STYLE_DONT_DISPLAY,
645 "release-val": str(),
646 "help-dis": disabled,
647 "help-val": "<testbed> <nic> <driver> " + \
648 "<framesize> <cores> <test>",
649 "search-dis": disabled,
654 "dut-val": trigger.value,
655 "release-opt": options,
656 "release-dis": disabled,
657 "release-val": str(),
658 "help-dis": C.STYLE_DONT_DISPLAY,
660 "search-dis": C.STYLE_DONT_DISPLAY,
663 elif trigger.idx == "release":
665 "release-val": trigger.value,
666 "help-dis": C.STYLE_DISPLAY,
667 "help-val": "<DUT version> <testbed> <nic> " + \
668 "<driver> <framesize> <core> <test>",
669 "search-dis": C.STYLE_DISPLAY,
672 elif trigger.idx == "search":
673 ctrl_panel.set({"search-val": trigger.value})
675 "datatype": ctrl_panel.get("datatype-val"),
676 "dut": ctrl_panel.get("dut-val"),
677 "release": ctrl_panel.get("release-val"),
678 "help": ctrl_panel.get("help-val"),
679 "regexp": ctrl_panel.get("search-val"),
682 elif trigger.type == "table" and trigger.idx == "search":
683 if trigger.parameter == "filter_query":
684 filtered_data = filter_table_data(
688 elif trigger.parameter == "sort_by":
689 filtered_data = sort_table_data(
693 table_data = [filtered_data, ]
696 table = search_table(data=self._data, selection=selection)
697 plotting_area = Layout._get_plotting_area(
699 gen_new_url(parsed_url, {"selection": selection})
701 store_table_data = table.to_dict("records")
702 filtered_data = store_table_data
704 table_data = [store_table_data, ]
706 plotting_area = no_update
708 store["control-panel"] = ctrl_panel.panel
709 store["selection"] = selection
717 ret_val.extend(ctrl_panel.values)
722 Output("offcanvas-details", "is_open"),
723 Output("offcanvas-details", "children"),
724 State("store", "data"),
725 State("store-filtered-table-data", "data"),
726 Input({"type": "table", "index": ALL}, "active_cell"),
727 prevent_initial_call=True
729 def show_test_data(store, table, *_):
730 """Show offcanvas with graphs and tables based on selected test(s).
733 trigger = Trigger(callback_context.triggered)
734 if not trigger.value:
738 row = pd.DataFrame.from_records(table).\
739 iloc[[trigger.value["row"]]]
740 datatype = store["selection"]["datatype"]
741 dut = store["selection"]["dut"]
742 rls = store["selection"]["release"]
743 tb = row["Test Bed"].iloc[0]
744 nic = row["NIC"].iloc[0]
745 driver = row["Driver"].iloc[0]
746 test_name = row["Test"].iloc[0]
748 except(KeyError, IndexError, AttributeError, ValueError):
751 data = self._data[datatype]
752 if datatype == "trending":
753 df = pd.DataFrame(data.loc[data["dut_type"] == dut])
755 dutver = row["DUT Version"].iloc[0]
756 df = pd.DataFrame(data.loc[(
757 (data["dut_type"] == dut) &
758 (data["dut_version"] == dutver) &
759 (data["release"] == rls)
762 df = df[df.full_id.str.contains(
763 f".*{tb}.*{nic}.*{test_name}",
767 if datatype in ("trending", "iterative"):
768 l_test_id = df["test_id"].iloc[0].split(".")
772 area = ".".join(l_test_id[3:-2])
773 for drv in C.DRIVERS:
775 test = test_name.replace(f"{drv}-", "")
779 l_test = test.split("-")
780 testtype = l_test[-1]
781 if testtype == "ndrpdr":
782 testtype = ["ndr", "pdr"]
784 testtype = [testtype, ]
785 core = l_test[1] if l_test[1] else "8c"
786 test = "-".join(l_test[2: -1])
787 test_id = f"{tb}-{nic}-{driver}-{l_test[0]}-{core}-{test}"
789 class_name="g-0 p-0",
790 children=dbc.Alert(test_id, color="info"),
793 indexes = ("tput", "bandwidth", "lat")
794 if datatype == "trending":
795 for ttype in testtype:
797 "id": f"{dut}-{test_id}-{ttype}",
799 "phy": f"{tb}-{nic}-{driver}",
802 "framesize": l_test[0],
806 graphs = graph_trending(df, selected, self._graph_layout)
807 labels = ("Throughput", "Bandwidth", "Latency")
809 for graph, label, idx in zip(graphs, labels, indexes):
814 id={"type": "graph-trend", "index": idx},
821 dbc.Row(dbc.Tabs(tabs), class_name="g-0 p-0")
826 dbc.Row("No data.", class_name="g-0 p-0")
830 for ttype in testtype:
832 "id": f"{test_id}-{ttype}",
836 "phy": f"{tb}-{nic}-{driver}",
839 "framesize": l_test[0],
843 graphs = graph_iterative(df, selected, self._graph_layout)
845 for graph, idx in zip(graphs, indexes):
847 cols.append(dbc.Col(dcc.Graph(
849 id={"type": "graph-iter", "index": idx},
855 dbc.Row(class_name="g-0 p-0", children=cols)
858 elif datatype == "coverage":
859 ret_val = coverage_tables(
865 "phy": f"{tb}-{nic}-{driver}",
868 start_collapsed=False
876 Output("metadata-tput-lat", "children"),
877 Output("metadata-hdrh-graph", "children"),
878 Output("offcanvas-metadata", "is_open"),
879 Input({"type": "graph-trend", "index": ALL}, "clickData"),
880 Input({"type": "graph-iter", "index": ALL}, "clickData"),
881 prevent_initial_call=True
883 def _show_metadata_from_graph(
887 """Generates the data for the offcanvas displayed when a particular
888 point in a graph is clicked on.
891 trigger = Trigger(callback_context.triggered)
892 if not trigger.value:
895 if trigger.type == "graph-trend":
896 return show_trending_graph_data(
897 trigger, trend_data, self._graph_layout)
898 elif trigger.type == "graph-iter":
899 return show_iterative_graph_data(
900 trigger, iter_data, self._graph_layout)
905 Output("plot-mod-url", "is_open"),
906 Input("plot-btn-url", "n_clicks"),
907 State("plot-mod-url", "is_open")
909 def toggle_plot_mod_url(n, is_open):
910 """Toggle the modal window with url.
917 Output("download-data", "data"),
918 State("store-filtered-table-data", "data"),
919 Input("plot-btn-download", "n_clicks"),
920 prevent_initial_call=True
922 def _download_search_data(selection, _):
923 """Download the data.
925 :param selection: Selected data in table format (records).
926 :type selection: dict
927 :returns: dict of data frame content (base64 encoded) and meta data
928 used by the Download component.
935 return dcc.send_data_frame(
936 pd.DataFrame.from_records(selection).to_csv,
937 C.SEARCH_DOWNLOAD_FILE_NAME
941 Output("offcanvas-documentation", "is_open"),
942 Input("btn-documentation", "n_clicks"),
943 State("offcanvas-documentation", "is_open")
945 def toggle_offcanvas_documentation(n_clicks, is_open):