C-Dash: Small changes in styling
[csit.git] / csit.infra.dash / app / cdash / 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
30 from ..utils.constants import Constants as C
31 from ..utils.control_panel import ControlPanel
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 = str()
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         # Control panel partameters and their default values.
202         self._cp_default = {
203             "ri-ttypes-options": self._default["ttypes"],
204             "ri-cadences-options": self._default["cadences"],
205             "dd-tbeds-options": self._default["tbeds"],
206             "ri-duts-value": self._default["dut"],
207             "ri-ttypes-value": self._default["ttype"],
208             "ri-cadences-value": self._default["cadence"],
209             "dd-tbeds-value": self._default["tbed"],
210             "al-job-children": self._default["job"]
211         }
212
213         # Callbacks:
214         if self._app is not None and hasattr(self, "callbacks"):
215             self.callbacks(self._app)
216
217     @property
218     def html_layout(self) -> dict:
219         return self._html_layout
220
221     def add_content(self):
222         """Top level method which generated the web page.
223
224         It generates:
225         - Store for user input data,
226         - Navigation bar,
227         - Main area with control panel and ploting area.
228
229         If no HTML layout is provided, an error message is displayed instead.
230
231         :returns: The HTML div with the whole page.
232         :rtype: html.Div
233         """
234
235         if self.html_layout:
236             return html.Div(
237                 id="div-main",
238                 className="small",
239                 children=[
240                     dcc.Store(id="control-panel"),
241                     dcc.Location(id="url", refresh=False),
242                     dbc.Row(
243                         id="row-navbar",
244                         class_name="g-0",
245                         children=[
246                             self._add_navbar()
247                         ]
248                     ),
249                     dcc.Loading(
250                         dbc.Offcanvas(
251                             class_name="w-50",
252                             id="offcanvas-metadata",
253                             title="Detailed Information",
254                             placement="end",
255                             is_open=False,
256                             children=[
257                                 dbc.Row(id="row-metadata")
258                             ]
259                         )
260                     ),
261                     dbc.Row(
262                         id="row-main",
263                         class_name="g-0",
264                         children=[
265                             self._add_ctrl_col(),
266                             self._add_plotting_col()
267                         ]
268                     )
269                 ]
270             )
271         else:
272             return html.Div(
273                 id="div-main-error",
274                 children=[
275                     dbc.Alert(
276                         [
277                             "An Error Occured",
278                         ],
279                         color="danger"
280                     )
281                 ]
282             )
283
284     def _add_navbar(self):
285         """Add nav element with navigation panel. It is placed on the top.
286
287         :returns: Navigation bar.
288         :rtype: dbc.NavbarSimple
289         """
290         return dbc.NavbarSimple(
291             id="navbarsimple-main",
292             children=[
293                 dbc.NavItem(
294                     dbc.NavLink(
295                         C.STATS_TITLE,
296                         disabled=True,
297                         external_link=True,
298                         href="#"
299                     )
300                 )
301             ],
302             brand=C.BRAND,
303             brand_href="/",
304             brand_external_link=True,
305             class_name="p-2",
306             fluid=True
307         )
308
309     def _add_ctrl_col(self) -> dbc.Col:
310         """Add column with controls. It is placed on the left side.
311
312         :returns: Column with the control panel.
313         :rtype: dbc.Col
314         """
315         return dbc.Col([
316             html.Div(
317                 children=self._add_ctrl_panel(),
318                 className="sticky-top"
319             )
320         ])
321
322     def _add_plotting_col(self) -> dbc.Col:
323         """Add column with plots and tables. It is placed on the right side.
324
325         :returns: Column with tables.
326         :rtype: dbc.Col
327         """
328         return dbc.Col(
329             id="col-plotting-area",
330             children=[
331                 dcc.Loading(
332                     children=[
333                         dbc.Row(
334                             id="plotting-area",
335                             class_name="g-0 p-0",
336                             children=[
337                                 C.PLACEHOLDER
338                             ]
339                         )
340                     ]
341                 )
342             ],
343             width=9
344         )
345
346     def _add_ctrl_panel(self) -> dbc.Row:
347         """Add control panel.
348
349         :returns: Control panel.
350         :rtype: dbc.Row
351         """
352         return [
353             dbc.Row(
354                 class_name="g-0 p-1",
355                 children=[
356                     dbc.InputGroup(
357                         [
358                             dbc.InputGroupText(
359                                 children=show_tooltip(
360                                     self._tooltips,
361                                     "help-dut",
362                                     "DUT"
363                                 )
364                             ),
365                             dbc.RadioItems(
366                                 id="ri-duts",
367                                 inline=True,
368                                 value=self._default["dut"],
369                                 options=self._default["duts"],
370                                 class_name="form-control"
371                             )
372                         ],
373                         size="sm"
374                     )
375                 ]
376             ),
377             dbc.Row(
378                 class_name="g-0 p-1",
379                 children=[
380                     dbc.InputGroup(
381                         [
382                             dbc.InputGroupText(
383                                 children=show_tooltip(
384                                     self._tooltips,
385                                     "help-ttype",
386                                     "Test Type"
387                                 )
388                             ),
389                             dbc.RadioItems(
390                                 id="ri-ttypes",
391                                 inline=True,
392                                 value=self._default["ttype"],
393                                 options=self._default["ttypes"],
394                                 class_name="form-control"
395                             )
396                         ],
397                         size="sm"
398                     )
399                 ]
400             ),
401             dbc.Row(
402                 class_name="g-0 p-1",
403                 children=[
404                     dbc.InputGroup(
405                         [
406                             dbc.InputGroupText(
407                                 children=show_tooltip(
408                                     self._tooltips,
409                                     "help-cadence",
410                                     "Cadence"
411                                 )
412                             ),
413                             dbc.RadioItems(
414                                 id="ri-cadences",
415                                 inline=True,
416                                 value=self._default["cadence"],
417                                 options=self._default["cadences"],
418                                 class_name="form-control"
419                             )
420                         ],
421                         size="sm"
422                     )
423                 ]
424             ),
425             dbc.Row(
426                 class_name="g-0 p-1",
427                 children=[
428                     dbc.InputGroup(
429                         [
430                             dbc.InputGroupText(
431                                 children=show_tooltip(
432                                     self._tooltips,
433                                     "help-tbed",
434                                     "Test Bed"
435                                 )
436                             ),
437                             dbc.Select(
438                                 id="dd-tbeds",
439                                 placeholder="Select a test bed...",
440                                 value=self._default["tbed"],
441                                 options=self._default["tbeds"]
442                             )
443                         ],
444                         size="sm"
445                     )
446                 ]
447             ),
448             dbc.Row(
449                 class_name="g-0 p-1",
450                 children=[
451                     dbc.Alert(
452                         id="al-job",
453                         color="info",
454                         children=self._default["job"]
455                     )
456                 ]
457             )
458         ]
459
460     def _get_plotting_area(
461             self,
462             job: str,
463             url: str
464         ) -> list:
465         """Generate the plotting area with all its content.
466
467         :param job: The job which data will be displayed.
468         :param url: URL to be displayed in the modal window.
469         :type job: str
470         :type url: str
471         :returns: List of rows with elements to be displayed in the plotting
472             area.
473         :rtype: list
474         """
475
476         figs = graph_statistics(self._data, job, self._graph_layout)
477
478         if not figs[0]:
479             return C.PLACEHOLDER
480
481         return [
482             dbc.Row(
483                 id="row-graph-passed",
484                 class_name="g-0 p-1",
485                 children=[
486                     dcc.Graph(
487                         id="graph-passed",
488                         figure=figs[0]
489                     )
490                 ]
491             ),
492             dbc.Row(
493                 id="row-graph-duration",
494                 class_name="g-0 p-1",
495                 children=[
496                     dcc.Graph(
497                         id="graph-duration",
498                         figure=figs[1]
499                     )
500                 ]
501             ),
502             dbc.Row(
503                 [
504                     dbc.Col([html.Div(
505                         [
506                             dbc.Button(
507                                 id="plot-btn-url",
508                                 children="Show URL",
509                                 class_name="me-1",
510                                 color="info",
511                                 style={
512                                     "text-transform": "none",
513                                     "padding": "0rem 1rem"
514                                 }
515                             ),
516                             dbc.Modal(
517                                 [
518                                     dbc.ModalHeader(dbc.ModalTitle("URL")),
519                                     dbc.ModalBody(url)
520                                 ],
521                                 id="plot-mod-url",
522                                 size="xl",
523                                 is_open=False,
524                                 scrollable=True
525                             ),
526                             dbc.Button(
527                                 id="plot-btn-download",
528                                 children="Download Data",
529                                 class_name="me-1",
530                                 color="info",
531                                 style={
532                                     "text-transform": "none",
533                                     "padding": "0rem 1rem"
534                                 }
535                             ),
536                             dcc.Download(id="download-stats-data")
537                         ],
538                         className=\
539                             "d-grid gap-0 d-md-flex justify-content-md-end"
540                     )])
541                 ],
542                 class_name="g-0 p-0"
543             )
544         ]
545
546     def callbacks(self, app):
547         """Callbacks for the whole application.
548
549         :param app: The application.
550         :type app: Flask
551         """
552
553         @app.callback(
554             Output("control-panel", "data"),  # Store
555             Output("plotting-area", "children"),
556             Output("ri-ttypes", "options"),
557             Output("ri-cadences", "options"),
558             Output("dd-tbeds", "options"),
559             Output("ri-duts", "value"),
560             Output("ri-ttypes", "value"),
561             Output("ri-cadences", "value"),
562             Output("dd-tbeds", "value"),
563             Output("al-job", "children"),
564             State("control-panel", "data"),  # Store
565             Input("ri-duts", "value"),
566             Input("ri-ttypes", "value"),
567             Input("ri-cadences", "value"),
568             Input("dd-tbeds", "value"),
569             Input("url", "href")
570         )
571         def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str, cadence:str,
572                 tbed: str, href: str) -> tuple:
573             """Update the application when the event is detected.
574
575             :param cp_data: Current status of the control panel stored in
576                 browser.
577             :param dut: Input - DUT name.
578             :param ttype: Input - Test type.
579             :param cadence: Input - The cadence of the job.
580             :param tbed: Input - The test bed.
581             :param href: Input - The URL provided by the browser.
582             :type cp_data: dict
583             :type dut: str
584             :type ttype: str
585             :type cadence: str
586             :type tbed: str
587             :type href: str
588             :returns: New values for web page elements.
589             :rtype: tuple
590             """
591
592             ctrl_panel = ControlPanel(self._cp_default, cp_data)
593
594             # Parse the url:
595             parsed_url = url_decode(href)
596             if parsed_url:
597                 url_params = parsed_url["params"]
598             else:
599                 url_params = None
600
601             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
602             if trigger_id == "ri-duts":
603                 ttype_opts = generate_options(get_ttypes(self._job_info, dut))
604                 ttype_val = ttype_opts[0]["value"]
605                 cad_opts = generate_options(get_cadences(
606                     self._job_info, dut, ttype_val))
607                 cad_val = cad_opts[0]["value"]
608                 tbed_opts = generate_options(get_test_beds(
609                     self._job_info, dut, ttype_val, cad_val))
610                 tbed_val = tbed_opts[0]["value"]
611                 ctrl_panel.set({
612                     "ri-duts-value": dut,
613                     "ri-ttypes-options": ttype_opts,
614                     "ri-ttypes-value": ttype_val,
615                     "ri-cadences-options": cad_opts,
616                     "ri-cadences-value": cad_val,
617                     "dd-tbeds-options": tbed_opts,
618                     "dd-tbeds-value": tbed_val
619                 })
620             elif trigger_id == "ri-ttypes":
621                 cad_opts = generate_options(get_cadences(
622                     self._job_info, ctrl_panel.get("ri-duts-value"), ttype))
623                 cad_val = cad_opts[0]["value"]
624                 tbed_opts = generate_options(get_test_beds(
625                     self._job_info, ctrl_panel.get("ri-duts-value"), ttype,
626                     cad_val))
627                 tbed_val = tbed_opts[0]["value"]
628                 ctrl_panel.set({
629                     "ri-ttypes-value": ttype,
630                     "ri-cadences-options": cad_opts,
631                     "ri-cadences-value": cad_val,
632                     "dd-tbeds-options": tbed_opts,
633                     "dd-tbeds-value": tbed_val
634                 })
635             elif trigger_id == "ri-cadences":
636                 tbed_opts = generate_options(get_test_beds(
637                     self._job_info, ctrl_panel.get("ri-duts-value"),
638                     ctrl_panel.get("ri-ttypes-value"), cadence))
639                 tbed_val = tbed_opts[0]["value"]
640                 ctrl_panel.set({
641                     "ri-cadences-value": cadence,
642                     "dd-tbeds-options": tbed_opts,
643                     "dd-tbeds-value": tbed_val
644                 })
645             elif trigger_id == "dd-tbeds":
646                 ctrl_panel.set({
647                     "dd-tbeds-value": tbed
648                 })
649             elif trigger_id == "url":
650                 if url_params:
651                     new_job = url_params.get("job", list())[0]
652                     if new_job:
653                         job_params = set_job_params(self._job_info, new_job)
654                         ctrl_panel = ControlPanel(
655                             {
656                                 "ri-ttypes-options": job_params["ttypes"],
657                                 "ri-cadences-options": job_params["cadences"],
658                                 "dd-tbeds-options": job_params["tbeds"],
659                                 "ri-duts-value": job_params["dut"],
660                                 "ri-ttypes-value": job_params["ttype"],
661                                 "ri-cadences-value": job_params["cadence"],
662                                 "dd-tbeds-value": job_params["tbed"],
663                                 "al-job-children": job_params["job"]
664                             },
665                             None
666                         )
667                 else:
668                     ctrl_panel = ControlPanel(self._cp_default, cp_data)
669
670             job = get_job(
671                 self._job_info,
672                 ctrl_panel.get("ri-duts-value"),
673                 ctrl_panel.get("ri-ttypes-value"),
674                 ctrl_panel.get("ri-cadences-value"),
675                 ctrl_panel.get("dd-tbeds-value")
676             )
677
678             ctrl_panel.set({"al-job-children": job})
679             plotting_area = self._get_plotting_area(
680                 job,
681                 gen_new_url(parsed_url, {"job": job})
682             )
683
684             ret_val = [
685                 ctrl_panel.panel,
686                 plotting_area
687             ]
688             ret_val.extend(ctrl_panel.values)
689             return ret_val
690
691         @app.callback(
692             Output("plot-mod-url", "is_open"),
693             [Input("plot-btn-url", "n_clicks")],
694             [State("plot-mod-url", "is_open")],
695         )
696         def toggle_plot_mod_url(n, is_open):
697             """Toggle the modal window with url.
698             """
699             if n:
700                 return not is_open
701             return is_open
702
703         @app.callback(
704             Output("download-stats-data", "data"),
705             State("control-panel", "data"),  # Store
706             Input("plot-btn-download", "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 = ControlPanel(self._cp_default, cp_data)
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