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