C-Dash: Add search in tests
[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(
335                                 children=show_tooltip(
336                                     self._tooltips,
337                                     "help-dut",
338                                     "DUT"
339                                 )
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(
359                                 children=show_tooltip(
360                                     self._tooltips,
361                                     "help-ttype",
362                                     "Test Type"
363                                 )
364                             ),
365                             dbc.RadioItems(
366                                 id="ri-ttypes",
367                                 inline=True,
368                                 value=self._default["ttype"],
369                                 options=self._default["ttypes"],
370                                 class_name="form-control"
371                             )
372                         ],
373                         size="sm"
374                     )
375                 ]
376             ),
377             dbc.Row(
378                 class_name="g-0 p-1",
379                 children=[
380                     dbc.InputGroup(
381                         [
382                             dbc.InputGroupText(
383                                 children=show_tooltip(
384                                     self._tooltips,
385                                     "help-cadence",
386                                     "Cadence"
387                                 )
388                             ),
389                             dbc.RadioItems(
390                                 id="ri-cadences",
391                                 inline=True,
392                                 value=self._default["cadence"],
393                                 options=self._default["cadences"],
394                                 class_name="form-control"
395                             )
396                         ],
397                         size="sm"
398                     )
399                 ]
400             ),
401             dbc.Row(
402                 class_name="g-0 p-1",
403                 children=[
404                     dbc.InputGroup(
405                         [
406                             dbc.InputGroupText(
407                                 children=show_tooltip(
408                                     self._tooltips,
409                                     "help-tbed",
410                                     "Test Bed"
411                                 )
412                             ),
413                             dbc.Select(
414                                 id="dd-tbeds",
415                                 placeholder="Select a test bed...",
416                                 value=self._default["tbed"],
417                                 options=self._default["tbeds"]
418                             )
419                         ],
420                         size="sm"
421                     )
422                 ]
423             ),
424             dbc.Row(
425                 class_name="g-0 p-1",
426                 children=[
427                     dbc.Alert(
428                         id="al-job",
429                         color="info",
430                         children=self._default["job"]
431                     )
432                 ]
433             )
434         ]
435
436     def _get_plotting_area(
437             self,
438             job: str,
439             url: str
440         ) -> list:
441         """Generate the plotting area with all its content.
442
443         :param job: The job which data will be displayed.
444         :param url: URL to be displayed in the modal window.
445         :type job: str
446         :type url: str
447         :returns: List of rows with elements to be displayed in the plotting
448             area.
449         :rtype: list
450         """
451
452         figs = graph_statistics(self._data, job, self._graph_layout)
453
454         if not figs[0]:
455             return C.PLACEHOLDER
456
457         return [
458             dbc.Row(
459                 id="row-graph-passed",
460                 class_name="g-0 p-1",
461                 children=[
462                     dcc.Graph(
463                         id="graph-passed",
464                         figure=figs[0]
465                     )
466                 ]
467             ),
468             dbc.Row(
469                 id="row-graph-duration",
470                 class_name="g-0 p-1",
471                 children=[
472                     dcc.Graph(
473                         id="graph-duration",
474                         figure=figs[1]
475                     )
476                 ]
477             ),
478             dbc.Row(
479                 [
480                     dbc.Col([html.Div(
481                         [
482                             dbc.Button(
483                                 id="plot-btn-url",
484                                 children="Show URL",
485                                 class_name="me-1",
486                                 color="info",
487                                 style={
488                                     "text-transform": "none",
489                                     "padding": "0rem 1rem"
490                                 }
491                             ),
492                             dbc.Modal(
493                                 [
494                                     dbc.ModalHeader(dbc.ModalTitle("URL")),
495                                     dbc.ModalBody(url)
496                                 ],
497                                 id="plot-mod-url",
498                                 size="xl",
499                                 is_open=False,
500                                 scrollable=True
501                             ),
502                             dbc.Button(
503                                 id="plot-btn-download",
504                                 children="Download Data",
505                                 class_name="me-1",
506                                 color="info",
507                                 style={
508                                     "text-transform": "none",
509                                     "padding": "0rem 1rem"
510                                 }
511                             ),
512                             dcc.Download(id="download-stats-data")
513                         ],
514                         className=\
515                             "d-grid gap-0 d-md-flex justify-content-md-end"
516                     )])
517                 ],
518                 class_name="g-0 p-0"
519             )
520         ]
521
522     def callbacks(self, app):
523         """Callbacks for the whole application.
524
525         :param app: The application.
526         :type app: Flask
527         """
528
529         @app.callback(
530             Output("control-panel", "data"),  # Store
531             Output("plotting-area", "children"),
532             Output("ri-ttypes", "options"),
533             Output("ri-cadences", "options"),
534             Output("dd-tbeds", "options"),
535             Output("ri-duts", "value"),
536             Output("ri-ttypes", "value"),
537             Output("ri-cadences", "value"),
538             Output("dd-tbeds", "value"),
539             Output("al-job", "children"),
540             State("control-panel", "data"),  # Store
541             Input("ri-duts", "value"),
542             Input("ri-ttypes", "value"),
543             Input("ri-cadences", "value"),
544             Input("dd-tbeds", "value"),
545             Input("url", "href")
546         )
547         def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str,
548                 cadence: str, tbed: str, href: str) -> tuple:
549             """Update the application when the event is detected.
550
551             :param cp_data: Current status of the control panel stored in
552                 browser.
553             :param dut: Input - DUT name.
554             :param ttype: Input - Test type.
555             :param cadence: Input - The cadence of the job.
556             :param tbed: Input - The test bed.
557             :param href: Input - The URL provided by the browser.
558             :type cp_data: dict
559             :type dut: str
560             :type ttype: str
561             :type cadence: str
562             :type tbed: str
563             :type href: str
564             :returns: New values for web page elements.
565             :rtype: tuple
566             """
567
568             ctrl_panel = ControlPanel(self._cp_default, cp_data)
569
570             # Parse the url:
571             parsed_url = url_decode(href)
572             if parsed_url:
573                 url_params = parsed_url["params"]
574             else:
575                 url_params = None
576
577             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
578             if trigger_id == "ri-duts":
579                 ttype_opts = generate_options(get_ttypes(self._job_info, dut))
580                 ttype_val = ttype_opts[0]["value"]
581                 cad_opts = generate_options(get_cadences(
582                     self._job_info, dut, ttype_val))
583                 cad_val = cad_opts[0]["value"]
584                 tbed_opts = generate_options(get_test_beds(
585                     self._job_info, dut, ttype_val, cad_val))
586                 tbed_val = tbed_opts[0]["value"]
587                 ctrl_panel.set({
588                     "ri-duts-value": dut,
589                     "ri-ttypes-options": ttype_opts,
590                     "ri-ttypes-value": ttype_val,
591                     "ri-cadences-options": cad_opts,
592                     "ri-cadences-value": cad_val,
593                     "dd-tbeds-options": tbed_opts,
594                     "dd-tbeds-value": tbed_val
595                 })
596             elif trigger_id == "ri-ttypes":
597                 cad_opts = generate_options(get_cadences(
598                     self._job_info, ctrl_panel.get("ri-duts-value"), ttype))
599                 cad_val = cad_opts[0]["value"]
600                 tbed_opts = generate_options(get_test_beds(
601                     self._job_info, ctrl_panel.get("ri-duts-value"), ttype,
602                     cad_val))
603                 tbed_val = tbed_opts[0]["value"]
604                 ctrl_panel.set({
605                     "ri-ttypes-value": ttype,
606                     "ri-cadences-options": cad_opts,
607                     "ri-cadences-value": cad_val,
608                     "dd-tbeds-options": tbed_opts,
609                     "dd-tbeds-value": tbed_val
610                 })
611             elif trigger_id == "ri-cadences":
612                 tbed_opts = generate_options(get_test_beds(
613                     self._job_info, ctrl_panel.get("ri-duts-value"),
614                     ctrl_panel.get("ri-ttypes-value"), cadence))
615                 tbed_val = tbed_opts[0]["value"]
616                 ctrl_panel.set({
617                     "ri-cadences-value": cadence,
618                     "dd-tbeds-options": tbed_opts,
619                     "dd-tbeds-value": tbed_val
620                 })
621             elif trigger_id == "dd-tbeds":
622                 ctrl_panel.set({
623                     "dd-tbeds-value": tbed
624                 })
625             elif trigger_id == "url":
626                 if url_params:
627                     new_job = url_params.get("job", list())[0]
628                     if new_job:
629                         job_params = set_job_params(self._job_info, new_job)
630                         ctrl_panel = ControlPanel(
631                             {
632                                 "ri-ttypes-options": job_params["ttypes"],
633                                 "ri-cadences-options": job_params["cadences"],
634                                 "dd-tbeds-options": job_params["tbeds"],
635                                 "ri-duts-value": job_params["dut"],
636                                 "ri-ttypes-value": job_params["ttype"],
637                                 "ri-cadences-value": job_params["cadence"],
638                                 "dd-tbeds-value": job_params["tbed"],
639                                 "al-job-children": html.A(
640                                     self._default["job"],
641                                     href=(
642                                         f"{C.URL_JENKINS}"
643                                         f"{self._default['job']}"
644                                     ),
645                                     target="_blank"
646                                 )
647                             },
648                             None
649                         )
650                 else:
651                     ctrl_panel = ControlPanel(self._cp_default, cp_data)
652
653             job = get_job(
654                 self._job_info,
655                 ctrl_panel.get("ri-duts-value"),
656                 ctrl_panel.get("ri-ttypes-value"),
657                 ctrl_panel.get("ri-cadences-value"),
658                 ctrl_panel.get("dd-tbeds-value")
659             )
660
661             ctrl_panel.set(
662                 {
663                     "al-job-children": html.A(
664                         job,
665                         href=f"{C.URL_JENKINS}{job}",
666                         target="_blank"
667                     )
668                 }
669             )
670             plotting_area = self._get_plotting_area(
671                 job,
672                 gen_new_url(parsed_url, {"job": job})
673             )
674
675             ret_val = [
676                 ctrl_panel.panel,
677                 plotting_area
678             ]
679             ret_val.extend(ctrl_panel.values)
680             return ret_val
681
682         @app.callback(
683             Output("plot-mod-url", "is_open"),
684             [Input("plot-btn-url", "n_clicks")],
685             [State("plot-mod-url", "is_open")],
686         )
687         def toggle_plot_mod_url(n, is_open):
688             """Toggle the modal window with url.
689             """
690             if n:
691                 return not is_open
692             return is_open
693
694         @app.callback(
695             Output("download-stats-data", "data"),
696             State("control-panel", "data"),  # Store
697             Input("plot-btn-download", "n_clicks"),
698             prevent_initial_call=True
699         )
700         def _download_data(cp_data: dict, n_clicks: int):
701             """Download the data
702
703             :param cp_data: Current status of the control panel stored in
704                 browser.
705             :param n_clicks: Number of clicks on the button "Download".
706             :type cp_data: dict
707             :type n_clicks: int
708             :returns: dict of data frame content (base64 encoded) and meta data
709                 used by the Download component.
710             :rtype: dict
711             """
712             if not n_clicks:
713                 raise PreventUpdate
714
715             ctrl_panel = ControlPanel(self._cp_default, cp_data)
716
717             job = get_job(
718                 self._job_info,
719                 ctrl_panel.get("ri-duts-value"),
720                 ctrl_panel.get("ri-ttypes-value"),
721                 ctrl_panel.get("ri-cadences-value"),
722                 ctrl_panel.get("dd-tbeds-value")
723             )
724
725             data = select_data(self._data, job)
726             data = data.drop(columns=["job", ])
727
728             return dcc.send_data_frame(
729                 data.T.to_csv, f"{job}-{C.STATS_DOWNLOAD_FILE_NAME}")
730
731         @app.callback(
732             Output("row-metadata", "children"),
733             Output("offcanvas-metadata", "is_open"),
734             Input("graph-passed", "clickData"),
735             Input("graph-duration", "clickData"),
736             prevent_initial_call=True
737         )
738         def _show_metadata_from_graphs(
739                 passed_data: dict, duration_data: dict) -> tuple:
740             """Generates the data for the offcanvas displayed when a particular
741             point in a graph is clicked on.
742
743             :param passed_data: The data from the clicked point in the graph
744                 displaying the pass/fail data.
745             :param duration_data: The data from the clicked point in the graph
746                 displaying the duration data.
747             :type passed_data: dict
748             :type duration data: dict
749             :returns: The data to be displayed on the offcanvas (job statistics
750                 and the list of failed tests) and the information to show the
751                 offcanvas.
752             :rtype: tuple(list, bool)
753             """
754
755             if not (passed_data or duration_data):
756                 raise PreventUpdate
757
758             metadata = no_update
759             open_canvas = False
760             title = "Job Statistics"
761             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
762             if trigger_id == "graph-passed":
763                 graph_data = passed_data["points"][0].get("hovertext", "")
764             elif trigger_id == "graph-duration":
765                 graph_data = duration_data["points"][0].get("text", "")
766             if graph_data:
767                 lst_graph_data = graph_data.split("<br>")
768
769                 # Prepare list of failed tests:
770                 job = str()
771                 build = str()
772                 for itm in lst_graph_data:
773                     if "csit-ref:" in itm:
774                         job, build = itm.split(" ")[-1].split("/")
775                         break
776                 if job and build:
777                     fail_tests = self._data.loc[
778                         (self._data["job"] == job) &
779                         (self._data["build"] == build)
780                     ]["lst_failed"].values[0]
781                     if not fail_tests:
782                         fail_tests = None
783                 else:
784                     fail_tests = None
785
786                 # Create the content of the offcanvas:
787                 list_group_items = list()
788                 for itm in lst_graph_data:
789                     lst_itm = itm.split(": ")
790                     if lst_itm[0] == "csit-ref":
791                         list_group_item = dbc.ListGroupItem([
792                             dbc.Badge(lst_itm[0]),
793                             html.A(
794                                 lst_itm[1],
795                                 href=f"{C.URL_JENKINS}{lst_itm[1]}",
796                                 target="_blank"
797                             )
798                         ])
799                     else:
800                         list_group_item = dbc.ListGroupItem([
801                             dbc.Badge(lst_itm[0]),
802                             lst_itm[1]
803                         ])
804                     list_group_items.append(list_group_item)
805                 metadata = [
806                     dbc.Card(
807                         class_name="gy-2 p-0",
808                         children=[
809                             dbc.CardHeader([
810                                 dcc.Clipboard(
811                                     target_id="metadata",
812                                     title="Copy",
813                                     style={"display": "inline-block"}
814                                 ),
815                                 title
816                             ]),
817                             dbc.CardBody(
818                                 dbc.ListGroup(list_group_items, flush=True),
819                                 id="metadata",
820                                 class_name="p-0"
821                             )
822                         ]
823                     )
824                 ]
825
826                 if fail_tests is not None:
827                     metadata.append(
828                         dbc.Card(
829                             class_name="gy-2 p-0",
830                             children=[
831                                 dbc.CardHeader(
832                                     f"List of Failed Tests ({len(fail_tests)})"
833                                 ),
834                                 dbc.CardBody(
835                                     id="failed-tests",
836                                     class_name="p-0",
837                                     children=[dbc.ListGroup(
838                                         children=[
839                                             dbc.ListGroupItem(x) \
840                                                 for x in fail_tests
841                                         ],
842                                         flush=True),
843                                     ]
844                                 )
845                             ]
846                         )
847                     )
848
849                 open_canvas = True
850
851             return metadata, open_canvas
852
853         @app.callback(
854             Output("offcanvas-documentation", "is_open"),
855             Input("btn-documentation", "n_clicks"),
856             State("offcanvas-documentation", "is_open")
857         )
858         def toggle_offcanvas_documentation(n_clicks, is_open):
859             if n_clicks:
860                 return not is_open
861             return is_open