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