C-Dash: Add detailed views to comparison tables
[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, 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
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="custom",
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": "table", "index": ALL}, "sort_by"),
542             Input({"type": "ctrl-dd", "index": ALL}, "value"),
543             prevent_initial_call=True
544         )
545         def _update_application(
546                 store: dict,
547                 store_table_data: list,
548                 filtered_data: list,
549                 table_data: list,
550                 href: str,
551                 *_
552             ) -> tuple:
553             """Update the application when the event is detected.
554             """
555
556             if store is None:
557                 store = {
558                     "control-panel": dict(),
559                     "selection": dict()
560                 }
561
562             ctrl_panel = ControlPanel(
563                 CP_PARAMS,
564                 store.get("control-panel", dict())
565             )
566             selection = store["selection"]
567
568             plotting_area = no_update
569             on_draw = False
570
571             # Parse the url:
572             parsed_url = url_decode(href)
573             if parsed_url:
574                 url_params = parsed_url["params"]
575             else:
576                 url_params = None
577
578             trigger = Trigger(callback_context.triggered)
579             if trigger.type == "url" and url_params:
580                 try:
581                     selection = literal_eval(url_params["selection"][0])
582                     if selection:
583                         dtype = selection["datatype"]
584                         dut = selection["dut"]
585                         if dtype == "trending":
586                             rls_opts = list()
587                             rls_dis = C.STYLE_DONT_DISPLAY
588                         else:
589                             rls_opts = generate_options(self._duts[dtype][dut])
590                             rls_dis = C.STYLE_DISPLAY
591                         ctrl_panel.set({
592                             "datatype-val": dtype,
593                             "dut-opt": \
594                                 generate_options(self._duts[dtype].keys()),
595                             "dut-dis": C.STYLE_DISPLAY,
596                             "dut-val": dut,
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"]
604                         })
605                         on_draw = True
606                 except (KeyError, IndexError, AttributeError, ValueError):
607                     pass
608             elif trigger.type == "ctrl-dd":
609                 if trigger.idx == "datatype":
610                     try:
611                         data_type = self._duts[trigger.value]
612                         options = generate_options(data_type.keys())
613                         disabled = C.STYLE_DISPLAY
614                     except KeyError:
615                         options = list()
616                         disabled = C.STYLE_DONT_DISPLAY
617                     ctrl_panel.set({
618                         "datatype-val": trigger.value,
619                         "dut-opt": options,
620                         "dut-dis": disabled,
621                         "dut-val": str(),
622                         "release-opt": list(),
623                         "release-dis": C.STYLE_DONT_DISPLAY,
624                         "release-val": str(),
625                         "help-dis": C.STYLE_DONT_DISPLAY,
626                         "help-val": str(),
627                         "search-dis": C.STYLE_DONT_DISPLAY,
628                         "search-val": str()
629                     })
630                 elif trigger.idx == "dut":
631                     try:
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
637                     except KeyError:
638                         options = list()
639                         disabled = C.STYLE_DONT_DISPLAY
640                     if data_type == "trending":
641                         ctrl_panel.set({
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,
650                             "search-val": str()
651                         })
652                     else:
653                         ctrl_panel.set({
654                             "dut-val": trigger.value,
655                             "release-opt": options,
656                             "release-dis": disabled,
657                             "release-val": str(),
658                             "help-dis": C.STYLE_DONT_DISPLAY,
659                             "help-val": str(),
660                             "search-dis": C.STYLE_DONT_DISPLAY,
661                             "search-val": str()
662                         })
663                 elif trigger.idx == "release":
664                     ctrl_panel.set({
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,
670                         "search-val": str()
671                     })
672                 elif trigger.idx == "search":
673                     ctrl_panel.set({"search-val": trigger.value})
674                     selection = {
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"),
680                     }
681                     on_draw = True
682             elif trigger.type == "table" and trigger.idx == "search":
683                 if trigger.parameter == "filter_query":
684                     filtered_data = filter_table_data(
685                         store_table_data,
686                         trigger.value
687                     )
688                 elif trigger.parameter == "sort_by":
689                     filtered_data = sort_table_data(
690                         store_table_data,
691                         trigger.value
692                     )
693                 table_data = [filtered_data, ]
694
695             if on_draw:
696                 table = search_table(data=self._data, selection=selection)
697                 plotting_area = Layout._get_plotting_area(
698                     table,
699                     gen_new_url(parsed_url, {"selection": selection})
700                 )
701                 store_table_data = table.to_dict("records")
702                 filtered_data = store_table_data
703                 if table_data:
704                     table_data = [store_table_data, ]
705             else:
706                 plotting_area = no_update
707
708             store["control-panel"] = ctrl_panel.panel
709             store["selection"] = selection
710             ret_val = [
711                 store,
712                 store_table_data,
713                 filtered_data,
714                 plotting_area,
715                 table_data
716             ]
717             ret_val.extend(ctrl_panel.values)
718
719             return ret_val
720
721         @app.callback(
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
728         )
729         def show_test_data(store, table, *_):
730             """Show offcanvas with graphs and tables based on selected test(s).
731             """
732
733             trigger = Trigger(callback_context.triggered)
734             if not trigger.value:
735                 raise PreventUpdate
736
737             try:
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]
747                 dutver = str()
748             except(KeyError, IndexError, AttributeError, ValueError):
749                 raise PreventUpdate
750
751             data = self._data[datatype]
752             if datatype == "trending":
753                 df = pd.DataFrame(data.loc[data["dut_type"] == dut])
754             else:
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)
760                 )])
761
762             df = df[df.full_id.str.contains(
763                 f".*{tb}.*{nic}.*{test_name}",
764                 regex=True
765             )]
766
767             if datatype in ("trending", "iterative"):
768                 l_test_id = df["test_id"].iloc[0].split(".")
769                 if dut == "dpdk":
770                     area = "dpdk"
771                 else:
772                     area = ".".join(l_test_id[3:-2])
773                 for drv in C.DRIVERS:
774                     if drv in test_name:
775                         test = test_name.replace(f"{drv}-", "")
776                         break
777                 else:
778                     test = test_name
779                 l_test = test.split("-")
780                 testtype = l_test[-1]
781                 if testtype == "ndrpdr":
782                     testtype = ["ndr", "pdr"]
783                 else:
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}"
788                 title = dbc.Row(
789                     class_name="g-0 p-0",
790                     children=dbc.Alert(test_id, color="info"),
791                 )
792                 selected = list()
793                 indexes = ("tput", "bandwidth", "lat")
794                 if datatype == "trending":
795                     for ttype in testtype:
796                         selected.append({
797                             "id": f"{dut}-{test_id}-{ttype}",
798                             "dut": dut,
799                             "phy": f"{tb}-{nic}-{driver}",
800                             "area": area,
801                             "test": test,
802                             "framesize": l_test[0],
803                             "core": core,
804                             "testtype": ttype
805                         })
806                     graphs = graph_trending(df, selected, self._graph_layout)
807                     labels = ("Throughput", "Bandwidth", "Latency")
808                     tabs = list()
809                     for graph, label, idx in zip(graphs, labels, indexes):
810                         if graph:
811                             tabs.append(dbc.Tab(
812                                 children=dcc.Graph(
813                                     figure=graph,
814                                     id={"type": "graph-trend", "index": idx},
815                                 ),
816                                 label=label
817                             ))
818                     if tabs:
819                         ret_val = [
820                             title,
821                             dbc.Row(dbc.Tabs(tabs), class_name="g-0 p-0")
822                         ]
823                     else:
824                         ret_val = [
825                             title,
826                             dbc.Row("No data.", class_name="g-0 p-0")
827                         ]
828
829                 else:  # Iterative
830                     for ttype in testtype:
831                         selected.append({
832                             "id": f"{test_id}-{ttype}",
833                             "rls": rls,
834                             "dut": dut,
835                             "dutver": dutver,
836                             "phy": f"{tb}-{nic}-{driver}",
837                             "area": area,
838                             "test": test,
839                             "framesize": l_test[0],
840                             "core": core,
841                             "testtype": ttype
842                         })
843                     graphs = graph_iterative(df, selected, self._graph_layout)
844                     cols = list()
845                     for graph, idx in zip(graphs, indexes):
846                         if graph:
847                             cols.append(dbc.Col(dcc.Graph(
848                                 figure=graph,
849                                 id={"type": "graph-iter", "index": idx},
850                             )))
851                     if not cols:
852                         cols="No data."
853                     ret_val = [
854                         title,
855                         dbc.Row(class_name="g-0 p-0", children=cols)
856                     ]
857
858             elif datatype == "coverage":
859                 ret_val = coverage_tables(
860                     data=df,
861                     selected={
862                         "rls": rls,
863                         "dut": dut,
864                         "dutver": dutver,
865                         "phy": f"{tb}-{nic}-{driver}",
866                         "area": ".*",
867                     },
868                     start_collapsed=False
869                 )
870             else:
871                 raise PreventUpdate
872
873             return True, ret_val
874
875         @app.callback(
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
882         )
883         def _show_metadata_from_graph(
884                 trend_data: dict,
885                 iter_data: dict
886             ) -> tuple:
887             """Generates the data for the offcanvas displayed when a particular
888             point in a graph is clicked on.
889             """
890
891             trigger = Trigger(callback_context.triggered)
892             if not trigger.value:
893                 raise PreventUpdate
894
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)
901             else:
902                 raise PreventUpdate
903
904         @app.callback(
905             Output("plot-mod-url", "is_open"),
906             Input("plot-btn-url", "n_clicks"),
907             State("plot-mod-url", "is_open")
908         )
909         def toggle_plot_mod_url(n, is_open):
910             """Toggle the modal window with url.
911             """
912             if n:
913                 return not is_open
914             return is_open
915
916         @app.callback(
917             Output("download-data", "data"),
918             State("store-filtered-table-data", "data"),
919             Input("plot-btn-download", "n_clicks"),
920             prevent_initial_call=True
921         )
922         def _download_search_data(selection, _):
923             """Download the data.
924
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.
929             :rtype: dict
930             """
931
932             if not selection:
933                 raise PreventUpdate
934
935             return dcc.send_data_frame(
936                 pd.DataFrame.from_records(selection).to_csv,
937                 C.SEARCH_DOWNLOAD_FILE_NAME
938             )
939
940         @app.callback(
941             Output("offcanvas-documentation", "is_open"),
942             Input("btn-documentation", "n_clicks"),
943             State("offcanvas-documentation", "is_open")
944         )
945         def toggle_offcanvas_documentation(n_clicks, is_open):
946             if n_clicks:
947                 return not is_open
948             return is_open