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