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