C-Dash: Add tooltips
[csit.git] / csit.infra.dash / app / cdash / stats / layout.py
1 # Copyright (c) 2024 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """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
25 from dash import Input, Output, State
26 from dash.exceptions import PreventUpdate
27 from yaml import load, FullLoader, YAMLError
28
29 from ..utils.constants import Constants as C
30 from ..utils.control_panel import ControlPanel
31 from ..utils.utils import show_tooltip, gen_new_url, get_ttypes, get_cadences, \
32     get_test_beds, get_job, generate_options, set_job_params, navbar_trending
33 from ..utils.url_processing import url_decode
34 from .graphs import graph_statistics, select_data
35
36
37 class Layout:
38     """The layout of the dash app and the callbacks.
39     """
40
41     def __init__(
42             self,
43             app: Flask,
44             data_stats: pd.DataFrame,
45             data_trending: pd.DataFrame,
46             html_layout_file: str,
47             graph_layout_file: str,
48             tooltip_file: str
49         ) -> None:
50         """Initialization:
51         - save the input parameters,
52         - read and pre-process the data,
53         - prepare data for the control panel,
54         - read HTML layout file,
55         - read tooltips from the tooltip file.
56
57         :param app: Flask application running the dash application.
58         :param data_stats: Pandas dataframe with staistical data.
59         :param data_trending: Pandas dataframe with trending data.
60         :param html_layout_file: Path and name of the file specifying the HTML
61             layout of the dash application.
62         :param graph_layout_file: Path and name of the file with layout of
63             plot.ly graphs.
64         :param tooltip_file: Path and name of the yaml file specifying the
65             tooltips.
66         :type app: Flask
67         :type data_stats: pandas.DataFrame
68         :type data_trending: pandas.DataFrame
69         :type html_layout_file: str
70         :type graph_layout_file: str
71         :type tooltip_file: str
72         """
73
74         # Inputs
75         self._app = app
76         self._html_layout_file = html_layout_file
77         self._graph_layout_file = graph_layout_file
78         self._tooltip_file = tooltip_file
79
80         # Pre-process the data:
81         data_stats = data_stats[~data_stats.job.str.contains("-verify-")]
82         data_stats = data_stats[~data_stats.job.str.contains("-coverage-")]
83         data_stats = data_stats[~data_stats.job.str.contains("-iterative-")]
84         data_stats = data_stats[["job", "build", "start_time", "duration"]]
85
86         jobs = sorted(list(data_stats["job"].unique()))
87         d_job_info = {
88             "job": list(),
89             "dut": list(),
90             "ttype": list(),
91             "cadence": list(),
92             "tbed": list()
93         }
94         for job in jobs:
95             lst_job = job.split("-")
96             d_job_info["job"].append(job)
97             d_job_info["dut"].append(lst_job[1])
98             d_job_info["ttype"].append(lst_job[3])
99             d_job_info["cadence"].append(lst_job[4])
100             d_job_info["tbed"].append("-".join(lst_job[-2:]))
101         self._job_info = pd.DataFrame.from_dict(d_job_info)
102
103         self._default = set_job_params(self._job_info, C.STATS_DEFAULT_JOB)
104
105         tst_info = {
106             "job": list(),
107             "build": list(),
108             "dut_type": list(),
109             "dut_version": list(),
110             "hosts": list(),
111             "passed": list(),
112             "failed": list(),
113             "lst_failed": list()
114         }
115         for job in jobs:
116             df_job = data_trending.loc[(data_trending["job"] == job)]
117             builds = df_job["build"].unique()
118             for build in builds:
119                 df_build = df_job.loc[(df_job["build"] == build)]
120                 tst_info["job"].append(job)
121                 tst_info["build"].append(build)
122                 tst_info["dut_type"].append(df_build["dut_type"].iloc[-1])
123                 tst_info["dut_version"].append(df_build["dut_version"].iloc[-1])
124                 tst_info["hosts"].append(df_build["hosts"].iloc[-1])
125                 try:
126                     passed = df_build.value_counts(subset="passed")[True]
127                 except KeyError:
128                     passed = 0
129                 try:
130                     failed = df_build.value_counts(subset="passed")[False]
131                     failed_tests = df_build.loc[(df_build["passed"] == False)]\
132                         ["test_id"].to_list()
133                     l_failed = list()
134                     for tst in failed_tests:
135                         lst_tst = tst.split(".")
136                         suite = lst_tst[-2].replace("2n1l-", "").\
137                             replace("1n1l-", "").replace("2n-", "")
138                         l_failed.append(f"{suite.split('-')[0]}-{lst_tst[-1]}")
139                 except KeyError:
140                     failed = 0
141                     l_failed = list()
142                 tst_info["passed"].append(passed)
143                 tst_info["failed"].append(failed)
144                 tst_info["lst_failed"].append(sorted(l_failed))
145
146         self._data = data_stats.merge(pd.DataFrame.from_dict(tst_info))
147
148         # Read from files:
149         self._html_layout = str()
150         self._graph_layout = None
151         self._tooltips = dict()
152
153         try:
154             with open(self._html_layout_file, "r") as file_read:
155                 self._html_layout = file_read.read()
156         except IOError as err:
157             raise RuntimeError(
158                 f"Not possible to open the file {self._html_layout_file}\n{err}"
159             )
160
161         try:
162             with open(self._graph_layout_file, "r") as file_read:
163                 self._graph_layout = load(file_read, Loader=FullLoader)
164         except IOError as err:
165             raise RuntimeError(
166                 f"Not possible to open the file {self._graph_layout_file}\n"
167                 f"{err}"
168             )
169         except YAMLError as err:
170             raise RuntimeError(
171                 f"An error occurred while parsing the specification file "
172                 f"{self._graph_layout_file}\n{err}"
173             )
174
175         try:
176             with open(self._tooltip_file, "r") as file_read:
177                 self._tooltips = load(file_read, Loader=FullLoader)
178         except IOError as err:
179             logging.warning(
180                 f"Not possible to open the file {self._tooltip_file}\n{err}"
181             )
182         except YAMLError as err:
183             logging.warning(
184                 f"An error occurred while parsing the specification file "
185                 f"{self._tooltip_file}\n{err}"
186             )
187
188         # Control panel partameters and their default values.
189         self._cp_default = {
190             "ri-ttypes-options": self._default["ttypes"],
191             "ri-cadences-options": self._default["cadences"],
192             "dd-tbeds-options": self._default["tbeds"],
193             "ri-duts-value": self._default["dut"],
194             "ri-ttypes-value": self._default["ttype"],
195             "ri-cadences-value": self._default["cadence"],
196             "dd-tbeds-value": self._default["tbed"],
197             "al-job-children": html.A(
198                 self._default["job"],
199                 href=f"{C.URL_JENKINS}{self._default['job']}",
200                 target="_blank"
201             )
202         }
203
204         # Callbacks:
205         if self._app is not None and hasattr(self, "callbacks"):
206             self.callbacks(self._app)
207
208     @property
209     def html_layout(self) -> dict:
210         return self._html_layout
211
212     def add_content(self):
213         """Top level method which generated the web page.
214
215         It generates:
216         - Store for user input data,
217         - Navigation bar,
218         - Main area with control panel and ploting area.
219
220         If no HTML layout is provided, an error message is displayed instead.
221
222         :returns: The HTML div with the whole page.
223         :rtype: html.Div
224         """
225
226         if self.html_layout:
227             return html.Div(
228                 id="div-main",
229                 className="small",
230                 children=[
231                     dcc.Store(id="control-panel"),
232                     dcc.Location(id="url", refresh=False),
233                     dbc.Row(
234                         id="row-navbar",
235                         class_name="g-0",
236                         children=[navbar_trending((False, False, True, False))]
237                     ),
238                     dbc.Spinner(
239                         dbc.Offcanvas(
240                             class_name="w-50",
241                             id="offcanvas-metadata",
242                             title="Detailed Information",
243                             placement="end",
244                             is_open=False,
245                             children=[
246                                 dbc.Row(id="row-metadata")
247                             ]
248                         )
249                     ),
250                     dbc.Row(
251                         id="row-main",
252                         class_name="g-0",
253                         children=[
254                             self._add_ctrl_col(),
255                             self._add_plotting_col()
256                         ]
257                     ),
258                     dbc.Offcanvas(
259                         class_name="w-75",
260                         id="offcanvas-documentation",
261                         title="Documentation",
262                         placement="end",
263                         is_open=False,
264                         children=html.Iframe(
265                             src=C.URL_DOC_TRENDING,
266                             width="100%",
267                             height="100%"
268                         )
269                     )
270                 ]
271             )
272         else:
273             return html.Div(
274                 id="div-main-error",
275                 children=[
276                     dbc.Alert(
277                         [
278                             "An Error Occured",
279                         ],
280                         color="danger"
281                     )
282                 ]
283             )
284
285     def _add_ctrl_col(self) -> dbc.Col:
286         """Add column with controls. It is placed on the left side.
287
288         :returns: Column with the control panel.
289         :rtype: dbc.Col
290         """
291         return dbc.Col([
292             html.Div(
293                 children=self._add_ctrl_panel(),
294                 className="sticky-top"
295             )
296         ])
297
298     def _add_plotting_col(self) -> dbc.Col:
299         """Add column with plots and tables. It is placed on the right side.
300
301         :returns: Column with tables.
302         :rtype: dbc.Col
303         """
304         return dbc.Col(
305             id="col-plotting-area",
306             children=[
307                 dbc.Spinner(
308                     children=[
309                         dbc.Row(
310                             id="plotting-area",
311                             class_name="g-0 p-0",
312                             children=[
313                                 C.PLACEHOLDER
314                             ]
315                         )
316                     ]
317                 )
318             ],
319             width=9
320         )
321
322     def _add_ctrl_panel(self) -> dbc.Row:
323         """Add control panel.
324
325         :returns: Control panel.
326         :rtype: dbc.Row
327         """
328         return [
329             dbc.Row(
330                 class_name="g-0 p-1",
331                 children=[
332                     dbc.InputGroup(
333                         [
334                             dbc.InputGroupText(show_tooltip(
335                                 self._tooltips,
336                                 "help-dut",
337                                 "DUT"
338                             )),
339                             dbc.RadioItems(
340                                 id="ri-duts",
341                                 inline=True,
342                                 value=self._default["dut"],
343                                 options=self._default["duts"],
344                                 class_name="form-control"
345                             )
346                         ],
347                         size="sm"
348                     )
349                 ]
350             ),
351             dbc.Row(
352                 class_name="g-0 p-1",
353                 children=[
354                     dbc.InputGroup(
355                         [
356                             dbc.InputGroupText(show_tooltip(
357                                 self._tooltips,
358                                 "help-ttype",
359                                 "Test Type"
360                             )),
361                             dbc.RadioItems(
362                                 id="ri-ttypes",
363                                 inline=True,
364                                 value=self._default["ttype"],
365                                 options=self._default["ttypes"],
366                                 class_name="form-control"
367                             )
368                         ],
369                         size="sm"
370                     )
371                 ]
372             ),
373             dbc.Row(
374                 class_name="g-0 p-1",
375                 children=[
376                     dbc.InputGroup(
377                         [
378                             dbc.InputGroupText(show_tooltip(
379                                 self._tooltips,
380                                 "help-cadence",
381                                 "Cadence"
382                             )),
383                             dbc.RadioItems(
384                                 id="ri-cadences",
385                                 inline=True,
386                                 value=self._default["cadence"],
387                                 options=self._default["cadences"],
388                                 class_name="form-control"
389                             )
390                         ],
391                         size="sm"
392                     )
393                 ]
394             ),
395             dbc.Row(
396                 class_name="g-0 p-1",
397                 children=[
398                     dbc.InputGroup(
399                         [
400                             dbc.InputGroupText(show_tooltip(
401                                 self._tooltips,
402                                 "help-tbed",
403                                 "Test Bed"
404                             )),
405                             dbc.Select(
406                                 id="dd-tbeds",
407                                 placeholder="Select a test bed...",
408                                 value=self._default["tbed"],
409                                 options=self._default["tbeds"]
410                             )
411                         ],
412                         size="sm"
413                     )
414                 ]
415             ),
416             dbc.Row(
417                 class_name="g-0 p-1",
418                 children=[
419                     dbc.Alert(
420                         id="al-job",
421                         color="info",
422                         children=self._default["job"]
423                     )
424                 ]
425             )
426         ]
427
428     def _get_plotting_area(
429             self,
430             job: str,
431             url: str
432         ) -> list:
433         """Generate the plotting area with all its content.
434
435         :param job: The job which data will be displayed.
436         :param url: URL to be displayed in the modal window.
437         :type job: str
438         :type url: str
439         :returns: List of rows with elements to be displayed in the plotting
440             area.
441         :rtype: list
442         """
443
444         figs = graph_statistics(self._data, job, self._graph_layout)
445
446         if not figs[0]:
447             return C.PLACEHOLDER
448
449         return [
450             dbc.Row(
451                 id="row-graph-passed",
452                 class_name="g-0 p-1",
453                 children=[
454                     dcc.Graph(
455                         id="graph-passed",
456                         figure=figs[0]
457                     )
458                 ]
459             ),
460             dbc.Row(
461                 id="row-graph-duration",
462                 class_name="g-0 p-1",
463                 children=[
464                     dcc.Graph(
465                         id="graph-duration",
466                         figure=figs[1]
467                     )
468                 ]
469             ),
470             dbc.Row(
471                 [
472                     dbc.Col([html.Div(
473                         [
474                             dbc.Button(
475                                 id="plot-btn-url",
476                                 children="Show URL",
477                                 class_name="me-1",
478                                 color="info",
479                                 style={
480                                     "text-transform": "none",
481                                     "padding": "0rem 1rem"
482                                 }
483                             ),
484                             dbc.Modal(
485                                 [
486                                     dbc.ModalHeader(dbc.ModalTitle("URL")),
487                                     dbc.ModalBody(url)
488                                 ],
489                                 id="plot-mod-url",
490                                 size="xl",
491                                 is_open=False,
492                                 scrollable=True
493                             ),
494                             dbc.Button(
495                                 id="plot-btn-download",
496                                 children="Download Data",
497                                 class_name="me-1",
498                                 color="info",
499                                 style={
500                                     "text-transform": "none",
501                                     "padding": "0rem 1rem"
502                                 }
503                             ),
504                             dcc.Download(id="download-stats-data")
505                         ],
506                         className=\
507                             "d-grid gap-0 d-md-flex justify-content-md-end"
508                     )])
509                 ],
510                 class_name="g-0 p-0"
511             )
512         ]
513
514     def callbacks(self, app):
515         """Callbacks for the whole application.
516
517         :param app: The application.
518         :type app: Flask
519         """
520
521         @app.callback(
522             Output("control-panel", "data"),  # Store
523             Output("plotting-area", "children"),
524             Output("ri-ttypes", "options"),
525             Output("ri-cadences", "options"),
526             Output("dd-tbeds", "options"),
527             Output("ri-duts", "value"),
528             Output("ri-ttypes", "value"),
529             Output("ri-cadences", "value"),
530             Output("dd-tbeds", "value"),
531             Output("al-job", "children"),
532             State("control-panel", "data"),  # Store
533             Input("ri-duts", "value"),
534             Input("ri-ttypes", "value"),
535             Input("ri-cadences", "value"),
536             Input("dd-tbeds", "value"),
537             Input("url", "href")
538         )
539         def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str,
540                 cadence: str, tbed: str, href: str) -> tuple:
541             """Update the application when the event is detected.
542
543             :param cp_data: Current status of the control panel stored in
544                 browser.
545             :param dut: Input - DUT name.
546             :param ttype: Input - Test type.
547             :param cadence: Input - The cadence of the job.
548             :param tbed: Input - The test bed.
549             :param href: Input - The URL provided by the browser.
550             :type cp_data: dict
551             :type dut: str
552             :type ttype: str
553             :type cadence: str
554             :type tbed: str
555             :type href: str
556             :returns: New values for web page elements.
557             :rtype: tuple
558             """
559
560             ctrl_panel = ControlPanel(self._cp_default, cp_data)
561
562             # Parse the url:
563             parsed_url = url_decode(href)
564             if parsed_url:
565                 url_params = parsed_url["params"]
566             else:
567                 url_params = None
568
569             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
570             if trigger_id == "ri-duts":
571                 ttype_opts = generate_options(get_ttypes(self._job_info, dut))
572                 ttype_val = ttype_opts[0]["value"]
573                 cad_opts = generate_options(get_cadences(
574                     self._job_info, dut, ttype_val))
575                 cad_val = cad_opts[0]["value"]
576                 tbed_opts = generate_options(get_test_beds(
577                     self._job_info, dut, ttype_val, cad_val))
578                 tbed_val = tbed_opts[0]["value"]
579                 ctrl_panel.set({
580                     "ri-duts-value": dut,
581                     "ri-ttypes-options": ttype_opts,
582                     "ri-ttypes-value": ttype_val,
583                     "ri-cadences-options": cad_opts,
584                     "ri-cadences-value": cad_val,
585                     "dd-tbeds-options": tbed_opts,
586                     "dd-tbeds-value": tbed_val
587                 })
588             elif trigger_id == "ri-ttypes":
589                 cad_opts = generate_options(get_cadences(
590                     self._job_info, ctrl_panel.get("ri-duts-value"), ttype))
591                 cad_val = cad_opts[0]["value"]
592                 tbed_opts = generate_options(get_test_beds(
593                     self._job_info, ctrl_panel.get("ri-duts-value"), ttype,
594                     cad_val))
595                 tbed_val = tbed_opts[0]["value"]
596                 ctrl_panel.set({
597                     "ri-ttypes-value": ttype,
598                     "ri-cadences-options": cad_opts,
599                     "ri-cadences-value": cad_val,
600                     "dd-tbeds-options": tbed_opts,
601                     "dd-tbeds-value": tbed_val
602                 })
603             elif trigger_id == "ri-cadences":
604                 tbed_opts = generate_options(get_test_beds(
605                     self._job_info, ctrl_panel.get("ri-duts-value"),
606                     ctrl_panel.get("ri-ttypes-value"), cadence))
607                 tbed_val = tbed_opts[0]["value"]
608                 ctrl_panel.set({
609                     "ri-cadences-value": cadence,
610                     "dd-tbeds-options": tbed_opts,
611                     "dd-tbeds-value": tbed_val
612                 })
613             elif trigger_id == "dd-tbeds":
614                 ctrl_panel.set({
615                     "dd-tbeds-value": tbed
616                 })
617             elif trigger_id == "url":
618                 if url_params:
619                     new_job = url_params.get("job", list())[0]
620                     if new_job:
621                         job_params = set_job_params(self._job_info, new_job)
622                         ctrl_panel = ControlPanel(
623                             {
624                                 "ri-ttypes-options": job_params["ttypes"],
625                                 "ri-cadences-options": job_params["cadences"],
626                                 "dd-tbeds-options": job_params["tbeds"],
627                                 "ri-duts-value": job_params["dut"],
628                                 "ri-ttypes-value": job_params["ttype"],
629                                 "ri-cadences-value": job_params["cadence"],
630                                 "dd-tbeds-value": job_params["tbed"],
631                                 "al-job-children": html.A(
632                                     self._default["job"],
633                                     href=(
634                                         f"{C.URL_JENKINS}"
635                                         f"{self._default['job']}"
636                                     ),
637                                     target="_blank"
638                                 )
639                             },
640                             None
641                         )
642                 else:
643                     ctrl_panel = ControlPanel(self._cp_default, cp_data)
644
645             job = get_job(
646                 self._job_info,
647                 ctrl_panel.get("ri-duts-value"),
648                 ctrl_panel.get("ri-ttypes-value"),
649                 ctrl_panel.get("ri-cadences-value"),
650                 ctrl_panel.get("dd-tbeds-value")
651             )
652
653             ctrl_panel.set(
654                 {
655                     "al-job-children": html.A(
656                         job,
657                         href=f"{C.URL_JENKINS}{job}",
658                         target="_blank"
659                     )
660                 }
661             )
662             plotting_area = self._get_plotting_area(
663                 job,
664                 gen_new_url(parsed_url, {"job": job})
665             )
666
667             ret_val = [
668                 ctrl_panel.panel,
669                 plotting_area
670             ]
671             ret_val.extend(ctrl_panel.values)
672             return ret_val
673
674         @app.callback(
675             Output("plot-mod-url", "is_open"),
676             [Input("plot-btn-url", "n_clicks")],
677             [State("plot-mod-url", "is_open")],
678         )
679         def toggle_plot_mod_url(n, is_open):
680             """Toggle the modal window with url.
681             """
682             if n:
683                 return not is_open
684             return is_open
685
686         @app.callback(
687             Output("download-stats-data", "data"),
688             State("control-panel", "data"),  # Store
689             Input("plot-btn-download", "n_clicks"),
690             prevent_initial_call=True
691         )
692         def _download_data(cp_data: dict, n_clicks: int):
693             """Download the data
694
695             :param cp_data: Current status of the control panel stored in
696                 browser.
697             :param n_clicks: Number of clicks on the button "Download".
698             :type cp_data: dict
699             :type n_clicks: int
700             :returns: dict of data frame content (base64 encoded) and meta data
701                 used by the Download component.
702             :rtype: dict
703             """
704             if not n_clicks:
705                 raise PreventUpdate
706
707             ctrl_panel = ControlPanel(self._cp_default, cp_data)
708
709             job = get_job(
710                 self._job_info,
711                 ctrl_panel.get("ri-duts-value"),
712                 ctrl_panel.get("ri-ttypes-value"),
713                 ctrl_panel.get("ri-cadences-value"),
714                 ctrl_panel.get("dd-tbeds-value")
715             )
716
717             data = select_data(self._data, job)
718             data = data.drop(columns=["job", ])
719
720             return dcc.send_data_frame(
721                 data.T.to_csv, f"{job}-{C.STATS_DOWNLOAD_FILE_NAME}")
722
723         @app.callback(
724             Output("row-metadata", "children"),
725             Output("offcanvas-metadata", "is_open"),
726             Input("graph-passed", "clickData"),
727             Input("graph-duration", "clickData"),
728             prevent_initial_call=True
729         )
730         def _show_metadata_from_graphs(
731                 passed_data: dict, duration_data: dict) -> tuple:
732             """Generates the data for the offcanvas displayed when a particular
733             point in a graph is clicked on.
734
735             :param passed_data: The data from the clicked point in the graph
736                 displaying the pass/fail data.
737             :param duration_data: The data from the clicked point in the graph
738                 displaying the duration data.
739             :type passed_data: dict
740             :type duration data: dict
741             :returns: The data to be displayed on the offcanvas (job statistics
742                 and the list of failed tests) and the information to show the
743                 offcanvas.
744             :rtype: tuple(list, bool)
745             """
746
747             if not (passed_data or duration_data):
748                 raise PreventUpdate
749
750             metadata = no_update
751             open_canvas = False
752             title = "Job Statistics"
753             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
754             if trigger_id == "graph-passed":
755                 graph_data = passed_data["points"][0].get("hovertext", "")
756             elif trigger_id == "graph-duration":
757                 graph_data = duration_data["points"][0].get("text", "")
758             if graph_data:
759                 lst_graph_data = graph_data.split("<br>")
760
761                 # Prepare list of failed tests:
762                 job = str()
763                 build = str()
764                 for itm in lst_graph_data:
765                     if "csit-ref:" in itm:
766                         job, build = itm.split(" ")[-1].split("/")
767                         break
768                 if job and build:
769                     fail_tests = self._data.loc[
770                         (self._data["job"] == job) &
771                         (self._data["build"] == build)
772                     ]["lst_failed"].values[0]
773                     if not fail_tests:
774                         fail_tests = None
775                 else:
776                     fail_tests = None
777
778                 # Create the content of the offcanvas:
779                 list_group_items = list()
780                 for itm in lst_graph_data:
781                     lst_itm = itm.split(": ")
782                     if lst_itm[0] == "csit-ref":
783                         list_group_item = dbc.ListGroupItem([
784                             dbc.Badge(lst_itm[0]),
785                             html.A(
786                                 lst_itm[1],
787                                 href=f"{C.URL_JENKINS}{lst_itm[1]}",
788                                 target="_blank"
789                             )
790                         ])
791                     else:
792                         list_group_item = dbc.ListGroupItem([
793                             dbc.Badge(lst_itm[0]),
794                             lst_itm[1]
795                         ])
796                     list_group_items.append(list_group_item)
797                 metadata = [
798                     dbc.Card(
799                         class_name="gy-2 p-0",
800                         children=[
801                             dbc.CardHeader([
802                                 dcc.Clipboard(
803                                     target_id="metadata",
804                                     title="Copy",
805                                     style={"display": "inline-block"}
806                                 ),
807                                 title
808                             ]),
809                             dbc.CardBody(
810                                 dbc.ListGroup(list_group_items, flush=True),
811                                 id="metadata",
812                                 class_name="p-0"
813                             )
814                         ]
815                     )
816                 ]
817
818                 if fail_tests is not None:
819                     metadata.append(
820                         dbc.Card(
821                             class_name="gy-2 p-0",
822                             children=[
823                                 dbc.CardHeader(
824                                     f"List of Failed Tests ({len(fail_tests)})"
825                                 ),
826                                 dbc.CardBody(
827                                     id="failed-tests",
828                                     class_name="p-0",
829                                     children=[dbc.ListGroup(
830                                         children=[
831                                             dbc.ListGroupItem(x) \
832                                                 for x in fail_tests
833                                         ],
834                                         flush=True),
835                                     ]
836                                 )
837                             ]
838                         )
839                     )
840
841                 open_canvas = True
842
843             return metadata, open_canvas
844
845         @app.callback(
846             Output("offcanvas-documentation", "is_open"),
847             Input("btn-documentation", "n_clicks"),
848             State("offcanvas-documentation", "is_open")
849         )
850         def toggle_offcanvas_documentation(n_clicks, is_open):
851             if n_clicks:
852                 return not is_open
853             return is_open