C-Dash: Prepare layout for telemetry in trending
[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.Label(
357                         children=show_tooltip(
358                             self._tooltips,
359                             "help-dut",
360                             "Device under Test"
361                         )
362                     ),
363                     dbc.RadioItems(
364                         id="ri-duts",
365                         inline=True,
366                         value=self._default["dut"],
367                         options=self._default["duts"],
368                         input_class_name="border-info bg-info"
369                     )
370                 ]
371             ),
372             dbc.Row(
373                 class_name="g-0 p-1",
374                 children=[
375                     dbc.Label(
376                         children=show_tooltip(
377                             self._tooltips,
378                             "help-ttype",
379                             "Test Type"
380                         )
381                     ),
382                     dbc.RadioItems(
383                         id="ri-ttypes",
384                         inline=True,
385                         value=self._default["ttype"],
386                         options=self._default["ttypes"],
387                         input_class_name="border-info bg-info"
388                     )
389                 ]
390             ),
391             dbc.Row(
392                 class_name="g-0 p-1",
393                 children=[
394                     dbc.Label(
395                         children=show_tooltip(
396                             self._tooltips,
397                             "help-cadence",
398                             "Cadence"
399                         )
400                     ),
401                     dbc.RadioItems(
402                         id="ri-cadences",
403                         inline=True,
404                         value=self._default["cadence"],
405                         options=self._default["cadences"],
406                         input_class_name="border-info bg-info"
407                     )
408                 ]
409             ),
410             dbc.Row(
411                 class_name="g-0 p-1",
412                 children=[
413                     dbc.Label(
414                         children=show_tooltip(
415                             self._tooltips,
416                             "help-tbed",
417                             "Test Bed"
418                         )
419                     ),
420                     dbc.Select(
421                         id="dd-tbeds",
422                         placeholder="Select a test bed...",
423                         value=self._default["tbed"],
424                         options=self._default["tbeds"]
425                     )
426                 ]
427             ),
428             dbc.Row(
429                 class_name="g-0 p-1",
430                 children=[
431                     dbc.Alert(
432                         id="al-job",
433                         color="info",
434                         children=self._default["job"]
435                     )
436                 ]
437             )
438         ]
439
440     def _get_plotting_area(
441             self,
442             job: str,
443             url: str
444         ) -> list:
445         """Generate the plotting area with all its content.
446
447         :param job: The job which data will be displayed.
448         :param url: URL to be displayed in the modal window.
449         :type job: str
450         :type url: str
451         :returns: List of rows with elements to be displayed in the plotting
452             area.
453         :rtype: list
454         """
455
456         figs = graph_statistics(self._data, job, self._graph_layout)
457
458         if not figs[0]:
459             return C.PLACEHOLDER
460
461         return [
462             dbc.Row(
463                 id="row-graph-passed",
464                 class_name="g-0 p-1",
465                 children=[
466                     dcc.Graph(
467                         id="graph-passed",
468                         figure=figs[0]
469                     )
470                 ]
471             ),
472             dbc.Row(
473                 id="row-graph-duration",
474                 class_name="g-0 p-1",
475                 children=[
476                     dcc.Graph(
477                         id="graph-duration",
478                         figure=figs[1]
479                     )
480                 ]
481             ),
482             dbc.Row(
483                 [
484                     dbc.Col([html.Div(
485                         [
486                             dbc.Button(
487                                 id="plot-btn-url",
488                                 children="URL",
489                                 class_name="me-1",
490                                 color="info",
491                                 style={
492                                     "text-transform": "none",
493                                     "padding": "0rem 1rem"
494                                 }
495                             ),
496                             dbc.Modal(
497                                 [
498                                     dbc.ModalHeader(dbc.ModalTitle("URL")),
499                                     dbc.ModalBody(url)
500                                 ],
501                                 id="plot-mod-url",
502                                 size="xl",
503                                 is_open=False,
504                                 scrollable=True
505                             ),
506                             dbc.Button(
507                                 id="plot-btn-download",
508                                 children="Download Data",
509                                 class_name="me-1",
510                                 color="info",
511                                 style={
512                                     "text-transform": "none",
513                                     "padding": "0rem 1rem"
514                                 }
515                             ),
516                             dcc.Download(id="download-stats-data")
517                         ],
518                         className=\
519                             "d-grid gap-0 d-md-flex justify-content-md-end"
520                     )])
521                 ],
522                 class_name="g-0 p-0"
523             )
524         ]
525
526     def callbacks(self, app):
527         """Callbacks for the whole application.
528
529         :param app: The application.
530         :type app: Flask
531         """
532
533         @app.callback(
534             Output("control-panel", "data"),  # Store
535             Output("plotting-area", "children"),
536             Output("ri-ttypes", "options"),
537             Output("ri-cadences", "options"),
538             Output("dd-tbeds", "options"),
539             Output("ri-duts", "value"),
540             Output("ri-ttypes", "value"),
541             Output("ri-cadences", "value"),
542             Output("dd-tbeds", "value"),
543             Output("al-job", "children"),
544             State("control-panel", "data"),  # Store
545             Input("ri-duts", "value"),
546             Input("ri-ttypes", "value"),
547             Input("ri-cadences", "value"),
548             Input("dd-tbeds", "value"),
549             Input("url", "href")
550         )
551         def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str, cadence:str,
552                 tbed: str, href: str) -> tuple:
553             """Update the application when the event is detected.
554
555             :param cp_data: Current status of the control panel stored in
556                 browser.
557             :param dut: Input - DUT name.
558             :param ttype: Input - Test type.
559             :param cadence: Input - The cadence of the job.
560             :param tbed: Input - The test bed.
561             :param href: Input - The URL provided by the browser.
562             :type cp_data: dict
563             :type dut: str
564             :type ttype: str
565             :type cadence: str
566             :type tbed: str
567             :type href: str
568             :returns: New values for web page elements.
569             :rtype: tuple
570             """
571
572             ctrl_panel = ControlPanel(self._cp_default, cp_data)
573
574             # Parse the url:
575             parsed_url = url_decode(href)
576             if parsed_url:
577                 url_params = parsed_url["params"]
578             else:
579                 url_params = None
580
581             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
582             if trigger_id == "ri-duts":
583                 ttype_opts = generate_options(get_ttypes(self._job_info, dut))
584                 ttype_val = ttype_opts[0]["value"]
585                 cad_opts = generate_options(get_cadences(
586                     self._job_info, dut, ttype_val))
587                 cad_val = cad_opts[0]["value"]
588                 tbed_opts = generate_options(get_test_beds(
589                     self._job_info, dut, ttype_val, cad_val))
590                 tbed_val = tbed_opts[0]["value"]
591                 ctrl_panel.set({
592                     "ri-duts-value": dut,
593                     "ri-ttypes-options": ttype_opts,
594                     "ri-ttypes-value": ttype_val,
595                     "ri-cadences-options": cad_opts,
596                     "ri-cadences-value": cad_val,
597                     "dd-tbeds-options": tbed_opts,
598                     "dd-tbeds-value": tbed_val
599                 })
600             elif trigger_id == "ri-ttypes":
601                 cad_opts = generate_options(get_cadences(
602                     self._job_info, ctrl_panel.get("ri-duts-value"), ttype))
603                 cad_val = cad_opts[0]["value"]
604                 tbed_opts = generate_options(get_test_beds(
605                     self._job_info, ctrl_panel.get("ri-duts-value"), ttype,
606                     cad_val))
607                 tbed_val = tbed_opts[0]["value"]
608                 ctrl_panel.set({
609                     "ri-ttypes-value": ttype,
610                     "ri-cadences-options": cad_opts,
611                     "ri-cadences-value": cad_val,
612                     "dd-tbeds-options": tbed_opts,
613                     "dd-tbeds-value": tbed_val
614                 })
615             elif trigger_id == "ri-cadences":
616                 tbed_opts = generate_options(get_test_beds(
617                     self._job_info, ctrl_panel.get("ri-duts-value"),
618                     ctrl_panel.get("ri-ttypes-value"), cadence))
619                 tbed_val = tbed_opts[0]["value"]
620                 ctrl_panel.set({
621                     "ri-cadences-value": cadence,
622                     "dd-tbeds-options": tbed_opts,
623                     "dd-tbeds-value": tbed_val
624                 })
625             elif trigger_id == "dd-tbeds":
626                 ctrl_panel.set({
627                     "dd-tbeds-value": tbed
628                 })
629             elif trigger_id == "url":
630                 if url_params:
631                     new_job = url_params.get("job", list())[0]
632                     if new_job:
633                         job_params = set_job_params(self._job_info, new_job)
634                         ctrl_panel = ControlPanel(
635                             {
636                                 "ri-ttypes-options": job_params["ttypes"],
637                                 "ri-cadences-options": job_params["cadences"],
638                                 "dd-tbeds-options": job_params["tbeds"],
639                                 "ri-duts-value": job_params["dut"],
640                                 "ri-ttypes-value": job_params["ttype"],
641                                 "ri-cadences-value": job_params["cadence"],
642                                 "dd-tbeds-value": job_params["tbed"],
643                                 "al-job-children": job_params["job"]
644                             },
645                             None
646                         )
647                 else:
648                     ctrl_panel = ControlPanel(self._cp_default, cp_data)
649
650             job = get_job(
651                 self._job_info,
652                 ctrl_panel.get("ri-duts-value"),
653                 ctrl_panel.get("ri-ttypes-value"),
654                 ctrl_panel.get("ri-cadences-value"),
655                 ctrl_panel.get("dd-tbeds-value")
656             )
657
658             ctrl_panel.set({"al-job-children": job})
659             plotting_area = self._get_plotting_area(
660                 job,
661                 gen_new_url(parsed_url, {"job": job})
662             )
663
664             ret_val = [
665                 ctrl_panel.panel,
666                 plotting_area
667             ]
668             ret_val.extend(ctrl_panel.values)
669             return ret_val
670
671         @app.callback(
672             Output("plot-mod-url", "is_open"),
673             [Input("plot-btn-url", "n_clicks")],
674             [State("plot-mod-url", "is_open")],
675         )
676         def toggle_plot_mod_url(n, is_open):
677             """Toggle the modal window with url.
678             """
679             if n:
680                 return not is_open
681             return is_open
682
683         @app.callback(
684             Output("download-stats-data", "data"),
685             State("control-panel", "data"),  # Store
686             Input("plot-btn-download", "n_clicks"),
687             prevent_initial_call=True
688         )
689         def _download_data(cp_data: dict, n_clicks: int):
690             """Download the data
691
692             :param cp_data: Current status of the control panel stored in
693                 browser.
694             :param n_clicks: Number of clicks on the button "Download".
695             :type cp_data: dict
696             :type n_clicks: int
697             :returns: dict of data frame content (base64 encoded) and meta data
698                 used by the Download component.
699             :rtype: dict
700             """
701             if not n_clicks:
702                 raise PreventUpdate
703
704             ctrl_panel = ControlPanel(self._cp_default, cp_data)
705
706             job = get_job(
707                 self._job_info,
708                 ctrl_panel.get("ri-duts-value"),
709                 ctrl_panel.get("ri-ttypes-value"),
710                 ctrl_panel.get("ri-cadences-value"),
711                 ctrl_panel.get("dd-tbeds-value")
712             )
713
714             data = select_data(self._data, job)
715             data = data.drop(columns=["job", ])
716
717             return dcc.send_data_frame(
718                 data.T.to_csv, f"{job}-{C.STATS_DOWNLOAD_FILE_NAME}")
719
720         @app.callback(
721             Output("row-metadata", "children"),
722             Output("offcanvas-metadata", "is_open"),
723             Input("graph-passed", "clickData"),
724             Input("graph-duration", "clickData"),
725             prevent_initial_call=True
726         )
727         def _show_metadata_from_graphs(
728                 passed_data: dict, duration_data: dict) -> tuple:
729             """Generates the data for the offcanvas displayed when a particular
730             point in a graph is clicked on.
731
732             :param passed_data: The data from the clicked point in the graph
733                 displaying the pass/fail data.
734             :param duration_data: The data from the clicked point in the graph
735                 displaying the duration data.
736             :type passed_data: dict
737             :type duration data: dict
738             :returns: The data to be displayed on the offcanvas (job statistics
739                 and the list of failed tests) and the information to show the
740                 offcanvas.
741             :rtype: tuple(list, bool)
742             """
743
744             if not (passed_data or duration_data):
745                 raise PreventUpdate
746
747             metadata = no_update
748             open_canvas = False
749             title = "Job Statistics"
750             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
751             if trigger_id == "graph-passed":
752                 graph_data = passed_data["points"][0].get("hovertext", "")
753             elif trigger_id == "graph-duration":
754                 graph_data = duration_data["points"][0].get("text", "")
755             if graph_data:
756                 lst_graph_data = graph_data.split("<br>")
757
758                 # Prepare list of failed tests:
759                 job = str()
760                 build = str()
761                 for itm in lst_graph_data:
762                     if "csit-ref:" in itm:
763                         job, build = itm.split(" ")[-1].split("/")
764                         break
765                 if job and build:
766                     fail_tests = self._data.loc[
767                         (self._data["job"] == job) &
768                         (self._data["build"] == build)
769                     ]["lst_failed"].values[0]
770                     if not fail_tests:
771                         fail_tests = None
772                 else:
773                     fail_tests = None
774
775                 # Create the content of the offcanvas:
776                 metadata = [
777                     dbc.Card(
778                         class_name="gy-2 p-0",
779                         children=[
780                             dbc.CardHeader(children=[
781                                 dcc.Clipboard(
782                                     target_id="metadata",
783                                     title="Copy",
784                                     style={"display": "inline-block"}
785                                 ),
786                                 title
787                             ]),
788                             dbc.CardBody(
789                                 id="metadata",
790                                 class_name="p-0",
791                                 children=[dbc.ListGroup(
792                                     children=[
793                                         dbc.ListGroupItem(
794                                             [
795                                                 dbc.Badge(
796                                                     x.split(":")[0]
797                                                 ),
798                                                 x.split(": ")[1]
799                                             ]
800                                         ) for x in lst_graph_data
801                                     ],
802                                     flush=True),
803                                 ]
804                             )
805                         ]
806                     )
807                 ]
808
809                 if fail_tests is not None:
810                     metadata.append(
811                         dbc.Card(
812                             class_name="gy-2 p-0",
813                             children=[
814                                 dbc.CardHeader(
815                                     f"List of Failed Tests ({len(fail_tests)})"
816                                 ),
817                                 dbc.CardBody(
818                                     id="failed-tests",
819                                     class_name="p-0",
820                                     children=[dbc.ListGroup(
821                                         children=[
822                                             dbc.ListGroupItem(x) \
823                                                 for x in fail_tests
824                                         ],
825                                         flush=True),
826                                     ]
827                                 )
828                             ]
829                         )
830                     )
831
832                 open_canvas = True
833
834             return metadata, open_canvas