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