574479e1235698664e60783981ce17f785fdda7e
[csit.git] / csit.infra.dash / app / cdash / stats / layout.py
1 # Copyright (c) 2023 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """Plotly Dash HTML layout override.
15 """
16
17 import logging
18 import pandas as pd
19 import dash_bootstrap_components as dbc
20
21 from flask import Flask
22 from dash import dcc
23 from dash import html
24 from dash import callback_context, no_update
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
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": self._default["job"]
198         }
199
200         # Callbacks:
201         if self._app is not None and hasattr(self, "callbacks"):
202             self.callbacks(self._app)
203
204     @property
205     def html_layout(self) -> dict:
206         return self._html_layout
207
208     def add_content(self):
209         """Top level method which generated the web page.
210
211         It generates:
212         - Store for user input data,
213         - Navigation bar,
214         - Main area with control panel and ploting area.
215
216         If no HTML layout is provided, an error message is displayed instead.
217
218         :returns: The HTML div with the whole page.
219         :rtype: html.Div
220         """
221
222         if self.html_layout:
223             return html.Div(
224                 id="div-main",
225                 className="small",
226                 children=[
227                     dcc.Store(id="control-panel"),
228                     dcc.Location(id="url", refresh=False),
229                     dbc.Row(
230                         id="row-navbar",
231                         class_name="g-0",
232                         children=[
233                             self._add_navbar()
234                         ]
235                     ),
236                     dbc.Spinner(
237                         dbc.Offcanvas(
238                             class_name="w-50",
239                             id="offcanvas-metadata",
240                             title="Detailed Information",
241                             placement="end",
242                             is_open=False,
243                             children=[
244                                 dbc.Row(id="row-metadata")
245                             ]
246                         )
247                     ),
248                     dbc.Row(
249                         id="row-main",
250                         class_name="g-0",
251                         children=[
252                             self._add_ctrl_col(),
253                             self._add_plotting_col()
254                         ]
255                     ),
256                     dbc.Offcanvas(
257                         class_name="w-75",
258                         id="offcanvas-documentation",
259                         title="Documentation",
260                         placement="end",
261                         is_open=False,
262                         children=html.Iframe(
263                             src=C.URL_DOC_TRENDING,
264                             width="100%",
265                             height="100%"
266                         )
267                     )
268                 ]
269             )
270         else:
271             return html.Div(
272                 id="div-main-error",
273                 children=[
274                     dbc.Alert(
275                         [
276                             "An Error Occured",
277                         ],
278                         color="danger"
279                     )
280                 ]
281             )
282
283     def _add_navbar(self):
284         """Add nav element with navigation panel. It is placed on the top.
285
286         :returns: Navigation bar.
287         :rtype: dbc.NavbarSimple
288         """
289         return dbc.NavbarSimple(
290             id="navbarsimple-main",
291             children=[
292                 dbc.NavItem(dbc.NavLink(
293                     C.TREND_TITLE,
294                     external_link=True,
295                     href="/trending"
296                 )),
297                 dbc.NavItem(dbc.NavLink(
298                     C.NEWS_TITLE,
299                     external_link=True,
300                     href="/news"
301                 )),
302                 dbc.NavItem(dbc.NavLink(
303                     C.STATS_TITLE,
304                     active=True,
305                     external_link=True,
306                     href="/stats"
307                 )),
308                 dbc.NavItem(dbc.NavLink(
309                     "Documentation",
310                     id="btn-documentation",
311                 ))
312             ],
313             brand=C.BRAND,
314             brand_href="/",
315             brand_external_link=True,
316             class_name="p-2",
317             fluid=True
318         )
319
320     def _add_ctrl_col(self) -> dbc.Col:
321         """Add column with controls. It is placed on the left side.
322
323         :returns: Column with the control panel.
324         :rtype: dbc.Col
325         """
326         return dbc.Col([
327             html.Div(
328                 children=self._add_ctrl_panel(),
329                 className="sticky-top"
330             )
331         ])
332
333     def _add_plotting_col(self) -> dbc.Col:
334         """Add column with plots and tables. It is placed on the right side.
335
336         :returns: Column with tables.
337         :rtype: dbc.Col
338         """
339         return dbc.Col(
340             id="col-plotting-area",
341             children=[
342                 dbc.Spinner(
343                     children=[
344                         dbc.Row(
345                             id="plotting-area",
346                             class_name="g-0 p-0",
347                             children=[
348                                 C.PLACEHOLDER
349                             ]
350                         )
351                     ]
352                 )
353             ],
354             width=9
355         )
356
357     def _add_ctrl_panel(self) -> dbc.Row:
358         """Add control panel.
359
360         :returns: Control panel.
361         :rtype: dbc.Row
362         """
363         return [
364             dbc.Row(
365                 class_name="g-0 p-1",
366                 children=[
367                     dbc.InputGroup(
368                         [
369                             dbc.InputGroupText(
370                                 children=show_tooltip(
371                                     self._tooltips,
372                                     "help-dut",
373                                     "DUT"
374                                 )
375                             ),
376                             dbc.RadioItems(
377                                 id="ri-duts",
378                                 inline=True,
379                                 value=self._default["dut"],
380                                 options=self._default["duts"],
381                                 class_name="form-control"
382                             )
383                         ],
384                         size="sm"
385                     )
386                 ]
387             ),
388             dbc.Row(
389                 class_name="g-0 p-1",
390                 children=[
391                     dbc.InputGroup(
392                         [
393                             dbc.InputGroupText(
394                                 children=show_tooltip(
395                                     self._tooltips,
396                                     "help-ttype",
397                                     "Test Type"
398                                 )
399                             ),
400                             dbc.RadioItems(
401                                 id="ri-ttypes",
402                                 inline=True,
403                                 value=self._default["ttype"],
404                                 options=self._default["ttypes"],
405                                 class_name="form-control"
406                             )
407                         ],
408                         size="sm"
409                     )
410                 ]
411             ),
412             dbc.Row(
413                 class_name="g-0 p-1",
414                 children=[
415                     dbc.InputGroup(
416                         [
417                             dbc.InputGroupText(
418                                 children=show_tooltip(
419                                     self._tooltips,
420                                     "help-cadence",
421                                     "Cadence"
422                                 )
423                             ),
424                             dbc.RadioItems(
425                                 id="ri-cadences",
426                                 inline=True,
427                                 value=self._default["cadence"],
428                                 options=self._default["cadences"],
429                                 class_name="form-control"
430                             )
431                         ],
432                         size="sm"
433                     )
434                 ]
435             ),
436             dbc.Row(
437                 class_name="g-0 p-1",
438                 children=[
439                     dbc.InputGroup(
440                         [
441                             dbc.InputGroupText(
442                                 children=show_tooltip(
443                                     self._tooltips,
444                                     "help-tbed",
445                                     "Test Bed"
446                                 )
447                             ),
448                             dbc.Select(
449                                 id="dd-tbeds",
450                                 placeholder="Select a test bed...",
451                                 value=self._default["tbed"],
452                                 options=self._default["tbeds"]
453                             )
454                         ],
455                         size="sm"
456                     )
457                 ]
458             ),
459             dbc.Row(
460                 class_name="g-0 p-1",
461                 children=[
462                     dbc.Alert(
463                         id="al-job",
464                         color="info",
465                         children=self._default["job"]
466                     )
467                 ]
468             )
469         ]
470
471     def _get_plotting_area(
472             self,
473             job: str,
474             url: str
475         ) -> list:
476         """Generate the plotting area with all its content.
477
478         :param job: The job which data will be displayed.
479         :param url: URL to be displayed in the modal window.
480         :type job: str
481         :type url: str
482         :returns: List of rows with elements to be displayed in the plotting
483             area.
484         :rtype: list
485         """
486
487         figs = graph_statistics(self._data, job, self._graph_layout)
488
489         if not figs[0]:
490             return C.PLACEHOLDER
491
492         return [
493             dbc.Row(
494                 id="row-graph-passed",
495                 class_name="g-0 p-1",
496                 children=[
497                     dcc.Graph(
498                         id="graph-passed",
499                         figure=figs[0]
500                     )
501                 ]
502             ),
503             dbc.Row(
504                 id="row-graph-duration",
505                 class_name="g-0 p-1",
506                 children=[
507                     dcc.Graph(
508                         id="graph-duration",
509                         figure=figs[1]
510                     )
511                 ]
512             ),
513             dbc.Row(
514                 [
515                     dbc.Col([html.Div(
516                         [
517                             dbc.Button(
518                                 id="plot-btn-url",
519                                 children="Show URL",
520                                 class_name="me-1",
521                                 color="info",
522                                 style={
523                                     "text-transform": "none",
524                                     "padding": "0rem 1rem"
525                                 }
526                             ),
527                             dbc.Modal(
528                                 [
529                                     dbc.ModalHeader(dbc.ModalTitle("URL")),
530                                     dbc.ModalBody(url)
531                                 ],
532                                 id="plot-mod-url",
533                                 size="xl",
534                                 is_open=False,
535                                 scrollable=True
536                             ),
537                             dbc.Button(
538                                 id="plot-btn-download",
539                                 children="Download Data",
540                                 class_name="me-1",
541                                 color="info",
542                                 style={
543                                     "text-transform": "none",
544                                     "padding": "0rem 1rem"
545                                 }
546                             ),
547                             dcc.Download(id="download-stats-data")
548                         ],
549                         className=\
550                             "d-grid gap-0 d-md-flex justify-content-md-end"
551                     )])
552                 ],
553                 class_name="g-0 p-0"
554             )
555         ]
556
557     def callbacks(self, app):
558         """Callbacks for the whole application.
559
560         :param app: The application.
561         :type app: Flask
562         """
563
564         @app.callback(
565             Output("control-panel", "data"),  # Store
566             Output("plotting-area", "children"),
567             Output("ri-ttypes", "options"),
568             Output("ri-cadences", "options"),
569             Output("dd-tbeds", "options"),
570             Output("ri-duts", "value"),
571             Output("ri-ttypes", "value"),
572             Output("ri-cadences", "value"),
573             Output("dd-tbeds", "value"),
574             Output("al-job", "children"),
575             State("control-panel", "data"),  # Store
576             Input("ri-duts", "value"),
577             Input("ri-ttypes", "value"),
578             Input("ri-cadences", "value"),
579             Input("dd-tbeds", "value"),
580             Input("url", "href")
581         )
582         def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str, cadence:str,
583                 tbed: str, href: str) -> tuple:
584             """Update the application when the event is detected.
585
586             :param cp_data: Current status of the control panel stored in
587                 browser.
588             :param dut: Input - DUT name.
589             :param ttype: Input - Test type.
590             :param cadence: Input - The cadence of the job.
591             :param tbed: Input - The test bed.
592             :param href: Input - The URL provided by the browser.
593             :type cp_data: dict
594             :type dut: str
595             :type ttype: str
596             :type cadence: str
597             :type tbed: str
598             :type href: str
599             :returns: New values for web page elements.
600             :rtype: tuple
601             """
602
603             ctrl_panel = ControlPanel(self._cp_default, cp_data)
604
605             # Parse the url:
606             parsed_url = url_decode(href)
607             if parsed_url:
608                 url_params = parsed_url["params"]
609             else:
610                 url_params = None
611
612             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
613             if trigger_id == "ri-duts":
614                 ttype_opts = generate_options(get_ttypes(self._job_info, dut))
615                 ttype_val = ttype_opts[0]["value"]
616                 cad_opts = generate_options(get_cadences(
617                     self._job_info, dut, ttype_val))
618                 cad_val = cad_opts[0]["value"]
619                 tbed_opts = generate_options(get_test_beds(
620                     self._job_info, dut, ttype_val, cad_val))
621                 tbed_val = tbed_opts[0]["value"]
622                 ctrl_panel.set({
623                     "ri-duts-value": dut,
624                     "ri-ttypes-options": ttype_opts,
625                     "ri-ttypes-value": ttype_val,
626                     "ri-cadences-options": cad_opts,
627                     "ri-cadences-value": cad_val,
628                     "dd-tbeds-options": tbed_opts,
629                     "dd-tbeds-value": tbed_val
630                 })
631             elif trigger_id == "ri-ttypes":
632                 cad_opts = generate_options(get_cadences(
633                     self._job_info, ctrl_panel.get("ri-duts-value"), ttype))
634                 cad_val = cad_opts[0]["value"]
635                 tbed_opts = generate_options(get_test_beds(
636                     self._job_info, ctrl_panel.get("ri-duts-value"), ttype,
637                     cad_val))
638                 tbed_val = tbed_opts[0]["value"]
639                 ctrl_panel.set({
640                     "ri-ttypes-value": ttype,
641                     "ri-cadences-options": cad_opts,
642                     "ri-cadences-value": cad_val,
643                     "dd-tbeds-options": tbed_opts,
644                     "dd-tbeds-value": tbed_val
645                 })
646             elif trigger_id == "ri-cadences":
647                 tbed_opts = generate_options(get_test_beds(
648                     self._job_info, ctrl_panel.get("ri-duts-value"),
649                     ctrl_panel.get("ri-ttypes-value"), cadence))
650                 tbed_val = tbed_opts[0]["value"]
651                 ctrl_panel.set({
652                     "ri-cadences-value": cadence,
653                     "dd-tbeds-options": tbed_opts,
654                     "dd-tbeds-value": tbed_val
655                 })
656             elif trigger_id == "dd-tbeds":
657                 ctrl_panel.set({
658                     "dd-tbeds-value": tbed
659                 })
660             elif trigger_id == "url":
661                 if url_params:
662                     new_job = url_params.get("job", list())[0]
663                     if new_job:
664                         job_params = set_job_params(self._job_info, new_job)
665                         ctrl_panel = ControlPanel(
666                             {
667                                 "ri-ttypes-options": job_params["ttypes"],
668                                 "ri-cadences-options": job_params["cadences"],
669                                 "dd-tbeds-options": job_params["tbeds"],
670                                 "ri-duts-value": job_params["dut"],
671                                 "ri-ttypes-value": job_params["ttype"],
672                                 "ri-cadences-value": job_params["cadence"],
673                                 "dd-tbeds-value": job_params["tbed"],
674                                 "al-job-children": job_params["job"]
675                             },
676                             None
677                         )
678                 else:
679                     ctrl_panel = ControlPanel(self._cp_default, cp_data)
680
681             job = get_job(
682                 self._job_info,
683                 ctrl_panel.get("ri-duts-value"),
684                 ctrl_panel.get("ri-ttypes-value"),
685                 ctrl_panel.get("ri-cadences-value"),
686                 ctrl_panel.get("dd-tbeds-value")
687             )
688
689             ctrl_panel.set({"al-job-children": job})
690             plotting_area = self._get_plotting_area(
691                 job,
692                 gen_new_url(parsed_url, {"job": job})
693             )
694
695             ret_val = [
696                 ctrl_panel.panel,
697                 plotting_area
698             ]
699             ret_val.extend(ctrl_panel.values)
700             return ret_val
701
702         @app.callback(
703             Output("plot-mod-url", "is_open"),
704             [Input("plot-btn-url", "n_clicks")],
705             [State("plot-mod-url", "is_open")],
706         )
707         def toggle_plot_mod_url(n, is_open):
708             """Toggle the modal window with url.
709             """
710             if n:
711                 return not is_open
712             return is_open
713
714         @app.callback(
715             Output("download-stats-data", "data"),
716             State("control-panel", "data"),  # Store
717             Input("plot-btn-download", "n_clicks"),
718             prevent_initial_call=True
719         )
720         def _download_data(cp_data: dict, n_clicks: int):
721             """Download the data
722
723             :param cp_data: Current status of the control panel stored in
724                 browser.
725             :param n_clicks: Number of clicks on the button "Download".
726             :type cp_data: dict
727             :type n_clicks: int
728             :returns: dict of data frame content (base64 encoded) and meta data
729                 used by the Download component.
730             :rtype: dict
731             """
732             if not n_clicks:
733                 raise PreventUpdate
734
735             ctrl_panel = ControlPanel(self._cp_default, cp_data)
736
737             job = get_job(
738                 self._job_info,
739                 ctrl_panel.get("ri-duts-value"),
740                 ctrl_panel.get("ri-ttypes-value"),
741                 ctrl_panel.get("ri-cadences-value"),
742                 ctrl_panel.get("dd-tbeds-value")
743             )
744
745             data = select_data(self._data, job)
746             data = data.drop(columns=["job", ])
747
748             return dcc.send_data_frame(
749                 data.T.to_csv, f"{job}-{C.STATS_DOWNLOAD_FILE_NAME}")
750
751         @app.callback(
752             Output("row-metadata", "children"),
753             Output("offcanvas-metadata", "is_open"),
754             Input("graph-passed", "clickData"),
755             Input("graph-duration", "clickData"),
756             prevent_initial_call=True
757         )
758         def _show_metadata_from_graphs(
759                 passed_data: dict, duration_data: dict) -> tuple:
760             """Generates the data for the offcanvas displayed when a particular
761             point in a graph is clicked on.
762
763             :param passed_data: The data from the clicked point in the graph
764                 displaying the pass/fail data.
765             :param duration_data: The data from the clicked point in the graph
766                 displaying the duration data.
767             :type passed_data: dict
768             :type duration data: dict
769             :returns: The data to be displayed on the offcanvas (job statistics
770                 and the list of failed tests) and the information to show the
771                 offcanvas.
772             :rtype: tuple(list, bool)
773             """
774
775             if not (passed_data or duration_data):
776                 raise PreventUpdate
777
778             metadata = no_update
779             open_canvas = False
780             title = "Job Statistics"
781             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
782             if trigger_id == "graph-passed":
783                 graph_data = passed_data["points"][0].get("hovertext", "")
784             elif trigger_id == "graph-duration":
785                 graph_data = duration_data["points"][0].get("text", "")
786             if graph_data:
787                 lst_graph_data = graph_data.split("<br>")
788
789                 # Prepare list of failed tests:
790                 job = str()
791                 build = str()
792                 for itm in lst_graph_data:
793                     if "csit-ref:" in itm:
794                         job, build = itm.split(" ")[-1].split("/")
795                         break
796                 if job and build:
797                     fail_tests = self._data.loc[
798                         (self._data["job"] == job) &
799                         (self._data["build"] == build)
800                     ]["lst_failed"].values[0]
801                     if not fail_tests:
802                         fail_tests = None
803                 else:
804                     fail_tests = None
805
806                 # Create the content of the offcanvas:
807                 metadata = [
808                     dbc.Card(
809                         class_name="gy-2 p-0",
810                         children=[
811                             dbc.CardHeader(children=[
812                                 dcc.Clipboard(
813                                     target_id="metadata",
814                                     title="Copy",
815                                     style={"display": "inline-block"}
816                                 ),
817                                 title
818                             ]),
819                             dbc.CardBody(
820                                 id="metadata",
821                                 class_name="p-0",
822                                 children=[dbc.ListGroup(
823                                     children=[
824                                         dbc.ListGroupItem(
825                                             [
826                                                 dbc.Badge(
827                                                     x.split(":")[0]
828                                                 ),
829                                                 x.split(": ")[1]
830                                             ]
831                                         ) for x in lst_graph_data
832                                     ],
833                                     flush=True),
834                                 ]
835                             )
836                         ]
837                     )
838                 ]
839
840                 if fail_tests is not None:
841                     metadata.append(
842                         dbc.Card(
843                             class_name="gy-2 p-0",
844                             children=[
845                                 dbc.CardHeader(
846                                     f"List of Failed Tests ({len(fail_tests)})"
847                                 ),
848                                 dbc.CardBody(
849                                     id="failed-tests",
850                                     class_name="p-0",
851                                     children=[dbc.ListGroup(
852                                         children=[
853                                             dbc.ListGroupItem(x) \
854                                                 for x in fail_tests
855                                         ],
856                                         flush=True),
857                                     ]
858                                 )
859                             ]
860                         )
861                     )
862
863                 open_canvas = True
864
865             return metadata, open_canvas
866
867         @app.callback(
868             Output("offcanvas-documentation", "is_open"),
869             Input("btn-documentation", "n_clicks"),
870             State("offcanvas-documentation", "is_open")
871         )
872         def toggle_offcanvas_documentation(n_clicks, is_open):
873             if n_clicks:
874                 return not is_open
875             return is_open