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