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