feat(uti): Add list of failed tests to stats
[csit.git] / resources / tools / dash / app / pal / stats / layout.py
1 # Copyright (c) 2022 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """Plotly Dash HTML layout override.
15 """
16
17 import logging
18 import pandas as pd
19 import dash_bootstrap_components as dbc
20
21 from flask import Flask
22 from dash import dcc
23 from dash import html
24 from dash import callback_context, no_update
25 from dash import Input, Output, State
26 from dash.exceptions import PreventUpdate
27 from yaml import load, FullLoader, YAMLError
28 from datetime import datetime, timedelta
29 from copy import deepcopy
30
31 from ..data.data import Data
32 from ..data.url_processing import url_decode, url_encode
33 from .graphs import graph_statistics, select_data
34
35
36 class Layout:
37     """
38     """
39
40     DEFAULT_JOB = "csit-vpp-perf-mrr-daily-master-2n-icx"
41
42     URL_STYLE = {
43         "background-color": "#d2ebf5",
44         "border-color": "#bce1f1",
45         "color": "#135d7c"
46     }
47
48     def __init__(self, app: Flask, html_layout_file: str, spec_file: str,
49         graph_layout_file: str, data_spec_file: str, tooltip_file: str,
50         time_period: int=None) -> None:
51         """
52         """
53
54         # Inputs
55         self._app = app
56         self._html_layout_file = html_layout_file
57         self._spec_file = spec_file
58         self._graph_layout_file = graph_layout_file
59         self._data_spec_file = data_spec_file
60         self._tooltip_file = tooltip_file
61         self._time_period = time_period
62
63         # Read the data:
64         data_stats, data_mrr, data_ndrpdr = Data(
65             data_spec_file=self._data_spec_file,
66             debug=True
67         ).read_stats(days=self._time_period)
68
69         df_tst_info = pd.concat([data_mrr, data_ndrpdr], ignore_index=True)
70
71         # Pre-process the data:
72         data_stats = data_stats[~data_stats.job.str.contains("-verify-")]
73         data_stats = data_stats[~data_stats.job.str.contains("-coverage-")]
74         data_stats = data_stats[~data_stats.job.str.contains("-iterative-")]
75         data_stats = data_stats[["job", "build", "start_time", "duration"]]
76
77         data_time_period = \
78             (datetime.utcnow() - data_stats["start_time"].min()).days
79         if self._time_period > data_time_period:
80             self._time_period = data_time_period
81
82         jobs = sorted(list(data_stats["job"].unique()))
83         job_info = {
84             "job": list(),
85             "dut": list(),
86             "ttype": list(),
87             "cadence": list(),
88             "tbed": list()
89         }
90         for job in jobs:
91             lst_job = job.split("-")
92             job_info["job"].append(job)
93             job_info["dut"].append(lst_job[1])
94             job_info["ttype"].append(lst_job[3])
95             job_info["cadence"].append(lst_job[4])
96             job_info["tbed"].append("-".join(lst_job[-2:]))
97         self.df_job_info = pd.DataFrame.from_dict(job_info)
98
99         self._default = self._set_job_params(self.DEFAULT_JOB)
100
101         tst_info = {
102             "job": list(),
103             "build": list(),
104             "dut_type": list(),
105             "dut_version": list(),
106             "hosts": list(),
107             "passed": list(),
108             "failed": list(),
109             "lst_failed": list()
110         }
111         for job in jobs:
112             df_job = df_tst_info.loc[(df_tst_info["job"] == job)]
113             builds = df_job["build"].unique()
114             for build in builds:
115                 df_build = df_job.loc[(df_job["build"] == build)]
116                 tst_info["job"].append(job)
117                 tst_info["build"].append(build)
118                 tst_info["dut_type"].append(df_build["dut_type"].iloc[-1])
119                 tst_info["dut_version"].append(df_build["dut_version"].iloc[-1])
120                 tst_info["hosts"].append(df_build["hosts"].iloc[-1])
121                 try:
122                     passed = df_build.value_counts(subset="passed")[True]
123                 except KeyError:
124                     passed = 0
125                 try:
126                     failed = df_build.value_counts(subset="passed")[False]
127                     failed_tests = df_build.loc[(df_build["passed"] == False)]\
128                         ["test_id"].to_list()
129                     l_failed = list()
130                     for tst in failed_tests:
131                         lst_tst = tst.split(".")
132                         suite = lst_tst[-2].replace("2n1l-", "").\
133                             replace("1n1l-", "").replace("2n-", "")
134                         l_failed.append(f"{suite.split('-')[0]}-{lst_tst[-1]}")
135                 except KeyError:
136                     failed = 0
137                     l_failed = list()
138                 tst_info["passed"].append(passed)
139                 tst_info["failed"].append(failed)
140                 tst_info["lst_failed"].append(sorted(l_failed))
141
142         self._data = data_stats.merge(pd.DataFrame.from_dict(tst_info))
143
144         # Read from files:
145         self._html_layout = ""
146         self._graph_layout = None
147         self._tooltips = dict()
148
149         try:
150             with open(self._html_layout_file, "r") as file_read:
151                 self._html_layout = file_read.read()
152         except IOError as err:
153             raise RuntimeError(
154                 f"Not possible to open the file {self._html_layout_file}\n{err}"
155             )
156
157         try:
158             with open(self._graph_layout_file, "r") as file_read:
159                 self._graph_layout = load(file_read, Loader=FullLoader)
160         except IOError as err:
161             raise RuntimeError(
162                 f"Not possible to open the file {self._graph_layout_file}\n"
163                 f"{err}"
164             )
165         except YAMLError as err:
166             raise RuntimeError(
167                 f"An error occurred while parsing the specification file "
168                 f"{self._graph_layout_file}\n{err}"
169             )
170
171         try:
172             with open(self._tooltip_file, "r") as file_read:
173                 self._tooltips = load(file_read, Loader=FullLoader)
174         except IOError as err:
175             logging.warning(
176                 f"Not possible to open the file {self._tooltip_file}\n{err}"
177             )
178         except YAMLError as err:
179             logging.warning(
180                 f"An error occurred while parsing the specification file "
181                 f"{self._tooltip_file}\n{err}"
182             )
183
184
185         self._default_fig_passed, self._default_fig_duration = graph_statistics(
186             self.data, self._default["job"], self.layout
187         )
188
189         # Callbacks:
190         if self._app is not None and hasattr(self, 'callbacks'):
191             self.callbacks(self._app)
192
193     @property
194     def html_layout(self) -> dict:
195         return self._html_layout
196
197     @property
198     def data(self) -> pd.DataFrame:
199         return self._data
200
201     @property
202     def layout(self) -> dict:
203         return self._graph_layout
204
205     @property
206     def time_period(self) -> int:
207         return self._time_period
208
209     @property
210     def default(self) -> any:
211         return self._default
212
213     def _get_duts(self) -> list:
214         """
215         """
216         return sorted(list(self.df_job_info["dut"].unique()))
217
218     def _get_ttypes(self, dut: str) -> list:
219         """
220         """
221         return sorted(list(self.df_job_info.loc[(
222             self.df_job_info["dut"] == dut
223         )]["ttype"].unique()))
224
225     def _get_cadences(self, dut: str, ttype: str) -> list:
226         """
227         """
228         return sorted(list(self.df_job_info.loc[(
229             (self.df_job_info["dut"] == dut) &
230             (self.df_job_info["ttype"] == ttype)
231         )]["cadence"].unique()))
232
233     def _get_test_beds(self, dut: str, ttype: str, cadence: str) -> list:
234         """
235         """
236         return sorted(list(self.df_job_info.loc[(
237             (self.df_job_info["dut"] == dut) &
238             (self.df_job_info["ttype"] == ttype) &
239             (self.df_job_info["cadence"] == cadence)
240         )]["tbed"].unique()))
241
242     def _get_job(self, dut, ttype, cadence, testbed):
243         """Get the name of a job defined by dut, ttype, cadence, testbed.
244
245         Input information comes from control panel.
246         """
247         return self.df_job_info.loc[(
248             (self.df_job_info["dut"] == dut) &
249             (self.df_job_info["ttype"] == ttype) &
250             (self.df_job_info["cadence"] == cadence) &
251             (self.df_job_info["tbed"] == testbed)
252         )]["job"].item()
253
254     def _set_job_params(self, job: str) -> dict:
255         """
256         """
257         lst_job = job.split("-")
258         return {
259             "job": job,
260             "dut": lst_job[1],
261             "ttype": lst_job[3],
262             "cadence": lst_job[4],
263             "tbed": "-".join(lst_job[-2:]),
264             "duts": self._generate_options(self._get_duts()),
265             "ttypes": self._generate_options(self._get_ttypes(lst_job[1])),
266             "cadences": self._generate_options(self._get_cadences(
267                 lst_job[1], lst_job[3])),
268             "tbeds": self._generate_options(self._get_test_beds(
269                 lst_job[1], lst_job[3], lst_job[4]))
270         }
271
272     def _show_tooltip(self, id: str, title: str,
273             clipboard_id: str=None) -> list:
274         """
275         """
276         return [
277             dcc.Clipboard(target_id=clipboard_id, title="Copy URL") \
278                 if clipboard_id else str(),
279             f"{title} ",
280             dbc.Badge(
281                 id=id,
282                 children="?",
283                 pill=True,
284                 color="white",
285                 text_color="info",
286                 class_name="border ms-1",
287             ),
288             dbc.Tooltip(
289                 children=self._tooltips.get(id, str()),
290                 target=id,
291                 placement="auto"
292             )
293         ]
294
295     def add_content(self):
296         """
297         """
298         if self.html_layout:
299             return html.Div(
300                 id="div-main",
301                 children=[
302                     dcc.Store(id="control-panel"),
303                     dcc.Location(id="url", refresh=False),
304                     dbc.Row(
305                         id="row-navbar",
306                         class_name="g-0",
307                         children=[
308                             self._add_navbar(),
309                         ]
310                     ),
311                     dcc.Loading(
312                         dbc.Offcanvas(
313                             class_name="w-50",
314                             id="offcanvas-metadata",
315                             title="Detailed Information",
316                             placement="end",
317                             is_open=False,
318                             children=[
319                                 dbc.Row(id="row-metadata")
320                             ]
321                         )
322                     ),
323                     dbc.Row(
324                         id="row-main",
325                         class_name="g-0",
326                         children=[
327                             self._add_ctrl_col(),
328                             self._add_plotting_col(),
329                         ]
330                     )
331                 ]
332             )
333         else:
334             return html.Div(
335                 id="div-main-error",
336                 children=[
337                     dbc.Alert(
338                         [
339                             "An Error Occured",
340                         ],
341                         color="danger",
342                     ),
343                 ]
344             )
345
346     def _add_navbar(self):
347         """Add nav element with navigation panel. It is placed on the top.
348         """
349         return dbc.NavbarSimple(
350             id="navbarsimple-main",
351             children=[
352                 dbc.NavItem(
353                     dbc.NavLink(
354                         "Continuous Performance Statistics",
355                         disabled=True,
356                         external_link=True,
357                         href="#"
358                     )
359                 )
360             ],
361             brand="Dashboard",
362             brand_href="/",
363             brand_external_link=True,
364             class_name="p-2",
365             fluid=True,
366         )
367
368     def _add_ctrl_col(self) -> dbc.Col:
369         """Add column with controls. It is placed on the left side.
370         """
371         return dbc.Col(
372             id="col-controls",
373             children=[
374                 self._add_ctrl_panel(),
375             ],
376         )
377
378     def _add_plotting_col(self) -> dbc.Col:
379         """Add column with plots and tables. It is placed on the right side.
380         """
381         return dbc.Col(
382             id="col-plotting-area",
383             children=[
384                 dbc.Row(  # Passed / failed tests
385                     id="row-graph-passed",
386                     class_name="g-0 p-2",
387                     children=[
388                         dcc.Loading(children=[
389                             dcc.Graph(
390                                 id="graph-passed",
391                                 figure=self._default_fig_passed
392                             )
393                         ])
394                     ]
395                 ),
396                 dbc.Row(  # Duration
397                     id="row-graph-duration",
398                     class_name="g-0 p-2",
399                     children=[
400                         dcc.Loading(children=[
401                             dcc.Graph(
402                                 id="graph-duration",
403                                 figure=self._default_fig_duration
404                             )
405                         ])
406                     ]
407                 ),
408                 dbc.Row(
409                     class_name="g-0 p-2",
410                     align="center",
411                     justify="start",
412                     children=[
413                         dbc.Col(  # Download
414                             width=2,
415                             children=[
416                                 dcc.Loading(children=[
417                                     dbc.Button(
418                                         id="btn-download-data",
419                                         children=self._show_tooltip(
420                                             "help-download", "Download Data"),
421                                         class_name="me-1",
422                                         color="info"
423                                     ),
424                                     dcc.Download(id="download-data")
425                                 ])
426                             ]
427                         ),
428                         dbc.Col(  # Show URL
429                             width=10,
430                             children=[
431                                 dbc.InputGroup(
432                                     class_name="me-1",
433                                     children=[
434                                         dbc.InputGroupText(
435                                             style=self.URL_STYLE,
436                                             children=self._show_tooltip(
437                                                 "help-url", "URL", "input-url")
438                                         ),
439                                         dbc.Input(
440                                             id="input-url",
441                                             readonly=True,
442                                             type="url",
443                                             style=self.URL_STYLE,
444                                             value=""
445                                         )
446                                     ]
447                                 )
448                             ]
449                         )
450                     ]
451                 )
452             ],
453             width=9,
454         )
455
456     def _add_ctrl_panel(self) -> dbc.Row:
457         """
458         """
459         return dbc.Row(
460             id="row-ctrl-panel",
461             class_name="g-0",
462             children=[
463                 dbc.Row(
464                     class_name="g-0 p-2",
465                     children=[
466                         dbc.Row(
467                             class_name="gy-1",
468                             children=[
469                                 dbc.Label(
470                                     class_name="p-0",
471                                     children=self._show_tooltip(
472                                         "help-dut", "Device under Test")
473                                 ),
474                                 dbc.Row(
475                                     dbc.RadioItems(
476                                         id="ri-duts",
477                                         inline=True,
478                                         value=self.default["dut"],
479                                         options=self.default["duts"]
480                                     )
481                                 )
482                             ]
483                         ),
484                         dbc.Row(
485                             class_name="gy-1",
486                             children=[
487                                 dbc.Label(
488                                     class_name="p-0",
489                                     children=self._show_tooltip(
490                                         "help-ttype", "Test Type"),
491                                 ),
492                                 dbc.RadioItems(
493                                     id="ri-ttypes",
494                                     inline=True,
495                                     value=self.default["ttype"],
496                                     options=self.default["ttypes"]
497                                 )
498                             ]
499                         ),
500                         dbc.Row(
501                             class_name="gy-1",
502                             children=[
503                                 dbc.Label(
504                                     class_name="p-0",
505                                     children=self._show_tooltip(
506                                         "help-cadence", "Cadence"),
507                                 ),
508                                 dbc.RadioItems(
509                                     id="ri-cadences",
510                                     inline=True,
511                                     value=self.default["cadence"],
512                                     options=self.default["cadences"]
513                                 )
514                             ]
515                         ),
516                         dbc.Row(
517                             class_name="gy-1",
518                             children=[
519                                 dbc.Label(
520                                     class_name="p-0",
521                                     children=self._show_tooltip(
522                                         "help-tbed", "Test Bed"),
523                                 ),
524                                 dbc.Select(
525                                     id="dd-tbeds",
526                                     placeholder="Select a test bed...",
527                                     value=self.default["tbed"],
528                                     options=self.default["tbeds"]
529                                 )
530                             ]
531                         ),
532                         dbc.Row(
533                             class_name="gy-1",
534                             children=[
535                                 dbc.Alert(
536                                     id="al-job",
537                                     color="info",
538                                     children=self.default["job"]
539                                 )
540                             ]
541                         ),
542                         dbc.Row(
543                             class_name="g-0 p-2",
544                             children=[
545                                 dbc.Label(
546                                     class_name="gy-1",
547                                     children=self._show_tooltip(
548                                         "help-time-period", "Time Period"),
549                                 ),
550                                 dcc.DatePickerRange(
551                                     id="dpr-period",
552                                     className="d-flex justify-content-center",
553                                     min_date_allowed=\
554                                         datetime.utcnow() - timedelta(
555                                             days=self.time_period),
556                                     max_date_allowed=datetime.utcnow(),
557                                     initial_visible_month=datetime.utcnow(),
558                                     start_date=\
559                                         datetime.utcnow() - timedelta(
560                                             days=self.time_period),
561                                     end_date=datetime.utcnow(),
562                                     display_format="D MMM YY"
563                                 )
564                             ]
565                         )
566                     ]
567                 ),
568             ]
569         )
570
571     class ControlPanel:
572         def __init__(self, panel: dict, default: dict) -> None:
573             self._defaults = {
574                 "ri-ttypes-options": default["ttypes"],
575                 "ri-cadences-options": default["cadences"],
576                 "dd-tbeds-options": default["tbeds"],
577                 "ri-duts-value": default["dut"],
578                 "ri-ttypes-value": default["ttype"],
579                 "ri-cadences-value": default["cadence"],
580                 "dd-tbeds-value": default["tbed"],
581                 "al-job-children": default["job"]
582             }
583             self._panel = deepcopy(self._defaults)
584             if panel:
585                 for key in self._defaults:
586                     self._panel[key] = panel[key]
587
588         def set(self, kwargs: dict) -> None:
589             for key, val in kwargs.items():
590                 if key in self._panel:
591                     self._panel[key] = val
592                 else:
593                     raise KeyError(f"The key {key} is not defined.")
594
595         @property
596         def defaults(self) -> dict:
597             return self._defaults
598
599         @property
600         def panel(self) -> dict:
601             return self._panel
602
603         def get(self, key: str) -> any:
604             return self._panel[key]
605
606         def values(self) -> list:
607             return list(self._panel.values())
608
609     @staticmethod
610     def _generate_options(opts: list) -> list:
611         return [{"label": i, "value": i} for i in opts]
612
613     @staticmethod
614     def _get_date(s_date: str) -> datetime:
615         return datetime(int(s_date[0:4]), int(s_date[5:7]), int(s_date[8:10]))
616
617     def callbacks(self, app):
618
619         @app.callback(
620             Output("control-panel", "data"),  # Store
621             Output("graph-passed", "figure"),
622             Output("graph-duration", "figure"),
623             Output("input-url", "value"),
624             Output("ri-ttypes", "options"),
625             Output("ri-cadences", "options"),
626             Output("dd-tbeds", "options"),
627             Output("ri-duts", "value"),
628             Output("ri-ttypes", "value"),
629             Output("ri-cadences", "value"),
630             Output("dd-tbeds", "value"),
631             Output("al-job", "children"),
632             State("control-panel", "data"),  # Store
633             Input("ri-duts", "value"),
634             Input("ri-ttypes", "value"),
635             Input("ri-cadences", "value"),
636             Input("dd-tbeds", "value"),
637             Input("dpr-period", "start_date"),
638             Input("dpr-period", "end_date"),
639             Input("url", "href")
640             # prevent_initial_call=True
641         )
642         def _update_ctrl_panel(cp_data: dict, dut:str, ttype: str, cadence:str,
643                 tbed: str, start: str, end: str, href: str) -> tuple:
644             """
645             """
646
647             ctrl_panel = self.ControlPanel(cp_data, self.default)
648
649             start = self._get_date(start)
650             end = self._get_date(end)
651
652             # Parse the url:
653             parsed_url = url_decode(href)
654             if parsed_url:
655                 url_params = parsed_url["params"]
656             else:
657                 url_params = None
658
659             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
660             if trigger_id == "ri-duts":
661                 ttype_opts = self._generate_options(self._get_ttypes(dut))
662                 ttype_val = ttype_opts[0]["value"]
663                 cad_opts = self._generate_options(
664                     self._get_cadences(dut, ttype_val))
665                 cad_val = cad_opts[0]["value"]
666                 tbed_opts = self._generate_options(
667                     self._get_test_beds(dut, ttype_val, cad_val))
668                 tbed_val = tbed_opts[0]["value"]
669                 ctrl_panel.set({
670                     "ri-duts-value": dut,
671                     "ri-ttypes-options": ttype_opts,
672                     "ri-ttypes-value": ttype_val,
673                     "ri-cadences-options": cad_opts,
674                     "ri-cadences-value": cad_val,
675                     "dd-tbeds-options": tbed_opts,
676                     "dd-tbeds-value": tbed_val
677                 })
678             elif trigger_id == "ri-ttypes":
679                 cad_opts = self._generate_options(
680                     self._get_cadences(ctrl_panel.get("ri-duts-value"), ttype))
681                 cad_val = cad_opts[0]["value"]
682                 tbed_opts = self._generate_options(
683                     self._get_test_beds(ctrl_panel.get("ri-duts-value"),
684                     ttype, cad_val))
685                 tbed_val = tbed_opts[0]["value"]
686                 ctrl_panel.set({
687                     "ri-ttypes-value": ttype,
688                     "ri-cadences-options": cad_opts,
689                     "ri-cadences-value": cad_val,
690                     "dd-tbeds-options": tbed_opts,
691                     "dd-tbeds-value": tbed_val
692                 })
693             elif trigger_id == "ri-cadences":
694                 tbed_opts = self._generate_options(
695                     self._get_test_beds(ctrl_panel.get("ri-duts-value"),
696                     ctrl_panel.get("ri-ttypes-value"), cadence))
697                 tbed_val = tbed_opts[0]["value"]
698                 ctrl_panel.set({
699                     "ri-cadences-value": cadence,
700                     "dd-tbeds-options": tbed_opts,
701                     "dd-tbeds-value": tbed_val
702                 })
703             elif trigger_id == "dd-tbeds":
704                 ctrl_panel.set({
705                     "dd-tbeds-value": tbed
706                 })
707             elif trigger_id == "dpr-period":
708                 pass
709             elif trigger_id == "url":
710                 # TODO: Add verification
711                 if url_params:
712                     new_job = url_params.get("job", list())[0]
713                     new_start = url_params.get("start", list())[0]
714                     new_end = url_params.get("end", list())[0]
715                     if new_job and new_start and new_end:
716                         start = self._get_date(new_start)
717                         end = self._get_date(new_end)
718                         job_params = self._set_job_params(new_job)
719                         ctrl_panel = self.ControlPanel(None, job_params)
720                 else:
721                     ctrl_panel = self.ControlPanel(cp_data, self.default)
722                     job = self._get_job(
723                         ctrl_panel.get("ri-duts-value"),
724                         ctrl_panel.get("ri-ttypes-value"),
725                         ctrl_panel.get("ri-cadences-value"),
726                         ctrl_panel.get("dd-tbeds-value")
727                     )
728
729             job = self._get_job(
730                 ctrl_panel.get("ri-duts-value"),
731                 ctrl_panel.get("ri-ttypes-value"),
732                 ctrl_panel.get("ri-cadences-value"),
733                 ctrl_panel.get("dd-tbeds-value")
734             )
735
736             ctrl_panel.set({"al-job-children": job})
737             fig_passed, fig_duration = graph_statistics(self.data, job,
738                 self.layout, start, end)
739
740             if parsed_url:
741                 new_url = url_encode({
742                     "scheme": parsed_url["scheme"],
743                     "netloc": parsed_url["netloc"],
744                     "path": parsed_url["path"],
745                     "params": {
746                         "job": job,
747                         "start": start,
748                         "end": end
749                     }
750                 })
751             else:
752                 new_url = str()
753
754             ret_val = [
755                 ctrl_panel.panel,
756                 fig_passed,
757                 fig_duration,
758                 new_url
759             ]
760             ret_val.extend(ctrl_panel.values())
761             return ret_val
762
763         @app.callback(
764             Output("download-data", "data"),
765             State("control-panel", "data"),  # Store
766             State("dpr-period", "start_date"),
767             State("dpr-period", "end_date"),
768             Input("btn-download-data", "n_clicks"),
769             prevent_initial_call=True
770         )
771         def _download_data(cp_data: dict, start: str, end: str, n_clicks: int):
772             """
773             """
774             if not (n_clicks):
775                 raise PreventUpdate
776
777             ctrl_panel = self.ControlPanel(cp_data, self.default)
778
779             job = self._get_job(
780                 ctrl_panel.get("ri-duts-value"),
781                 ctrl_panel.get("ri-ttypes-value"),
782                 ctrl_panel.get("ri-cadences-value"),
783                 ctrl_panel.get("dd-tbeds-value")
784             )
785
786             start = datetime(int(start[0:4]), int(start[5:7]), int(start[8:10]))
787             end = datetime(int(end[0:4]), int(end[5:7]), int(end[8:10]))
788             data = select_data(self.data, job, start, end)
789             data = data.drop(columns=["job", ])
790
791             return dcc.send_data_frame(data.T.to_csv, f"{job}-stats.csv")
792
793         @app.callback(
794             Output("row-metadata", "children"),
795             Output("offcanvas-metadata", "is_open"),
796             Input("graph-passed", "clickData"),
797             Input("graph-duration", "clickData"),
798             prevent_initial_call=True
799         )
800         def _show_metadata_from_graphs(
801                 passed_data: dict, duration_data: dict) -> tuple:
802             """
803             """
804
805             if not (passed_data or duration_data):
806                 raise PreventUpdate
807
808             metadata = no_update
809             open_canvas = False
810             title = "Job Statistics"
811             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
812             if trigger_id == "graph-passed":
813                 graph_data = passed_data["points"][0].get("hovertext", "")
814             elif trigger_id == "graph-duration":
815                 graph_data = duration_data["points"][0].get("text", "")
816             if graph_data:
817                 lst_graph_data = graph_data.split("<br>")
818
819                 # Prepare list of failed tests:
820                 job = str()
821                 build = str()
822                 for itm in lst_graph_data:
823                     if "csit-ref:" in itm:
824                         job, build = itm.split(" ")[-1].split("/")
825                         break
826                 if job and build:
827                     fail_tests = self.data.loc[
828                         (self.data["job"] == job) &
829                         (self.data["build"] == build)
830                     ]["lst_failed"].values[0]
831                     if not fail_tests:
832                         fail_tests = None
833                 else:
834                     fail_tests = None
835
836                 # Create the content of the offcanvas:
837                 metadata = [
838                     dbc.Card(
839                         class_name="gy-2 p-0",
840                         children=[
841                             dbc.CardHeader(children=[
842                                 dcc.Clipboard(
843                                     target_id="metadata",
844                                     title="Copy",
845                                     style={"display": "inline-block"}
846                                 ),
847                                 title
848                             ]),
849                             dbc.CardBody(
850                                 id="metadata",
851                                 class_name="p-0",
852                                 children=[dbc.ListGroup(
853                                     children=[
854                                         dbc.ListGroupItem(
855                                             [
856                                                 dbc.Badge(
857                                                     x.split(":")[0]
858                                                 ),
859                                                 x.split(": ")[1]
860                                             ]
861                                         ) for x in lst_graph_data
862                                     ],
863                                     flush=True),
864                                 ]
865                             )
866                         ]
867                     )
868                 ]
869
870                 if fail_tests is not None:
871                     metadata.append(
872                         dbc.Card(
873                             class_name="gy-2 p-0",
874                             children=[
875                                 dbc.CardHeader(
876                                     f"List of Failed Tests ({len(fail_tests)})"
877                                 ),
878                                 dbc.CardBody(
879                                     id="failed-tests",
880                                     class_name="p-0",
881                                     children=[dbc.ListGroup(
882                                         children=[
883                                             dbc.ListGroupItem(x) \
884                                                 for x in fail_tests
885                                         ],
886                                         flush=True),
887                                     ]
888                                 )
889                             ]
890                         )
891                     )
892
893                 open_canvas = True
894
895             return metadata, open_canvas