C-Dash: Add bandwidth to iterative graphs
[csit.git] / csit.infra.dash / app / cdash / report / layout.py
1 # Copyright (c) 2023 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 """Plotly Dash HTML layout override.
15 """
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
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 show_tooltip, label, sync_checklists, gen_new_url, \
35     generate_options, get_list_group_items, graph_hdrh_latency
36 from ..utils.url_processing import url_decode
37 from .graphs import graph_iterative, select_iterative_data
38
39
40 # Control panel partameters and their default values.
41 CP_PARAMS = {
42     "dd-rls-val": str(),
43     "dd-dut-opt": list(),
44     "dd-dut-dis": True,
45     "dd-dut-val": str(),
46     "dd-dutver-opt": list(),
47     "dd-dutver-dis": True,
48     "dd-dutver-val": str(),
49     "dd-phy-opt": list(),
50     "dd-phy-dis": True,
51     "dd-phy-val": str(),
52     "dd-area-opt": list(),
53     "dd-area-dis": True,
54     "dd-area-val": str(),
55     "dd-test-opt": list(),
56     "dd-test-dis": True,
57     "dd-test-val": str(),
58     "cl-core-opt": list(),
59     "cl-core-val": list(),
60     "cl-core-all-val": list(),
61     "cl-core-all-opt": C.CL_ALL_DISABLED,
62     "cl-frmsize-opt": list(),
63     "cl-frmsize-val": list(),
64     "cl-frmsize-all-val": list(),
65     "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
66     "cl-tsttype-opt": list(),
67     "cl-tsttype-val": list(),
68     "cl-tsttype-all-val": list(),
69     "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
70     "btn-add-dis": True,
71     "cl-normalize-val": list()
72 }
73
74
75 class Layout:
76     """The layout of the dash app and the callbacks.
77     """
78
79     def __init__(
80             self,
81             app: Flask,
82             data_iterative: pd.DataFrame,
83             html_layout_file: str,
84             graph_layout_file: str,
85             tooltip_file: str
86         ) -> None:
87         """Initialization:
88         - save the input parameters,
89         - read and pre-process the data,
90         - prepare data for the control panel,
91         - read HTML layout file,
92         - read tooltips from the tooltip file.
93
94         :param app: Flask application running the dash application.
95         :param html_layout_file: Path and name of the file specifying the HTML
96             layout of the dash application.
97         :param graph_layout_file: Path and name of the file with layout of
98             plot.ly graphs.
99         :param tooltip_file: Path and name of the yaml file specifying the
100             tooltips.
101         :type app: Flask
102         :type html_layout_file: str
103         :type graph_layout_file: str
104         :type tooltip_file: str
105         """
106
107         # Inputs
108         self._app = app
109         self._html_layout_file = html_layout_file
110         self._graph_layout_file = graph_layout_file
111         self._tooltip_file = tooltip_file
112         self._data = data_iterative
113
114         # Get structure of tests:
115         tbs = dict()
116         cols = [
117             "job", "test_id", "test_type", "dut_version", "tg_type", "release"
118         ]
119         for _, row in self._data[cols].drop_duplicates().iterrows():
120             rls = row["release"]
121             lst_job = row["job"].split("-")
122             dut = lst_job[1]
123             d_ver = row["dut_version"]
124             tbed = "-".join(lst_job[-2:])
125             lst_test_id = row["test_id"].split(".")
126             if dut == "dpdk":
127                 area = "dpdk"
128             else:
129                 area = ".".join(lst_test_id[3:-2])
130             suite = lst_test_id[-2].replace("2n1l-", "").replace("1n1l-", "").\
131                 replace("2n-", "")
132             test = lst_test_id[-1]
133             nic = suite.split("-")[0]
134             for drv in C.DRIVERS:
135                 if drv in test:
136                     driver = drv.replace("-", "_")
137                     test = test.replace(f"{drv}-", "")
138                     break
139             else:
140                 driver = "dpdk"
141             infra = "-".join((tbed, nic, driver))
142             lst_test = test.split("-")
143             framesize = lst_test[0]
144             core = lst_test[1] if lst_test[1] else "8C"
145             test = "-".join(lst_test[2: -1])
146
147             if tbs.get(rls, None) is None:
148                 tbs[rls] = dict()
149             if tbs[rls].get(dut, None) is None:
150                 tbs[rls][dut] = dict()
151             if tbs[rls][dut].get(d_ver, None) is None:
152                 tbs[rls][dut][d_ver] = dict()
153             if tbs[rls][dut][d_ver].get(area, None) is None:
154                 tbs[rls][dut][d_ver][area] = dict()
155             if tbs[rls][dut][d_ver][area].get(test, None) is None:
156                 tbs[rls][dut][d_ver][area][test] = dict()
157             if tbs[rls][dut][d_ver][area][test].get(infra, None) is None:
158                 tbs[rls][dut][d_ver][area][test][infra] = {
159                     "core": list(),
160                     "frame-size": list(),
161                     "test-type": list()
162                 }
163             tst_params = tbs[rls][dut][d_ver][area][test][infra]
164             if core.upper() not in tst_params["core"]:
165                 tst_params["core"].append(core.upper())
166             if framesize.upper() not in tst_params["frame-size"]:
167                 tst_params["frame-size"].append(framesize.upper())
168             if row["test_type"] == "mrr":
169                 if "MRR" not in tst_params["test-type"]:
170                     tst_params["test-type"].append("MRR")
171             elif row["test_type"] == "ndrpdr":
172                 if "NDR" not in tst_params["test-type"]:
173                     tst_params["test-type"].extend(("NDR", "PDR", ))
174             elif row["test_type"] == "hoststack" and \
175                     row["tg_type"] in ("iperf", "vpp"):
176                 if "BPS" not in tst_params["test-type"]:
177                     tst_params["test-type"].append("BPS")
178             elif row["test_type"] == "hoststack" and row["tg_type"] == "ab":
179                 if "CPS" not in tst_params["test-type"]:
180                     tst_params["test-type"].extend(("CPS", "RPS"))
181         self._spec_tbs = tbs
182
183         # Read from files:
184         self._html_layout = str()
185         self._graph_layout = None
186         self._tooltips = dict()
187
188         try:
189             with open(self._html_layout_file, "r") as file_read:
190                 self._html_layout = file_read.read()
191         except IOError as err:
192             raise RuntimeError(
193                 f"Not possible to open the file {self._html_layout_file}\n{err}"
194             )
195
196         try:
197             with open(self._graph_layout_file, "r") as file_read:
198                 self._graph_layout = load(file_read, Loader=FullLoader)
199         except IOError as err:
200             raise RuntimeError(
201                 f"Not possible to open the file {self._graph_layout_file}\n"
202                 f"{err}"
203             )
204         except YAMLError as err:
205             raise RuntimeError(
206                 f"An error occurred while parsing the specification file "
207                 f"{self._graph_layout_file}\n{err}"
208             )
209
210         try:
211             with open(self._tooltip_file, "r") as file_read:
212                 self._tooltips = load(file_read, Loader=FullLoader)
213         except IOError as err:
214             logging.warning(
215                 f"Not possible to open the file {self._tooltip_file}\n{err}"
216             )
217         except YAMLError as err:
218             logging.warning(
219                 f"An error occurred while parsing the specification file "
220                 f"{self._tooltip_file}\n{err}"
221             )
222
223         # Callbacks:
224         if self._app is not None and hasattr(self, "callbacks"):
225             self.callbacks(self._app)
226
227     @property
228     def html_layout(self):
229         return self._html_layout
230
231     def add_content(self):
232         """Top level method which generated the web page.
233
234         It generates:
235         - Store for user input data,
236         - Navigation bar,
237         - Main area with control panel and ploting area.
238
239         If no HTML layout is provided, an error message is displayed instead.
240
241         :returns: The HTML div with the whole page.
242         :rtype: html.Div
243         """
244
245         if self.html_layout and self._spec_tbs:
246             return html.Div(
247                 id="div-main",
248                 className="small",
249                 children=[
250                     dbc.Row(
251                         id="row-navbar",
252                         class_name="g-0",
253                         children=[
254                             self._add_navbar()
255                         ]
256                     ),
257                     dbc.Row(
258                         id="row-main",
259                         class_name="g-0",
260                         children=[
261                             dcc.Store(id="store-selected-tests"),
262                             dcc.Store(id="store-control-panel"),
263                             dcc.Location(id="url", refresh=False),
264                             self._add_ctrl_col(),
265                             self._add_plotting_col()
266                         ]
267                     ),
268                     dbc.Spinner(
269                         dbc.Offcanvas(
270                             class_name="w-50",
271                             id="offcanvas-metadata",
272                             title="Throughput And Latency",
273                             placement="end",
274                             is_open=False,
275                             children=[
276                                 dbc.Row(id="metadata-tput-lat"),
277                                 dbc.Row(id="metadata-hdrh-graph")
278                             ]
279                         ),
280                         delay_show=C.SPINNER_DELAY
281                     ),
282                     dbc.Offcanvas(
283                         class_name="w-75",
284                         id="offcanvas-documentation",
285                         title="Documentation",
286                         placement="end",
287                         is_open=False,
288                         children=html.Iframe(
289                             src=C.URL_DOC_REL_NOTES,
290                             width="100%",
291                             height="100%"
292                         )
293                     )
294                 ]
295             )
296         else:
297             return html.Div(
298                 id="div-main-error",
299                 children=[
300                     dbc.Alert(
301                         [
302                             "An Error Occured"
303                         ],
304                         color="danger"
305                     )
306                 ]
307             )
308
309     def _add_navbar(self):
310         """Add nav element with navigation panel. It is placed on the top.
311
312         :returns: Navigation bar.
313         :rtype: dbc.NavbarSimple
314         """
315         return dbc.NavbarSimple(
316             id="navbarsimple-main",
317             children=[
318                 dbc.NavItem(dbc.NavLink(
319                     C.REPORT_TITLE,
320                     active=True,
321                     external_link=True,
322                     href="/report"
323                 )),
324                 dbc.NavItem(dbc.NavLink(
325                     "Comparisons",
326                     external_link=True,
327                     href="/comparisons"
328                 )),
329                 dbc.NavItem(dbc.NavLink(
330                     "Coverage Data",
331                     external_link=True,
332                     href="/coverage"
333                 )),
334                 dbc.NavItem(dbc.NavLink(
335                     "Documentation",
336                     id="btn-documentation",
337                 ))
338             ],
339             brand=C.BRAND,
340             brand_href="/",
341             brand_external_link=True,
342             class_name="p-2",
343             fluid=True
344         )
345
346     def _add_ctrl_col(self) -> dbc.Col:
347         """Add column with controls. It is placed on the left side.
348
349         :returns: Column with the control panel.
350         :rtype: dbc.Col
351         """
352         return dbc.Col([
353             html.Div(
354                 children=self._add_ctrl_panel(),
355                 className="sticky-top"
356             )
357         ])
358
359     def _add_plotting_col(self) -> dbc.Col:
360         """Add column with plots. It is placed on the right side.
361
362         :returns: Column with plots.
363         :rtype: dbc.Col
364         """
365         return dbc.Col(
366             id="col-plotting-area",
367             children=[
368                 dbc.Spinner(
369                     children=[
370                         dbc.Row(
371                             id="plotting-area",
372                             class_name="g-0 p-0",
373                             children=[
374                                 C.PLACEHOLDER
375                             ]
376                         )
377                     ]
378                 )
379             ],
380             width=9
381         )
382
383     def _add_ctrl_panel(self) -> list:
384         """Add control panel.
385
386         :returns: Control panel.
387         :rtype: list
388         """
389         return [
390             dbc.Row(
391                 class_name="g-0 p-1",
392                 children=[
393                     dbc.InputGroup(
394                         [
395                             dbc.InputGroupText(
396                                 children=show_tooltip(
397                                     self._tooltips,
398                                     "help-release",
399                                     "CSIT Release"
400                                 )
401                             ),
402                             dbc.Select(
403                                 id={"type": "ctrl-dd", "index": "rls"},
404                                 placeholder="Select a Release...",
405                                 options=sorted(
406                                     [
407                                         {"label": k, "value": k} \
408                                             for k in self._spec_tbs.keys()
409                                     ],
410                                     key=lambda d: d["label"]
411                                 )
412                             )
413                         ],
414                         size="sm"
415                     )
416                 ]
417             ),
418             dbc.Row(
419                 class_name="g-0 p-1",
420                 children=[
421                     dbc.InputGroup(
422                         [
423                             dbc.InputGroupText(
424                                 children=show_tooltip(
425                                     self._tooltips,
426                                     "help-dut",
427                                     "DUT"
428                                 )
429                             ),
430                             dbc.Select(
431                                 id={"type": "ctrl-dd", "index": "dut"},
432                                 placeholder="Select a Device under Test..."
433                             )
434                         ],
435                         size="sm"
436                     )
437                 ]
438             ),
439             dbc.Row(
440                 class_name="g-0 p-1",
441                 children=[
442                     dbc.InputGroup(
443                         [
444                             dbc.InputGroupText(
445                                 children=show_tooltip(
446                                     self._tooltips,
447                                     "help-dut-ver",
448                                     "DUT Version"
449                                 )
450                             ),
451                             dbc.Select(
452                                 id={"type": "ctrl-dd", "index": "dutver"},
453                                 placeholder=\
454                                     "Select a Version of Device under Test..."
455                             )
456                         ],
457                         size="sm"
458                     )
459                 ]
460             ),
461             dbc.Row(
462                 class_name="g-0 p-1",
463                 children=[
464                     dbc.InputGroup(
465                         [
466                             dbc.InputGroupText(
467                                 children=show_tooltip(
468                                     self._tooltips,
469                                     "help-area",
470                                     "Area"
471                                 )
472                             ),
473                             dbc.Select(
474                                 id={"type": "ctrl-dd", "index": "area"},
475                                 placeholder="Select an Area..."
476                             )
477                         ],
478                         size="sm"
479                     )
480                 ]
481             ),
482             dbc.Row(
483                 class_name="g-0 p-1",
484                 children=[
485                     dbc.InputGroup(
486                         [
487                             dbc.InputGroupText(
488                                 children=show_tooltip(
489                                     self._tooltips,
490                                     "help-test",
491                                     "Test"
492                                 )
493                             ),
494                             dbc.Select(
495                                 id={"type": "ctrl-dd", "index": "test"},
496                                 placeholder="Select a Test..."
497                             )
498                         ],
499                         size="sm"
500                     )
501                 ]
502             ),
503             dbc.Row(
504                 class_name="g-0 p-1",
505                 children=[
506                     dbc.InputGroup(
507                         [
508                             dbc.InputGroupText(
509                                 children=show_tooltip(
510                                     self._tooltips,
511                                     "help-infra",
512                                     "Infra"
513                                 )
514                             ),
515                             dbc.Select(
516                                 id={"type": "ctrl-dd", "index": "phy"},
517                                 placeholder=\
518                                     "Select a Physical Test Bed Topology..."
519                             )
520                         ],
521                         size="sm"
522                     )
523                 ]
524             ),
525             dbc.Row(
526                 class_name="g-0 p-1",
527                 children=[
528                     dbc.InputGroup(
529                         [
530                             dbc.InputGroupText(
531                                 children=show_tooltip(
532                                     self._tooltips,
533                                     "help-framesize",
534                                     "Frame Size"
535                                 )
536                             ),
537                             dbc.Col(
538                                 children=[
539                                     dbc.Checklist(
540                                         id={
541                                             "type": "ctrl-cl",
542                                             "index": "frmsize-all"
543                                         },
544                                         options=C.CL_ALL_DISABLED,
545                                         inline=True,
546                                         class_name="ms-2"
547                                     )
548                                 ],
549                                 width=2
550                             ),
551                             dbc.Col(
552                                 children=[
553                                     dbc.Checklist(
554                                         id={
555                                             "type": "ctrl-cl",
556                                             "index": "frmsize"
557                                         },
558                                         inline=True
559                                     )
560                                 ]
561                             )
562                         ],
563                         style={"align-items": "center"},
564                         size="sm"
565                     )
566                 ]
567             ),
568             dbc.Row(
569                 class_name="g-0 p-1",
570                 children=[
571                     dbc.InputGroup(
572                         [
573                             dbc.InputGroupText(
574                                 children=show_tooltip(
575                                     self._tooltips,
576                                     "help-cores",
577                                     "Number of Cores"
578                                 )
579                             ),
580                             dbc.Col(
581                                 children=[
582                                     dbc.Checklist(
583                                         id={
584                                             "type": "ctrl-cl",
585                                             "index": "core-all"
586                                         },
587                                         options=C.CL_ALL_DISABLED,
588                                         inline=True,
589                                         class_name="ms-2"
590                                     )
591                                 ],
592                                 width=2
593                             ),
594                             dbc.Col(
595                                 children=[
596                                     dbc.Checklist(
597                                         id={
598                                             "type": "ctrl-cl",
599                                             "index": "core"
600                                         },
601                                         inline=True
602                                     )
603                                 ]
604                             )
605                         ],
606                         style={"align-items": "center"},
607                         size="sm"
608                     )
609                 ]
610             ),
611             dbc.Row(
612                 class_name="g-0 p-1",
613                 children=[
614                     dbc.InputGroup(
615                         [
616                             dbc.InputGroupText(
617                                 children=show_tooltip(
618                                     self._tooltips,
619                                     "help-ttype",
620                                     "Test Type"
621                                 )
622                             ),
623                             dbc.Col(
624                                 children=[
625                                     dbc.Checklist(
626                                         id={
627                                             "type": "ctrl-cl",
628                                             "index": "tsttype-all"
629                                         },
630                                         options=C.CL_ALL_DISABLED,
631                                         inline=True,
632                                         class_name="ms-2"
633                                     )
634                                 ],
635                                 width=2
636                             ),
637                             dbc.Col(
638                                 children=[
639                                     dbc.Checklist(
640                                         id={
641                                             "type": "ctrl-cl",
642                                             "index": "tsttype"
643                                         },
644                                         inline=True
645                                     )
646                                 ]
647                             )
648                         ],
649                         style={"align-items": "center"},
650                         size="sm"
651                     )
652                 ]
653             ),
654             dbc.Row(
655                 class_name="g-0 p-1",
656                 children=[
657                     dbc.InputGroup(
658                         [
659                             dbc.InputGroupText(
660                                 children=show_tooltip(
661                                     self._tooltips,
662                                     "help-normalize",
663                                     "Normalization"
664                                 )
665                             ),
666                             dbc.Col(
667                                 children=[
668                                     dbc.Checklist(
669                                         id="normalize",
670                                         options=[{
671                                             "value": "normalize",
672                                             "label": (
673                                                 "Normalize to CPU frequency "
674                                                 "2GHz"
675                                             )
676                                         }],
677                                         value=[],
678                                         inline=True,
679                                         class_name="ms-2"
680                                     )
681                                 ]
682                             )
683                         ],
684                         style={"align-items": "center"},
685                         size="sm"
686                     )
687                 ]
688             ),
689             dbc.Row(
690                 class_name="g-0 p-1",
691                 children=[
692                     dbc.Button(
693                         id={"type": "ctrl-btn", "index": "add-test"},
694                         children="Add Selected",
695                         color="info"
696                     )
697                 ]
698             ),
699             dbc.Row(
700                 id="row-card-sel-tests",
701                 class_name="g-0 p-1",
702                 style=C.STYLE_DISABLED,
703                 children=[
704                     dbc.ListGroup(
705                         class_name="overflow-auto p-0",
706                         id="lg-selected",
707                         children=[],
708                         style={"max-height": "20em"},
709                         flush=True
710                     )
711                 ]
712             ),
713             dbc.Row(
714                 id="row-btns-sel-tests",
715                 class_name="g-0 p-1",
716                 style=C.STYLE_DISABLED,
717                 children=[
718                     dbc.ButtonGroup(
719                         children=[
720                             dbc.Button(
721                                 id={"type": "ctrl-btn", "index": "rm-test"},
722                                 children="Remove Selected",
723                                 class_name="w-100",
724                                 color="info",
725                                 disabled=False
726                             ),
727                             dbc.Button(
728                                 id={"type": "ctrl-btn", "index": "rm-test-all"},
729                                 children="Remove All",
730                                 class_name="w-100",
731                                 color="info",
732                                 disabled=False
733                             )
734                         ]
735                     )
736                 ]
737             )
738         ]
739
740     def _get_plotting_area(
741             self,
742             tests: list,
743             normalize: bool,
744             url: str
745         ) -> list:
746         """Generate the plotting area with all its content.
747
748         :param tests: List of tests to be displayed in the graphs.
749         :param normalize: If true, the values in graphs are normalized.
750         :param url: URL to be displayed in the modal window.
751         :type tests: list
752         :type normalize: bool
753         :type url: str
754         :returns: List of rows with elements to be displayed in the plotting
755             area.
756         :rtype: list
757         """
758         if not tests:
759             return C.PLACEHOLDER
760
761         graphs = \
762             graph_iterative(self._data, tests, self._graph_layout, normalize)
763
764         if not graphs[0]:
765             return C.PLACEHOLDER
766         
767         tab_items = [
768             dbc.Tab(
769                 children=dcc.Graph(
770                     id={"type": "graph", "index": "tput"},
771                     figure=graphs[0]
772                 ),
773                 label="Throughput",
774                 tab_id="tab-tput"
775             )
776         ]
777
778         if graphs[1]:
779             tab_items.append(
780                 dbc.Tab(
781                     children=dcc.Graph(
782                         id={"type": "graph", "index": "bandwidth"},
783                         figure=graphs[1]
784                     ),
785                     label="Bandwidth",
786                     tab_id="tab-bandwidth"
787                 )
788             )
789
790         if graphs[2]:
791             tab_items.append(
792                 dbc.Tab(
793                     children=dcc.Graph(
794                         id={"type": "graph", "index": "lat"},
795                         figure=graphs[2]
796                     ),
797                     label="Latency",
798                     tab_id="tab-lat"
799                 )
800             )
801
802         return [
803             dbc.Row(
804                 dbc.Tabs(
805                     children=tab_items,
806                     id="tabs",
807                     active_tab="tab-tput",
808                 ),
809                 class_name="g-0 p-0"
810             ),
811             dbc.Row(
812                 [
813                     dbc.Col([html.Div(
814                         [
815                             dbc.Button(
816                                 id="plot-btn-url",
817                                 children="Show URL",
818                                 class_name="me-1",
819                                 color="info",
820                                 style={
821                                     "text-transform": "none",
822                                     "padding": "0rem 1rem"
823                                 }
824                             ),
825                             dbc.Modal(
826                                 [
827                                     dbc.ModalHeader(dbc.ModalTitle("URL")),
828                                     dbc.ModalBody(url)
829                                 ],
830                                 id="plot-mod-url",
831                                 size="xl",
832                                 is_open=False,
833                                 scrollable=True
834                             ),
835                             dbc.Button(
836                                 id="plot-btn-download",
837                                 children="Download Data",
838                                 class_name="me-1",
839                                 color="info",
840                                 style={
841                                     "text-transform": "none",
842                                     "padding": "0rem 1rem"
843                                 }
844                             ),
845                             dcc.Download(id="download-iterative-data")
846                         ],
847                         className=\
848                             "d-grid gap-0 d-md-flex justify-content-md-end"
849                     )])
850                 ],
851                 class_name="g-0 p-0"
852             )
853         ]
854
855     def callbacks(self, app):
856         """Callbacks for the whole application.
857
858         :param app: The application.
859         :type app: Flask
860         """
861
862         @app.callback(
863             [
864                 Output("store-control-panel", "data"),
865                 Output("store-selected-tests", "data"),
866                 Output("plotting-area", "children"),
867                 Output("row-card-sel-tests", "style"),
868                 Output("row-btns-sel-tests", "style"),
869                 Output("lg-selected", "children"),
870
871                 Output({"type": "ctrl-dd", "index": "rls"}, "value"),
872                 Output({"type": "ctrl-dd", "index": "dut"}, "options"),
873                 Output({"type": "ctrl-dd", "index": "dut"}, "disabled"),
874                 Output({"type": "ctrl-dd", "index": "dut"}, "value"),
875                 Output({"type": "ctrl-dd", "index": "dutver"}, "options"),
876                 Output({"type": "ctrl-dd", "index": "dutver"}, "disabled"),
877                 Output({"type": "ctrl-dd", "index": "dutver"}, "value"),
878                 Output({"type": "ctrl-dd", "index": "phy"}, "options"),
879                 Output({"type": "ctrl-dd", "index": "phy"}, "disabled"),
880                 Output({"type": "ctrl-dd", "index": "phy"}, "value"),
881                 Output({"type": "ctrl-dd", "index": "area"}, "options"),
882                 Output({"type": "ctrl-dd", "index": "area"}, "disabled"),
883                 Output({"type": "ctrl-dd", "index": "area"}, "value"),
884                 Output({"type": "ctrl-dd", "index": "test"}, "options"),
885                 Output({"type": "ctrl-dd", "index": "test"}, "disabled"),
886                 Output({"type": "ctrl-dd", "index": "test"}, "value"),
887                 Output({"type": "ctrl-cl", "index": "core"}, "options"),
888                 Output({"type": "ctrl-cl", "index": "core"}, "value"),
889                 Output({"type": "ctrl-cl", "index": "core-all"}, "value"),
890                 Output({"type": "ctrl-cl", "index": "core-all"}, "options"),
891                 Output({"type": "ctrl-cl", "index": "frmsize"}, "options"),
892                 Output({"type": "ctrl-cl", "index": "frmsize"}, "value"),
893                 Output({"type": "ctrl-cl", "index": "frmsize-all"}, "value"),
894                 Output({"type": "ctrl-cl", "index": "frmsize-all"}, "options"),
895                 Output({"type": "ctrl-cl", "index": "tsttype"}, "options"),
896                 Output({"type": "ctrl-cl", "index": "tsttype"}, "value"),
897                 Output({"type": "ctrl-cl", "index": "tsttype-all"}, "value"),
898                 Output({"type": "ctrl-cl", "index": "tsttype-all"}, "options"),
899                 Output({"type": "ctrl-btn", "index": "add-test"}, "disabled"),
900                 Output("normalize", "value")
901             ],
902             [
903                 State("store-control-panel", "data"),
904                 State("store-selected-tests", "data"),
905                 State({"type": "sel-cl", "index": ALL}, "value")
906             ],
907             [
908                 Input("url", "href"),
909                 Input("normalize", "value"),
910
911                 Input({"type": "ctrl-dd", "index": ALL}, "value"),
912                 Input({"type": "ctrl-cl", "index": ALL}, "value"),
913                 Input({"type": "ctrl-btn", "index": ALL}, "n_clicks")
914             ]
915         )
916         def _update_application(
917                 control_panel: dict,
918                 store_sel: list,
919                 lst_sel: list,
920                 href: str,
921                 normalize: list,
922                 *_
923             ) -> tuple:
924             """Update the application when the event is detected.
925             """
926
927             ctrl_panel = ControlPanel(CP_PARAMS, control_panel)
928             on_draw = False
929
930             # Parse the url:
931             parsed_url = url_decode(href)
932             if parsed_url:
933                 url_params = parsed_url["params"]
934             else:
935                 url_params = None
936
937             plotting_area = no_update
938             row_card_sel_tests = no_update
939             row_btns_sel_tests = no_update
940             lg_selected = no_update
941
942             trigger = Trigger(callback_context.triggered)
943
944             if trigger.type == "url" and url_params:
945                 try:
946                     store_sel = literal_eval(url_params["store_sel"][0])
947                     normalize = literal_eval(url_params["norm"][0])
948                 except (KeyError, IndexError, AttributeError):
949                     pass
950                 if store_sel:
951                     row_card_sel_tests = C.STYLE_ENABLED
952                     row_btns_sel_tests = C.STYLE_ENABLED
953                     last_test = store_sel[-1]
954                     test = self._spec_tbs[last_test["rls"]][last_test["dut"]]\
955                         [last_test["dutver"]][last_test["area"]]\
956                             [last_test["test"]][last_test["phy"]]
957                     ctrl_panel.set({
958                         "dd-rls-val": last_test["rls"],
959                         "dd-dut-val": last_test["dut"],
960                         "dd-dut-opt": generate_options(
961                             self._spec_tbs[last_test["rls"]].keys()
962                         ),
963                         "dd-dut-dis": False,
964                         "dd-dutver-val": last_test["dutver"],
965                         "dd-dutver-opt": generate_options(
966                             self._spec_tbs[last_test["rls"]]\
967                                 [last_test["dut"]].keys()
968                         ),
969                         "dd-dutver-dis": False,
970                         "dd-area-val": last_test["area"],
971                         "dd-area-opt": [
972                             {"label": label(v), "value": v} for v in \
973                                 sorted(self._spec_tbs[last_test["rls"]]\
974                                     [last_test["dut"]]\
975                                         [last_test["dutver"]].keys())
976                         ],
977                         "dd-area-dis": False,
978                         "dd-test-val": last_test["test"],
979                         "dd-test-opt": generate_options(
980                             self._spec_tbs[last_test["rls"]][last_test["dut"]]\
981                                 [last_test["dutver"]][last_test["area"]].keys()
982                         ),
983                         "dd-test-dis": False,
984                         "dd-phy-val": last_test["phy"],
985                         "dd-phy-opt": generate_options(
986                             self._spec_tbs[last_test["rls"]][last_test["dut"]]\
987                                 [last_test["dutver"]][last_test["area"]]\
988                                     [last_test["test"]].keys()
989                         ),
990                         "dd-phy-dis": False,
991                         "cl-core-opt": generate_options(test["core"]),
992                         "cl-core-val": [last_test["core"].upper(), ],
993                         "cl-core-all-val": list(),
994                         "cl-core-all-opt": C.CL_ALL_ENABLED,
995                         "cl-frmsize-opt": generate_options(test["frame-size"]),
996                         "cl-frmsize-val": [last_test["framesize"].upper(), ],
997                         "cl-frmsize-all-val": list(),
998                         "cl-frmsize-all-opt": C.CL_ALL_ENABLED,
999                         "cl-tsttype-opt": generate_options(test["test-type"]),
1000                         "cl-tsttype-val": [last_test["testtype"].upper(), ],
1001                         "cl-tsttype-all-val": list(),
1002                         "cl-tsttype-all-opt": C.CL_ALL_ENABLED,
1003                         "cl-normalize-val": normalize,
1004                         "btn-add-dis": False
1005                     })
1006                     on_draw = True
1007             elif trigger.type == "normalize":
1008                 ctrl_panel.set({"cl-normalize-val": normalize})
1009                 on_draw = True
1010             elif trigger.type == "ctrl-dd":
1011                 if trigger.idx == "rls":
1012                     try:
1013                         options = generate_options(
1014                             self._spec_tbs[trigger.value].keys()
1015                         )
1016                         disabled = False
1017                     except KeyError:
1018                         options = list()
1019                         disabled = True
1020                     ctrl_panel.set({
1021                         "dd-rls-val": trigger.value,
1022                         "dd-dut-val": str(),
1023                         "dd-dut-opt": options,
1024                         "dd-dut-dis": disabled,
1025                         "dd-dutver-val": str(),
1026                         "dd-dutver-opt": list(),
1027                         "dd-dutver-dis": True,
1028                         "dd-phy-val": str(),
1029                         "dd-phy-opt": list(),
1030                         "dd-phy-dis": True,
1031                         "dd-area-val": str(),
1032                         "dd-area-opt": list(),
1033                         "dd-area-dis": True,
1034                         "dd-test-val": str(),
1035                         "dd-test-opt": list(),
1036                         "dd-test-dis": True,
1037                         "cl-core-opt": list(),
1038                         "cl-core-val": list(),
1039                         "cl-core-all-val": list(),
1040                         "cl-core-all-opt": C.CL_ALL_DISABLED,
1041                         "cl-frmsize-opt": list(),
1042                         "cl-frmsize-val": list(),
1043                         "cl-frmsize-all-val": list(),
1044                         "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
1045                         "cl-tsttype-opt": list(),
1046                         "cl-tsttype-val": list(),
1047                         "cl-tsttype-all-val": list(),
1048                         "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
1049                         "btn-add-dis": True
1050                     })
1051                 elif trigger.idx == "dut":
1052                     try:
1053                         rls = ctrl_panel.get("dd-rls-val")
1054                         dut = self._spec_tbs[rls][trigger.value]
1055                         options = generate_options(dut.keys())
1056                         disabled = False
1057                     except KeyError:
1058                         options = list()
1059                         disabled = True
1060                     ctrl_panel.set({
1061                         "dd-dut-val": trigger.value,
1062                         "dd-dutver-val": str(),
1063                         "dd-dutver-opt": options,
1064                         "dd-dutver-dis": disabled,
1065                         "dd-phy-val": str(),
1066                         "dd-phy-opt": list(),
1067                         "dd-phy-dis": True,
1068                         "dd-area-val": str(),
1069                         "dd-area-opt": list(),
1070                         "dd-area-dis": True,
1071                         "dd-test-val": str(),
1072                         "dd-test-opt": list(),
1073                         "dd-test-dis": True,
1074                         "cl-core-opt": list(),
1075                         "cl-core-val": list(),
1076                         "cl-core-all-val": list(),
1077                         "cl-core-all-opt": C.CL_ALL_DISABLED,
1078                         "cl-frmsize-opt": list(),
1079                         "cl-frmsize-val": list(),
1080                         "cl-frmsize-all-val": list(),
1081                         "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
1082                         "cl-tsttype-opt": list(),
1083                         "cl-tsttype-val": list(),
1084                         "cl-tsttype-all-val": list(),
1085                         "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
1086                         "btn-add-dis": True
1087                     })
1088                 elif trigger.idx == "dutver":
1089                     try:
1090                         rls = ctrl_panel.get("dd-rls-val")
1091                         dut = ctrl_panel.get("dd-dut-val")
1092                         dutver = self._spec_tbs[rls][dut][trigger.value]
1093                         options = [{"label": label(v), "value": v} \
1094                             for v in sorted(dutver.keys())]
1095                         disabled = False
1096                     except KeyError:
1097                         options = list()
1098                         disabled = True
1099                     ctrl_panel.set({
1100                         "dd-dutver-val": trigger.value,
1101                         "dd-area-val": str(),
1102                         "dd-area-opt": options,
1103                         "dd-area-dis": disabled,
1104                         "dd-test-val": str(),
1105                         "dd-test-opt": list(),
1106                         "dd-test-dis": True,
1107                         "dd-phy-val": str(),
1108                         "dd-phy-opt": list(),
1109                         "dd-phy-dis": True,
1110                         "cl-core-opt": list(),
1111                         "cl-core-val": list(),
1112                         "cl-core-all-val": list(),
1113                         "cl-core-all-opt": C.CL_ALL_DISABLED,
1114                         "cl-frmsize-opt": list(),
1115                         "cl-frmsize-val": list(),
1116                         "cl-frmsize-all-val": list(),
1117                         "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
1118                         "cl-tsttype-opt": list(),
1119                         "cl-tsttype-val": list(),
1120                         "cl-tsttype-all-val": list(),
1121                         "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
1122                         "btn-add-dis": True
1123                     })
1124                 elif trigger.idx == "area":
1125                     try:
1126                         rls = ctrl_panel.get("dd-rls-val")
1127                         dut = ctrl_panel.get("dd-dut-val")
1128                         dutver = ctrl_panel.get("dd-dutver-val")
1129                         area = self._spec_tbs[rls][dut][dutver][trigger.value]
1130                         options = generate_options(area.keys())
1131                         disabled = False
1132                     except KeyError:
1133                         options = list()
1134                         disabled = True
1135                     ctrl_panel.set({
1136                         "dd-area-val": trigger.value,
1137                         "dd-test-val": str(),
1138                         "dd-test-opt": options,
1139                         "dd-test-dis": disabled,
1140                         "dd-phy-val": str(),
1141                         "dd-phy-opt": list(),
1142                         "dd-phy-dis": True,
1143                         "cl-core-opt": list(),
1144                         "cl-core-val": list(),
1145                         "cl-core-all-val": list(),
1146                         "cl-core-all-opt": C.CL_ALL_DISABLED,
1147                         "cl-frmsize-opt": list(),
1148                         "cl-frmsize-val": list(),
1149                         "cl-frmsize-all-val": list(),
1150                         "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
1151                         "cl-tsttype-opt": list(),
1152                         "cl-tsttype-val": list(),
1153                         "cl-tsttype-all-val": list(),
1154                         "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
1155                         "btn-add-dis": True
1156                     })
1157                 elif trigger.idx == "test":
1158                     try:
1159                         rls = ctrl_panel.get("dd-rls-val")
1160                         dut = ctrl_panel.get("dd-dut-val")
1161                         dutver = ctrl_panel.get("dd-dutver-val")
1162                         area = ctrl_panel.get("dd-area-val")
1163                         test = self._spec_tbs[rls][dut][dutver][area]\
1164                             [trigger.value]
1165                         options = generate_options(test.keys())
1166                         disabled = False
1167                     except KeyError:
1168                         options = list()
1169                         disabled = True
1170                     ctrl_panel.set({
1171                         "dd-test-val": trigger.value,
1172                         "dd-phy-val": str(),
1173                         "dd-phy-opt": options,
1174                         "dd-phy-dis": disabled,
1175                         "cl-core-opt": list(),
1176                         "cl-core-val": list(),
1177                         "cl-core-all-val": list(),
1178                         "cl-core-all-opt": C.CL_ALL_DISABLED,
1179                         "cl-frmsize-opt": list(),
1180                         "cl-frmsize-val": list(),
1181                         "cl-frmsize-all-val": list(),
1182                         "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
1183                         "cl-tsttype-opt": list(),
1184                         "cl-tsttype-val": list(),
1185                         "cl-tsttype-all-val": list(),
1186                         "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
1187                         "btn-add-dis": True
1188                     })
1189                 elif trigger.idx == "phy":
1190                     rls = ctrl_panel.get("dd-rls-val")
1191                     dut = ctrl_panel.get("dd-dut-val")
1192                     dutver = ctrl_panel.get("dd-dutver-val")
1193                     area = ctrl_panel.get("dd-area-val")
1194                     test = ctrl_panel.get("dd-test-val")
1195                     if all((rls, dut, dutver, area, test, trigger.value, )):
1196                         phy = self._spec_tbs[rls][dut][dutver][area][test]\
1197                             [trigger.value]
1198                         ctrl_panel.set({
1199                             "dd-phy-val": trigger.value,
1200                             "cl-core-opt": generate_options(phy["core"]),
1201                             "cl-core-val": list(),
1202                             "cl-core-all-val": list(),
1203                             "cl-core-all-opt": C.CL_ALL_ENABLED,
1204                             "cl-frmsize-opt": \
1205                                 generate_options(phy["frame-size"]),
1206                             "cl-frmsize-val": list(),
1207                             "cl-frmsize-all-val": list(),
1208                             "cl-frmsize-all-opt": C.CL_ALL_ENABLED,
1209                             "cl-tsttype-opt": \
1210                                 generate_options(phy["test-type"]),
1211                             "cl-tsttype-val": list(),
1212                             "cl-tsttype-all-val": list(),
1213                             "cl-tsttype-all-opt": C.CL_ALL_ENABLED,
1214                             "btn-add-dis": True
1215                         })
1216             elif trigger.type == "ctrl-cl":
1217                 param = trigger.idx.split("-")[0]
1218                 if "-all" in trigger.idx:
1219                     c_sel, c_all, c_id = list(), trigger.value, "all"
1220                 else:
1221                     c_sel, c_all, c_id = trigger.value, list(), str()
1222                 val_sel, val_all = sync_checklists(
1223                     options=ctrl_panel.get(f"cl-{param}-opt"),
1224                     sel=c_sel,
1225                     all=c_all,
1226                     id=c_id
1227                 )
1228                 ctrl_panel.set({
1229                     f"cl-{param}-val": val_sel,
1230                     f"cl-{param}-all-val": val_all,
1231                 })
1232                 if all((ctrl_panel.get("cl-core-val"),
1233                         ctrl_panel.get("cl-frmsize-val"),
1234                         ctrl_panel.get("cl-tsttype-val"), )):
1235                     ctrl_panel.set({"btn-add-dis": False})
1236                 else:
1237                     ctrl_panel.set({"btn-add-dis": True})
1238             elif trigger.type == "ctrl-btn":
1239                 on_draw = True
1240                 if trigger.idx == "add-test":
1241                     rls = ctrl_panel.get("dd-rls-val")
1242                     dut = ctrl_panel.get("dd-dut-val")
1243                     dutver = ctrl_panel.get("dd-dutver-val")
1244                     phy = ctrl_panel.get("dd-phy-val")
1245                     area = ctrl_panel.get("dd-area-val")
1246                     test = ctrl_panel.get("dd-test-val")
1247                     # Add selected test to the list of tests in store:
1248                     if store_sel is None:
1249                         store_sel = list()
1250                     for core in ctrl_panel.get("cl-core-val"):
1251                         for framesize in ctrl_panel.get("cl-frmsize-val"):
1252                             for ttype in ctrl_panel.get("cl-tsttype-val"):
1253                                 if dut == "trex":
1254                                     core = str()
1255                                 tid = "-".join((
1256                                     rls,
1257                                     dut,
1258                                     dutver,
1259                                     phy.replace("af_xdp", "af-xdp"),
1260                                     area,
1261                                     framesize.lower(),
1262                                     core.lower(),
1263                                     test,
1264                                     ttype.lower()
1265                                 ))
1266                                 if tid not in [i["id"] for i in store_sel]:
1267                                     store_sel.append({
1268                                         "id": tid,
1269                                         "rls": rls,
1270                                         "dut": dut,
1271                                         "dutver": dutver,
1272                                         "phy": phy,
1273                                         "area": area,
1274                                         "test": test,
1275                                         "framesize": framesize.lower(),
1276                                         "core": core.lower(),
1277                                         "testtype": ttype.lower()
1278                                     })
1279                     store_sel = sorted(store_sel, key=lambda d: d["id"])
1280                     if C.CLEAR_ALL_INPUTS:
1281                         ctrl_panel.set(ctrl_panel.defaults)
1282                 elif trigger.idx == "rm-test" and lst_sel:
1283                     new_store_sel = list()
1284                     for idx, item in enumerate(store_sel):
1285                         if not lst_sel[idx]:
1286                             new_store_sel.append(item)
1287                     store_sel = new_store_sel
1288                 elif trigger.idx == "rm-test-all":
1289                     store_sel = list()
1290
1291             if on_draw:
1292                 if store_sel:
1293                     lg_selected = get_list_group_items(
1294                         store_sel, "sel-cl", add_index=True
1295                     )
1296                     plotting_area = self._get_plotting_area(
1297                         store_sel,
1298                         bool(normalize),
1299                         gen_new_url(
1300                             parsed_url,
1301                             {"store_sel": store_sel, "norm": normalize}
1302                         )
1303                     )
1304                     row_card_sel_tests = C.STYLE_ENABLED
1305                     row_btns_sel_tests = C.STYLE_ENABLED
1306                 else:
1307                     plotting_area = C.PLACEHOLDER
1308                     row_card_sel_tests = C.STYLE_DISABLED
1309                     row_btns_sel_tests = C.STYLE_DISABLED
1310                     store_sel = list()
1311
1312             ret_val = [
1313                 ctrl_panel.panel,
1314                 store_sel,
1315                 plotting_area,
1316                 row_card_sel_tests,
1317                 row_btns_sel_tests,
1318                 lg_selected
1319             ]
1320             ret_val.extend(ctrl_panel.values)
1321             return ret_val
1322
1323         @app.callback(
1324             Output("plot-mod-url", "is_open"),
1325             [Input("plot-btn-url", "n_clicks")],
1326             [State("plot-mod-url", "is_open")],
1327         )
1328         def toggle_plot_mod_url(n, is_open):
1329             """Toggle the modal window with url.
1330             """
1331             if n:
1332                 return not is_open
1333             return is_open
1334
1335         @app.callback(
1336             Output("download-iterative-data", "data"),
1337             State("store-selected-tests", "data"),
1338             Input("plot-btn-download", "n_clicks"),
1339             prevent_initial_call=True
1340         )
1341         def _download_iterative_data(store_sel, _):
1342             """Download the data
1343
1344             :param store_sel: List of tests selected by user stored in the
1345                 browser.
1346             :type store_sel: list
1347             :returns: dict of data frame content (base64 encoded) and meta data
1348                 used by the Download component.
1349             :rtype: dict
1350             """
1351
1352             if not store_sel:
1353                 raise PreventUpdate
1354
1355             df = pd.DataFrame()
1356             for itm in store_sel:
1357                 sel_data = select_iterative_data(self._data, itm)
1358                 if sel_data is None:
1359                     continue
1360                 df = pd.concat([df, sel_data], ignore_index=True)
1361
1362             return dcc.send_data_frame(df.to_csv, C.REPORT_DOWNLOAD_FILE_NAME)
1363
1364         @app.callback(
1365             Output("metadata-tput-lat", "children"),
1366             Output("metadata-hdrh-graph", "children"),
1367             Output("offcanvas-metadata", "is_open"),
1368             Input({"type": "graph", "index": ALL}, "clickData"),
1369             prevent_initial_call=True
1370         )
1371         def _show_metadata_from_graphs(graph_data: dict) -> tuple:
1372             """Generates the data for the offcanvas displayed when a particular
1373             point in a graph is clicked on.
1374
1375             :param graph_data: The data from the clicked point in the graph.
1376             :type graph_data: dict
1377             :returns: The data to be displayed on the offcanvas and the
1378                 information to show the offcanvas.
1379             :rtype: tuple(list, list, bool)
1380             """
1381
1382             trigger = Trigger(callback_context.triggered)
1383
1384             if trigger.idx == "tput":
1385                 idx = 0
1386             elif trigger.idx == "bandwidth":
1387                 idx = 1
1388             elif trigger.idx == "lat":
1389                 idx = len(graph_data) - 1
1390             else:
1391                 return list(), list(), False
1392
1393             try:
1394                 graph_data = graph_data[idx]["points"]
1395             except (IndexError, KeyError, ValueError, TypeError):
1396                 raise PreventUpdate
1397
1398             def _process_stats(data: list, param: str) -> list:
1399                 """Process statistical data provided by plot.ly box graph.
1400
1401                 :param data: Statistical data provided by plot.ly box graph.
1402                 :param param: Parameter saying if the data come from "tput" or
1403                     "lat" graph.
1404                 :type data: list
1405                 :type param: str
1406                 :returns: Listo of tuples where the first value is the
1407                     statistic's name and the secont one it's value.
1408                 :rtype: list
1409                 """
1410                 if len(data) == 7:
1411                     stats = ("max", "upper fence", "q3", "median", "q1",
1412                             "lower fence", "min")
1413                 elif len(data) == 9:
1414                     stats = ("outlier", "max", "upper fence", "q3", "median",
1415                             "q1", "lower fence", "min", "outlier")
1416                 elif len(data) == 1:
1417                     if param == "lat":
1418                         stats = ("average latency at 50% PDR", )
1419                     elif param == "bandwidth":
1420                         stats = ("bandwidth", )
1421                     else:
1422                         stats = ("throughput", )
1423                 else:
1424                     return list()
1425                 unit = " [us]" if param == "lat" else str()
1426                 return [(f"{stat}{unit}", f"{value['y']:,.0f}")
1427                         for stat, value in zip(stats, data)]
1428
1429             customdata = graph_data[0].get("customdata", dict())
1430             datapoint = customdata.get("metadata", dict())
1431             hdrh_data = customdata.get("hdrh", dict())
1432
1433             list_group_items = list()
1434             for k, v in datapoint.items():
1435                 if k == "csit-ref":
1436                     if len(graph_data) > 1:
1437                         continue
1438                     list_group_item = dbc.ListGroupItem([
1439                         dbc.Badge(k),
1440                         html.A(v, href=f"{C.URL_JENKINS}{v}", target="_blank")
1441                     ])
1442                 else:
1443                     list_group_item = dbc.ListGroupItem([dbc.Badge(k), v])
1444                 list_group_items.append(list_group_item)
1445
1446             graph = list()
1447             if trigger.idx == "tput":
1448                 title = "Throughput"
1449             elif trigger.idx == "bandwidth":
1450                 title = "Bandwidth"
1451             elif trigger.idx == "lat":
1452                 title = "Latency"
1453                 if len(graph_data) == 1:
1454                     if hdrh_data:
1455                         graph = [dbc.Card(
1456                             class_name="gy-2 p-0",
1457                             children=[
1458                                 dbc.CardHeader(hdrh_data.pop("name")),
1459                                 dbc.CardBody(dcc.Graph(
1460                                     id="hdrh-latency-graph",
1461                                     figure=graph_hdrh_latency(
1462                                         hdrh_data, self._graph_layout
1463                                     )
1464                                 ))
1465                             ])
1466                         ]
1467             else:
1468                 raise PreventUpdate
1469
1470             for k, v in _process_stats(graph_data, trigger.idx):
1471                 list_group_items.append(dbc.ListGroupItem([dbc.Badge(k), v]))
1472
1473             metadata = [
1474                 dbc.Card(
1475                     class_name="gy-2 p-0",
1476                     children=[
1477                         dbc.CardHeader(children=[
1478                             dcc.Clipboard(
1479                                 target_id="tput-lat-metadata",
1480                                 title="Copy",
1481                                 style={"display": "inline-block"}
1482                             ),
1483                             title
1484                         ]),
1485                         dbc.CardBody(
1486                             dbc.ListGroup(list_group_items, flush=True),
1487                             id="tput-lat-metadata",
1488                             class_name="p-0"
1489                         )
1490                     ]
1491                 )
1492             ]
1493
1494             return metadata, graph, True
1495
1496         @app.callback(
1497             Output("offcanvas-documentation", "is_open"),
1498             Input("btn-documentation", "n_clicks"),
1499             State("offcanvas-documentation", "is_open")
1500         )
1501         def toggle_offcanvas_documentation(n_clicks, is_open):
1502             if n_clicks:
1503                 return not is_open
1504             return is_open