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