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