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