CDash: 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         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=C.BRAND,
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             html.Div(
326                 children=self._add_ctrl_panel(),
327                 className="sticky-top"
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 [
422             dbc.Row(
423                 class_name="g-0 p-1",
424                 children=[
425                     dbc.Label(
426                         children=show_tooltip(self._tooltips,
427                             "help-dut", "Device under Test")
428                     ),
429                     dbc.RadioItems(
430                         id="ri-duts",
431                         inline=True,
432                         value=self.default["dut"],
433                         options=self.default["duts"]
434                     )
435                 ]
436             ),
437             dbc.Row(
438                 class_name="g-0 p-1",
439                 children=[
440                     dbc.Label(
441                         children=show_tooltip(self._tooltips,
442                             "help-ttype", "Test Type"),
443                     ),
444                     dbc.RadioItems(
445                         id="ri-ttypes",
446                         inline=True,
447                         value=self.default["ttype"],
448                         options=self.default["ttypes"]
449                     )
450                 ]
451             ),
452             dbc.Row(
453                 class_name="g-0 p-1",
454                 children=[
455                     dbc.Label(
456                         children=show_tooltip(self._tooltips,
457                             "help-cadence", "Cadence"),
458                     ),
459                     dbc.RadioItems(
460                         id="ri-cadences",
461                         inline=True,
462                         value=self.default["cadence"],
463                         options=self.default["cadences"]
464                     )
465                 ]
466             ),
467             dbc.Row(
468                 class_name="g-0 p-1",
469                 children=[
470                     dbc.Label(
471                         children=show_tooltip(self._tooltips,
472                             "help-tbed", "Test Bed"),
473                     ),
474                     dbc.Select(
475                         id="dd-tbeds",
476                         placeholder="Select a test bed...",
477                         value=self.default["tbed"],
478                         options=self.default["tbeds"]
479                     )
480                 ]
481             ),
482             dbc.Row(
483                 class_name="g-0 p-1",
484                 children=[
485                     dbc.Alert(
486                         id="al-job",
487                         color="info",
488                         children=self.default["job"]
489                     )
490                 ]
491             )
492         ]
493
494     class ControlPanel:
495         """A class representing the control panel.
496         """
497
498         def __init__(self, panel: dict, default: dict) -> None:
499             """Initialisation of the control pannel by default values. If
500             particular values are provided (parameter "panel") they are set
501             afterwards.
502
503             :param panel: Custom values to be set to the control panel.
504             :param default: Default values to be set to the control panel.
505             :type panel: dict
506             :type defaults: dict
507             """
508
509             self._defaults = {
510                 "ri-ttypes-options": default["ttypes"],
511                 "ri-cadences-options": default["cadences"],
512                 "dd-tbeds-options": default["tbeds"],
513                 "ri-duts-value": default["dut"],
514                 "ri-ttypes-value": default["ttype"],
515                 "ri-cadences-value": default["cadence"],
516                 "dd-tbeds-value": default["tbed"],
517                 "al-job-children": default["job"]
518             }
519             self._panel = deepcopy(self._defaults)
520             if panel:
521                 for key in self._defaults:
522                     self._panel[key] = panel[key]
523
524         def set(self, kwargs: dict) -> None:
525             """Set the values of the Control panel.
526
527             :param kwargs: key - value pairs to be set.
528             :type kwargs: dict
529             :raises KeyError: If the key in kwargs is not present in the Control
530                 panel.
531             """
532             for key, val in kwargs.items():
533                 if key in self._panel:
534                     self._panel[key] = val
535                 else:
536                     raise KeyError(f"The key {key} is not defined.")
537
538         @property
539         def defaults(self) -> dict:
540             return self._defaults
541
542         @property
543         def panel(self) -> dict:
544             return self._panel
545
546         def get(self, key: str) -> any:
547             """Returns the value of a key from the Control panel.
548
549             :param key: The key which value should be returned.
550             :type key: str
551             :returns: The value of the key.
552             :rtype: any
553             :raises KeyError: If the key in kwargs is not present in the Control
554                 panel.
555             """
556             return self._panel[key]
557
558         def values(self) -> list:
559             """Returns the values from the Control panel as a list.
560
561             :returns: The values from the Control panel.
562             :rtype: list
563             """
564             return list(self._panel.values())
565
566
567     def callbacks(self, app):
568         """Callbacks for the whole application.
569
570         :param app: The application.
571         :type app: Flask
572         """
573
574         @app.callback(
575             Output("control-panel", "data"),  # Store
576             Output("graph-passed", "figure"),
577             Output("graph-duration", "figure"),
578             Output("input-url", "value"),
579             Output("ri-ttypes", "options"),
580             Output("ri-cadences", "options"),
581             Output("dd-tbeds", "options"),
582             Output("ri-duts", "value"),
583             Output("ri-ttypes", "value"),
584             Output("ri-cadences", "value"),
585             Output("dd-tbeds", "value"),
586             Output("al-job", "children"),
587             State("control-panel", "data"),  # Store
588             Input("ri-duts", "value"),
589             Input("ri-ttypes", "value"),
590             Input("ri-cadences", "value"),
591             Input("dd-tbeds", "value"),
592             Input("url", "href")
593         )
594         def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str, cadence:str,
595                 tbed: str, href: str) -> tuple:
596             """Update the application when the event is detected.
597
598             :param cp_data: Current status of the control panel stored in
599                 browser.
600             :param dut: Input - DUT name.
601             :param ttype: Input - Test type.
602             :param cadence: Input - The cadence of the job.
603             :param tbed: Input - The test bed.
604             :param href: Input - The URL provided by the browser.
605             :type cp_data: dict
606             :type dut: str
607             :type ttype: str
608             :type cadence: str
609             :type tbed: str
610             :type href: str
611             :returns: New values for web page elements.
612             :rtype: tuple
613             """
614
615             ctrl_panel = self.ControlPanel(cp_data, self.default)
616
617             # Parse the url:
618             parsed_url = url_decode(href)
619             if parsed_url:
620                 url_params = parsed_url["params"]
621             else:
622                 url_params = None
623
624             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
625             if trigger_id == "ri-duts":
626                 ttype_opts = generate_options(get_ttypes(self.job_info, dut))
627                 ttype_val = ttype_opts[0]["value"]
628                 cad_opts = generate_options(get_cadences(
629                     self.job_info, dut, ttype_val))
630                 cad_val = cad_opts[0]["value"]
631                 tbed_opts = generate_options(get_test_beds(
632                     self.job_info, dut, ttype_val, cad_val))
633                 tbed_val = tbed_opts[0]["value"]
634                 ctrl_panel.set({
635                     "ri-duts-value": dut,
636                     "ri-ttypes-options": ttype_opts,
637                     "ri-ttypes-value": ttype_val,
638                     "ri-cadences-options": cad_opts,
639                     "ri-cadences-value": cad_val,
640                     "dd-tbeds-options": tbed_opts,
641                     "dd-tbeds-value": tbed_val
642                 })
643             elif trigger_id == "ri-ttypes":
644                 cad_opts = generate_options(get_cadences(
645                     self.job_info, ctrl_panel.get("ri-duts-value"), ttype))
646                 cad_val = cad_opts[0]["value"]
647                 tbed_opts = generate_options(get_test_beds(
648                     self.job_info, ctrl_panel.get("ri-duts-value"), ttype,
649                     cad_val))
650                 tbed_val = tbed_opts[0]["value"]
651                 ctrl_panel.set({
652                     "ri-ttypes-value": ttype,
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-cadences":
659                 tbed_opts = generate_options(get_test_beds(
660                     self.job_info, ctrl_panel.get("ri-duts-value"),
661                     ctrl_panel.get("ri-ttypes-value"), cadence))
662                 tbed_val = tbed_opts[0]["value"]
663                 ctrl_panel.set({
664                     "ri-cadences-value": cadence,
665                     "dd-tbeds-options": tbed_opts,
666                     "dd-tbeds-value": tbed_val
667                 })
668             elif trigger_id == "dd-tbeds":
669                 ctrl_panel.set({
670                     "dd-tbeds-value": tbed
671                 })
672             elif trigger_id == "url":
673                 if url_params:
674                     new_job = url_params.get("job", list())[0]
675                     if new_job:
676                         job_params = set_job_params(self.job_info, new_job)
677                         ctrl_panel = self.ControlPanel(None, job_params)
678                 else:
679                     ctrl_panel = self.ControlPanel(cp_data, self.default)
680
681             job = get_job(
682                 self.job_info,
683                 ctrl_panel.get("ri-duts-value"),
684                 ctrl_panel.get("ri-ttypes-value"),
685                 ctrl_panel.get("ri-cadences-value"),
686                 ctrl_panel.get("dd-tbeds-value")
687             )
688
689             ctrl_panel.set({"al-job-children": job})
690             fig_passed, fig_duration = \
691                 graph_statistics(self.data, job, self.layout)
692
693             ret_val = [
694                 ctrl_panel.panel,
695                 fig_passed,
696                 fig_duration,
697                 gen_new_url(parsed_url, {"job": job})
698             ]
699             ret_val.extend(ctrl_panel.values())
700             return ret_val
701
702         @app.callback(
703             Output("download-data", "data"),
704             State("control-panel", "data"),  # Store
705             Input("btn-download-data", "n_clicks"),
706             prevent_initial_call=True
707         )
708         def _download_data(cp_data: dict, n_clicks: int):
709             """Download the data
710
711             :param cp_data: Current status of the control panel stored in
712                 browser.
713             :param n_clicks: Number of clicks on the button "Download".
714             :type cp_data: dict
715             :type n_clicks: int
716             :returns: dict of data frame content (base64 encoded) and meta data
717                 used by the Download component.
718             :rtype: dict
719             """
720             if not (n_clicks):
721                 raise PreventUpdate
722
723             ctrl_panel = self.ControlPanel(cp_data, self.default)
724
725             job = get_job(
726                 self.job_info,
727                 ctrl_panel.get("ri-duts-value"),
728                 ctrl_panel.get("ri-ttypes-value"),
729                 ctrl_panel.get("ri-cadences-value"),
730                 ctrl_panel.get("dd-tbeds-value")
731             )
732
733             data = select_data(self.data, job)
734             data = data.drop(columns=["job", ])
735
736             return dcc.send_data_frame(
737                 data.T.to_csv, f"{job}-{C.STATS_DOWNLOAD_FILE_NAME}")
738
739         @app.callback(
740             Output("row-metadata", "children"),
741             Output("offcanvas-metadata", "is_open"),
742             Input("graph-passed", "clickData"),
743             Input("graph-duration", "clickData"),
744             prevent_initial_call=True
745         )
746         def _show_metadata_from_graphs(
747                 passed_data: dict, duration_data: dict) -> tuple:
748             """Generates the data for the offcanvas displayed when a particular
749             point in a graph is clicked on.
750
751             :param passed_data: The data from the clicked point in the graph
752                 displaying the pass/fail data.
753             :param duration_data: The data from the clicked point in the graph
754                 displaying the duration data.
755             :type passed_data: dict
756             :type duration data: dict
757             :returns: The data to be displayed on the offcanvas (job statistics
758                 and the list of failed tests) and the information to show the
759                 offcanvas.
760             :rtype: tuple(list, bool)
761             """
762
763             if not (passed_data or duration_data):
764                 raise PreventUpdate
765
766             metadata = no_update
767             open_canvas = False
768             title = "Job Statistics"
769             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
770             if trigger_id == "graph-passed":
771                 graph_data = passed_data["points"][0].get("hovertext", "")
772             elif trigger_id == "graph-duration":
773                 graph_data = duration_data["points"][0].get("text", "")
774             if graph_data:
775                 lst_graph_data = graph_data.split("<br>")
776
777                 # Prepare list of failed tests:
778                 job = str()
779                 build = str()
780                 for itm in lst_graph_data:
781                     if "csit-ref:" in itm:
782                         job, build = itm.split(" ")[-1].split("/")
783                         break
784                 if job and build:
785                     fail_tests = self.data.loc[
786                         (self.data["job"] == job) &
787                         (self.data["build"] == build)
788                     ]["lst_failed"].values[0]
789                     if not fail_tests:
790                         fail_tests = None
791                 else:
792                     fail_tests = None
793
794                 # Create the content of the offcanvas:
795                 metadata = [
796                     dbc.Card(
797                         class_name="gy-2 p-0",
798                         children=[
799                             dbc.CardHeader(children=[
800                                 dcc.Clipboard(
801                                     target_id="metadata",
802                                     title="Copy",
803                                     style={"display": "inline-block"}
804                                 ),
805                                 title
806                             ]),
807                             dbc.CardBody(
808                                 id="metadata",
809                                 class_name="p-0",
810                                 children=[dbc.ListGroup(
811                                     children=[
812                                         dbc.ListGroupItem(
813                                             [
814                                                 dbc.Badge(
815                                                     x.split(":")[0]
816                                                 ),
817                                                 x.split(": ")[1]
818                                             ]
819                                         ) for x in lst_graph_data
820                                     ],
821                                     flush=True),
822                                 ]
823                             )
824                         ]
825                     )
826                 ]
827
828                 if fail_tests is not None:
829                     metadata.append(
830                         dbc.Card(
831                             class_name="gy-2 p-0",
832                             children=[
833                                 dbc.CardHeader(
834                                     f"List of Failed Tests ({len(fail_tests)})"
835                                 ),
836                                 dbc.CardBody(
837                                     id="failed-tests",
838                                     class_name="p-0",
839                                     children=[dbc.ListGroup(
840                                         children=[
841                                             dbc.ListGroupItem(x) \
842                                                 for x in fail_tests
843                                         ],
844                                         flush=True),
845                                     ]
846                                 )
847                             ]
848                         )
849                     )
850
851                 open_canvas = True
852
853             return metadata, open_canvas