2c50fba35297b1eae6ed4f77e8209cb1664125bf
[csit.git] / csit.infra.dash / app / cdash / search / layout.py
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:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
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.
13
14
15 """Plotly Dash HTML layout override.
16 """
17
18 import logging
19 import pandas as pd
20 import dash_bootstrap_components as dbc
21
22 from flask import Flask
23 from dash import dcc
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
30
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
41
42
43 # Control panel partameters and their default values.
44 CP_PARAMS = {
45     "datatype-val": str(),
46     "dut-opt": list(),
47     "dut-dis": C.STYLE_DONT_DISPLAY,
48     "dut-val": str(),
49     "release-opt": list(),
50     "release-dis": C.STYLE_DONT_DISPLAY,
51     "release-val": str(),
52     "help-dis": C.STYLE_DONT_DISPLAY,
53     "help-val": str(),
54     "search-dis": C.STYLE_DONT_DISPLAY,
55     "search-val": str()
56 }
57
58
59 class Layout:
60     """The layout of the dash app and the callbacks.
61     """
62
63     def __init__(self,
64             app: Flask,
65             data: dict,
66             html_layout_file: str,
67             graph_layout_file: str,
68             tooltip_file: str
69         ) -> None:
70         """Initialization:
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.
77
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
83             plot.ly graphs.
84         :param tooltip_file: Path and name of the yaml file specifying the
85             tooltips.
86         :type app: Flask
87         :type data_trending: pandas.DataFrame
88         :type html_layout_file: str
89         :type graph_layout_file: str
90         :type tooltip_file: str
91         """
92
93         # Inputs
94         self._app = app
95         self._html_layout_file = html_layout_file
96         self._graph_layout_file = graph_layout_file
97         self._tooltip_file = tooltip_file
98         # Inputs - Data
99         self._data = {
100             k: v for k, v in data.items() if not v.empty and k != "statistics"
101         }
102
103         for data_type, pd in self._data.items():
104             if pd.empty:
105                 continue
106             full_id = list()
107
108             for _, row in pd.iterrows():
109                 l_id = row["test_id"].split(".")
110                 suite = l_id[-2].replace("2n1l-", "").replace("1n1l-", "").\
111                     replace("2n-", "")
112                 tb = "-".join(row["job"].split("-")[-2:])
113                 nic = suite.split("-")[0]
114                 for driver in C.DRIVERS:
115                     if driver in suite:
116                         drv = driver
117                         break
118                 else:
119                     drv = "dpdk"
120                 test = l_id[-1]
121
122                 if data_type in ("iterative", "coverage", ):
123                     full_id.append(
124                         "_".join((row["release"], row["dut_type"],
125                             row["dut_version"], tb, nic, drv, test))
126                     )
127                 else:  # Trending
128                     full_id.append(
129                         "_".join((row["dut_type"], tb, nic, drv, test))
130                     )
131             pd["full_id"] = full_id
132
133         # Get structure of tests:
134         self._duts = dict()
135         for data_type, pd in self._data.items():
136             if pd.empty:
137                 continue
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"])
147             else:
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()
151
152         # Read from files:
153         self._html_layout = str()
154         self._graph_layout = None
155         self._tooltips = dict()
156
157         try:
158             with open(self._html_layout_file, "r") as file_read:
159                 self._html_layout = file_read.read()
160         except IOError as err:
161             raise RuntimeError(
162                 f"Not possible to open the file {self._html_layout_file}\n{err}"
163             )
164
165         try:
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:
169             raise RuntimeError(
170                 f"Not possible to open the file {self._graph_layout_file}\n"
171                 f"{err}"
172             )
173         except YAMLError as err:
174             raise RuntimeError(
175                 f"An error occurred while parsing the specification file "
176                 f"{self._graph_layout_file}\n{err}"
177             )
178
179         try:
180             with open(self._tooltip_file, "r") as file_read:
181                 self._tooltips = load(file_read, Loader=FullLoader)
182         except IOError as err:
183             logging.warning(
184                 f"Not possible to open the file {self._tooltip_file}\n{err}"
185             )
186         except YAMLError as err:
187             logging.warning(
188                 f"An error occurred while parsing the specification file "
189                 f"{self._tooltip_file}\n{err}"
190             )
191
192         # Callbacks:
193         if self._app is not None and hasattr(self, "callbacks"):
194             self.callbacks(self._app)
195
196     @property
197     def html_layout(self):
198         return self._html_layout
199
200     def add_content(self):
201         """Top level method which generated the web page.
202
203         It generates:
204         - Store for user input data,
205         - Navigation bar,
206         - Main area with control panel and ploting area.
207
208         If no HTML layout is provided, an error message is displayed instead.
209
210         :returns: The HTML div with the whole page.
211         :rtype: html.Div
212         """
213         if self.html_layout and self._duts:
214             return html.Div(
215                 id="div-main",
216                 className="small",
217                 children=[
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),
222                     dbc.Row(
223                         id="row-navbar",
224                         class_name="g-0",
225                         children=[navbar_trending((False, False, False, True))]
226                     ),
227                     dbc.Row(
228                         id="row-main",
229                         class_name="g-0",
230                         children=[
231                             self._add_ctrl_col(),
232                             self._add_plotting_col()
233                         ]
234                     ),
235                     dbc.Spinner(
236                         dbc.Offcanvas(
237                             class_name="w-75",
238                             id="offcanvas-details",
239                             title="Test Details",
240                             placement="end",
241                             is_open=False,
242                             children=[]
243                         ),
244                         delay_show=C.SPINNER_DELAY
245                     ),
246                     dbc.Spinner(
247                         dbc.Offcanvas(
248                             class_name="w-50",
249                             id="offcanvas-metadata",
250                             title="Detailed Information",
251                             placement="end",
252                             is_open=False,
253                             children=[
254                                 dbc.Row(id="metadata-tput-lat"),
255                                 dbc.Row(id="metadata-hdrh-graph")
256                             ]
257                         ),
258                         delay_show=C.SPINNER_DELAY
259                     ),
260                     dbc.Offcanvas(
261                         class_name="w-75",
262                         id="offcanvas-documentation",
263                         title="Documentation",
264                         placement="end",
265                         is_open=False,
266                         children=html.Iframe(
267                             src=C.URL_DOC_TRENDING,
268                             width="100%",
269                             height="100%"
270                         )
271                     )
272                 ]
273             )
274         else:
275             return html.Div(
276                 dbc.Alert("An Error Occured", color="danger"),
277                 id="div-main-error"
278             )
279
280     def _add_ctrl_col(self) -> dbc.Col:
281         """Add column with controls. It is placed on the left side.
282
283         :returns: Column with the control panel.
284         :rtype: dbc.Col
285         """
286         return dbc.Col(html.Div(self._add_ctrl_panel(), className="sticky-top"))
287
288     def _add_ctrl_panel(self) -> list:
289         """Add control panel.
290
291         :returns: Control panel.
292         :rtype: list
293         """
294         return [
295             dbc.Row(
296                 class_name="g-0 p-1",
297                 children=[
298                     dbc.InputGroup(
299                         [
300                             dbc.InputGroupText("Data Type"),
301                             dbc.Select(
302                                 id={"type": "ctrl-dd", "index": "datatype"},
303                                 placeholder="Select a Data Type...",
304                                 options=sorted(
305                                     [
306                                         {"label": k, "value": k} \
307                                             for k in self._data.keys()
308                                     ],
309                                     key=lambda d: d["label"]
310                                 )
311                             )
312                         ],
313                         size="sm"
314                     )
315                 ],
316                 style=C.STYLE_DISPLAY
317             ),
318             dbc.Row(
319                 class_name="g-0 p-1",
320                 id={"type": "ctrl-row", "index": "dut"},
321                 children=[
322                     dbc.InputGroup(
323                         [
324                             dbc.InputGroupText("DUT"),
325                             dbc.Select(
326                                 id={"type": "ctrl-dd", "index": "dut"},
327                                 placeholder="Select a Device under Test..."
328                             )
329                         ],
330                         size="sm"
331                     )
332                 ],
333                 style=C.STYLE_DONT_DISPLAY
334             ),
335             dbc.Row(
336                 class_name="g-0 p-1",
337                 id={"type": "ctrl-row", "index": "release"},
338                 children=[
339                     dbc.InputGroup(
340                         [
341                             dbc.InputGroupText("Release"),
342                             dbc.Select(
343                                 id={"type": "ctrl-dd", "index": "release"},
344                                 placeholder="Select a Release..."
345                             )
346                         ],
347                         size="sm"
348                     )
349                 ],
350                 style=C.STYLE_DONT_DISPLAY
351             ),
352             dbc.Row(
353                 class_name="g-0 p-1",
354                 id={"type": "ctrl-row", "index": "help"},
355                 children=[
356                     dbc.Input(
357                         id={"type": "ctrl-dd", "index": "help"},
358                         readonly=True,
359                         debounce=True,
360                         size="sm"
361                     )
362                 ],
363                 style=C.STYLE_DONT_DISPLAY
364             ),
365             dbc.Row(
366                 class_name="g-0 p-1",
367                 id={"type": "ctrl-row", "index": "search"},
368                 children=[
369                     dbc.Input(
370                         id={"type": "ctrl-dd", "index": "search"},
371                         placeholder="Type a Regular Expression...",
372                         debounce=True,
373                         size="sm"
374                     )
375                 ],
376                 style=C.STYLE_DONT_DISPLAY
377             )
378         ]
379
380     def _add_plotting_col(self) -> dbc.Col:
381         """Add column with tables. It is placed on the right side.
382
383         :returns: Column with tables.
384         :rtype: dbc.Col
385         """
386         return dbc.Col(
387             id="col-plotting-area",
388             children=[
389                 dbc.Spinner(
390                     children=[
391                         dbc.Row(
392                             id="plotting-area",
393                             class_name="g-0 p-0",
394                             children=[C.PLACEHOLDER, ]
395                         )
396                     ]
397                 )
398             ],
399             width=9
400         )
401
402     @staticmethod
403     def _get_plotting_area(table: pd.DataFrame, url: str) -> list:
404         """Generate the plotting area with all its content.
405
406         :param table: Search table to be displayed.
407         :param url: URL to be displayed in a modal window.
408         :type table: pandas.DataFrame
409         :type url: str
410         :returns: List of rows with elements to be displayed in the plotting
411             area.
412         :rtype: list
413         """
414
415         if table.empty:
416             return dbc.Row(
417                 dbc.Col(
418                     children=dbc.Alert(
419                         "No data found.",
420                         color="danger"
421                     ),
422                     class_name="g-0 p-1",
423                 ),
424                 class_name="g-0 p-0"
425             )
426
427         columns = [{"name": col, "id": col} for col in table.columns]
428
429         return [
430             dbc.Row(
431                 children=[
432                     dbc.Col(
433                         children=dash_table.DataTable(
434                             id={"type": "table", "index": "search"},
435                             columns=columns,
436                             data=table.to_dict("records"),
437                             filter_action="custom",
438                             sort_action="native",
439                             sort_mode="multi",
440                             selected_columns=[],
441                             selected_rows=[],
442                             page_action="none",
443                             style_cell={"textAlign": "left"}
444                         ),
445                         class_name="g-0 p-1"
446                     )
447                 ],
448                 class_name="g-0 p-0"
449             ),
450             dbc.Row(
451                 [
452                     dbc.Col([html.Div(
453                         [
454                             dbc.Button(
455                                 id="plot-btn-url",
456                                 children="Show URL",
457                                 class_name="me-1",
458                                 color="info",
459                                 style={
460                                     "text-transform": "none",
461                                     "padding": "0rem 1rem"
462                                 }
463                             ),
464                             dbc.Modal(
465                                 [
466                                     dbc.ModalHeader(dbc.ModalTitle("URL")),
467                                     dbc.ModalBody(url)
468                                 ],
469                                 id="plot-mod-url",
470                                 size="xl",
471                                 is_open=False,
472                                 scrollable=True
473                             ),
474                             dbc.Button(
475                                 id="plot-btn-download",
476                                 children="Download Data",
477                                 class_name="me-1",
478                                 color="info",
479                                 style={
480                                     "text-transform": "none",
481                                     "padding": "0rem 1rem"
482                                 }
483                             ),
484                             dcc.Download(id="download-data")
485                         ],
486                         className=\
487                             "d-grid gap-0 d-md-flex justify-content-md-end"
488                     )])
489                 ],
490                 class_name="g-0 p-0"
491             ),
492             dbc.Row(
493                 children=C.PLACEHOLDER,
494                 class_name="g-0 p-1"
495             )
496         ]
497
498     def callbacks(self, app):
499         """Callbacks for the whole application.
500
501         :param app: The application.
502         :type app: Flask
503         """
504
505         @app.callback(
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
530         )
531         def _update_application(
532                 store: dict,
533                 store_table_data: list,
534                 filtered_data: list,
535                 table_data: list,
536                 href: str,
537                 *_
538             ) -> tuple:
539             """Update the application when the event is detected.
540             """
541
542             if store is None:
543                 store = {
544                     "control-panel": dict(),
545                     "selection": dict()
546                 }
547
548             ctrl_panel = ControlPanel(
549                 CP_PARAMS,
550                 store.get("control-panel", dict())
551             )
552             selection = store["selection"]
553
554             plotting_area = no_update
555             on_draw = False
556
557             # Parse the url:
558             parsed_url = url_decode(href)
559             if parsed_url:
560                 url_params = parsed_url["params"]
561             else:
562                 url_params = None
563
564             trigger = Trigger(callback_context.triggered)
565             if trigger.type == "url" and url_params:
566                 try:
567                     selection = literal_eval(url_params["selection"][0])
568                     if selection:
569                         dtype = selection["datatype"]
570                         dut = selection["dut"]
571                         if dtype == "trending":
572                             rls_opts = list()
573                             rls_dis = C.STYLE_DONT_DISPLAY
574                         else:
575                             rls_opts = generate_options(self._duts[dtype][dut])
576                             rls_dis = C.STYLE_DISPLAY
577                         ctrl_panel.set({
578                             "datatype-val": dtype,
579                             "dut-opt": \
580                                 generate_options(self._duts[dtype].keys()),
581                             "dut-dis": C.STYLE_DISPLAY,
582                             "dut-val": dut,
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"]
590                         })
591                         on_draw = True
592                 except (KeyError, IndexError, AttributeError, ValueError):
593                     pass
594             elif trigger.type == "ctrl-dd":
595                 if trigger.idx == "datatype":
596                     try:
597                         data_type = self._duts[trigger.value]
598                         options = generate_options(data_type.keys())
599                         disabled = C.STYLE_DISPLAY
600                     except KeyError:
601                         options = list()
602                         disabled = C.STYLE_DONT_DISPLAY
603                     ctrl_panel.set({
604                         "datatype-val": trigger.value,
605                         "dut-opt": options,
606                         "dut-dis": disabled,
607                         "dut-val": str(),
608                         "release-opt": list(),
609                         "release-dis": C.STYLE_DONT_DISPLAY,
610                         "release-val": str(),
611                         "help-dis": C.STYLE_DONT_DISPLAY,
612                         "help-val": str(),
613                         "search-dis": C.STYLE_DONT_DISPLAY,
614                         "search-val": str()
615                     })
616                 elif trigger.idx == "dut":
617                     try:
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
623                     except KeyError:
624                         options = list()
625                         disabled = C.STYLE_DONT_DISPLAY
626                     if data_type == "trending":
627                         ctrl_panel.set({
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,
636                             "search-val": str()
637                         })
638                     else:
639                         ctrl_panel.set({
640                             "dut-val": trigger.value,
641                             "release-opt": options,
642                             "release-dis": disabled,
643                             "release-val": str(),
644                             "help-dis": C.STYLE_DONT_DISPLAY,
645                             "help-val": str(),
646                             "search-dis": C.STYLE_DONT_DISPLAY,
647                             "search-val": str()
648                         })
649                 elif trigger.idx == "release":
650                     ctrl_panel.set({
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,
656                         "search-val": str()
657                     })
658                 elif trigger.idx == "search":
659                     ctrl_panel.set({"search-val": trigger.value})
660                     selection = {
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"),
666                     }
667                     on_draw = True
668             elif trigger.type == "table" and trigger.idx == "search":
669                 filtered_data = filter_table_data(
670                     store_table_data,
671                     trigger.value
672                 )
673                 table_data = [filtered_data, ]
674
675             if on_draw:
676                 table = search_table(data=self._data, selection=selection)
677                 plotting_area = Layout._get_plotting_area(
678                     table,
679                     gen_new_url(parsed_url, {"selection": selection})
680                 )
681                 store_table_data = table.to_dict("records")
682                 filtered_data = store_table_data
683                 if table_data:
684                     table_data = [store_table_data, ]
685             else:
686                 plotting_area = no_update
687
688             store["control-panel"] = ctrl_panel.panel
689             store["selection"] = selection
690             ret_val = [
691                 store,
692                 store_table_data,
693                 filtered_data,
694                 plotting_area,
695                 table_data
696             ]
697             ret_val.extend(ctrl_panel.values)
698
699             return ret_val
700
701         @app.callback(
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
708         )
709         def show_test_data(store, table, *_):
710             """Show offcanvas with graphs and tables based on selected test(s).
711             """
712
713             trigger = Trigger(callback_context.triggered)
714             if not trigger.value:
715                 raise PreventUpdate
716
717             try:
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]
727                 dutver = str()
728             except(KeyError, IndexError, AttributeError, ValueError):
729                 raise PreventUpdate
730
731             data = self._data[datatype]
732             if datatype == "trending":
733                 df = pd.DataFrame(data.loc[data["dut_type"] == dut])
734             else:
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)
740                 )])
741
742             df = df[df.full_id.str.contains(
743                 f".*{tb}.*{nic}.*{test_name}",
744                 regex=True
745             )]
746
747             if datatype in ("trending", "iterative"):
748                 l_test_id = df["test_id"].iloc[0].split(".")
749                 if dut == "dpdk":
750                     area = "dpdk"
751                 else:
752                     area = ".".join(l_test_id[3:-2])
753                 for drv in C.DRIVERS:
754                     if drv in test_name:
755                         test = test_name.replace(f"{drv}-", "")
756                         break
757                 else:
758                     test = test_name
759                 l_test = test.split("-")
760                 testtype = l_test[-1]
761                 if testtype == "ndrpdr":
762                     testtype = ["ndr", "pdr"]
763                 else:
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}"
768                 title = dbc.Row(
769                     class_name="g-0 p-0",
770                     children=dbc.Alert(test_id, color="info"),
771                 )
772                 selected = list()
773                 indexes = ("tput", "bandwidth", "lat")
774                 if datatype == "trending":
775                     for ttype in testtype:
776                         selected.append({
777                             "id": f"{dut}-{test_id}-{ttype}",
778                             "dut": dut,
779                             "phy": f"{tb}-{nic}-{driver}",
780                             "area": area,
781                             "test": test,
782                             "framesize": l_test[0],
783                             "core": core,
784                             "testtype": ttype
785                         })
786                     graphs = graph_trending(df, selected, self._graph_layout)
787                     labels = ("Throughput", "Bandwidth", "Latency")
788                     tabs = list()
789                     for graph, label, idx in zip(graphs, labels, indexes):
790                         if graph:
791                             tabs.append(dbc.Tab(
792                                 children=dcc.Graph(
793                                     figure=graph,
794                                     id={"type": "graph-trend", "index": idx},
795                                 ),
796                                 label=label
797                             ))
798                     if tabs:
799                         ret_val = [
800                             title,
801                             dbc.Row(dbc.Tabs(tabs), class_name="g-0 p-0")
802                         ]
803                     else:
804                         ret_val = [
805                             title,
806                             dbc.Row("No data.", class_name="g-0 p-0")
807                         ]
808
809                 else:  # Iterative
810                     for ttype in testtype:
811                         selected.append({
812                             "id": f"{test_id}-{ttype}",
813                             "rls": rls,
814                             "dut": dut,
815                             "dutver": dutver,
816                             "phy": f"{tb}-{nic}-{driver}",
817                             "area": area,
818                             "test": test,
819                             "framesize": l_test[0],
820                             "core": core,
821                             "testtype": ttype
822                         })
823                     graphs = graph_iterative(df, selected, self._graph_layout)
824                     cols = list()
825                     for graph, idx in zip(graphs, indexes):
826                         if graph:
827                             cols.append(dbc.Col(dcc.Graph(
828                                 figure=graph,
829                                 id={"type": "graph-iter", "index": idx},
830                             )))
831                     if not cols:
832                         cols="No data."
833                     ret_val = [
834                         title,
835                         dbc.Row(class_name="g-0 p-0", children=cols)
836                     ]
837
838             elif datatype == "coverage":
839                 ret_val = coverage_tables(
840                     data=df,
841                     selected={
842                         "rls": rls,
843                         "dut": dut,
844                         "dutver": dutver,
845                         "phy": f"{tb}-{nic}-{driver}",
846                         "area": ".*",
847                     },
848                     start_collapsed=False
849                 )
850             else:
851                 raise PreventUpdate
852
853             return True, ret_val
854
855         @app.callback(
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
862         )
863         def _show_metadata_from_trend_graph(
864                 trend_data: dict,
865                 iter_data: dict
866             ) -> tuple:
867             """Generates the data for the offcanvas displayed when a particular
868             point in a graph is clicked on.
869             """
870
871             trigger = Trigger(callback_context.triggered)
872             if not trigger.value:
873                 raise PreventUpdate
874
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)
881             else:
882                 raise PreventUpdate
883
884         @app.callback(
885             Output("plot-mod-url", "is_open"),
886             Input("plot-btn-url", "n_clicks"),
887             State("plot-mod-url", "is_open")
888         )
889         def toggle_plot_mod_url(n, is_open):
890             """Toggle the modal window with url.
891             """
892             if n:
893                 return not is_open
894             return is_open
895
896         @app.callback(
897             Output("download-data", "data"),
898             State("store-filtered-table-data", "data"),
899             Input("plot-btn-download", "n_clicks"),
900             prevent_initial_call=True
901         )
902         def _download_search_data(selection, _):
903             """Download the data.
904
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.
909             :rtype: dict
910             """
911
912             if not selection:
913                 raise PreventUpdate
914
915             return dcc.send_data_frame(
916                 pd.DataFrame.from_records(selection).to_csv,
917                 C.SEARCH_DOWNLOAD_FILE_NAME
918             )
919
920         @app.callback(
921             Output("offcanvas-documentation", "is_open"),
922             Input("btn-documentation", "n_clicks"),
923             State("offcanvas-documentation", "is_open")
924         )
925         def toggle_offcanvas_documentation(n_clicks, is_open):
926             if n_clicks:
927                 return not is_open
928             return is_open