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