UTI: code clean-up
[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 teh 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             }
557             self._panel = deepcopy(self._defaults)
558             if panel:
559                 for key in self._defaults:
560                     self._panel[key] = panel[key]
561
562         def set(self, kwargs: dict) -> None:
563             """Set the values of the Control panel.
564
565             :param kwargs: key - value pairs to be set.
566             :type kwargs: dict
567             :raises KeyError: If the key in kwargs is not present in the Control
568                 panel.
569             """
570             for key, val in kwargs.items():
571                 if key in self._panel:
572                     self._panel[key] = val
573                 else:
574                     raise KeyError(f"The key {key} is not defined.")
575
576         @property
577         def defaults(self) -> dict:
578             return self._defaults
579
580         @property
581         def panel(self) -> dict:
582             return self._panel
583
584         def get(self, key: str) -> any:
585             """Returns the value of a key from the Control panel.
586
587             :param key: The key which value should be returned.
588             :type key: str
589             :returns: The value of the key.
590             :rtype: any
591             :raises KeyError: If the key in kwargs is not present in the Control
592                 panel.
593             """
594             return self._panel[key]
595
596         def values(self) -> list:
597             """Returns the values from the Control panel as a list.
598
599             :returns: The values from the Control panel.
600             :rtype: list
601             """
602             return list(self._panel.values())
603
604
605     def callbacks(self, app):
606         """Callbacks for the whole application.
607
608         :param app: The application.
609         :type app: Flask
610         """
611
612         @app.callback(
613             Output("control-panel", "data"),  # Store
614             Output("graph-passed", "figure"),
615             Output("graph-duration", "figure"),
616             Output("input-url", "value"),
617             Output("ri-ttypes", "options"),
618             Output("ri-cadences", "options"),
619             Output("dd-tbeds", "options"),
620             Output("ri-duts", "value"),
621             Output("ri-ttypes", "value"),
622             Output("ri-cadences", "value"),
623             Output("dd-tbeds", "value"),
624             Output("al-job", "children"),
625             State("control-panel", "data"),  # Store
626             Input("ri-duts", "value"),
627             Input("ri-ttypes", "value"),
628             Input("ri-cadences", "value"),
629             Input("dd-tbeds", "value"),
630             Input("dpr-period", "start_date"),
631             Input("dpr-period", "end_date"),
632             Input("url", "href")
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             """Update the application when the event is detected.
637
638             :param cp_data: Current status of the control panel stored in
639                 browser.
640             :param dut: Input - DUT name.
641             :param ttype: Input - Test type.
642             :param cadence: Input - The cadence of the job.
643             :param tbed: Input - The test bed.
644             :param start: Date and time where the data processing starts.
645             :param end: Date and time where the data processing ends.
646             :param href: Input - The URL provided by the browser.
647             :type cp_data: dict
648             :type dut: str
649             :type ttype: str
650             :type cadence: str
651             :type tbed: str
652             :type start: str
653             :type end: str
654             :type href: str
655             :returns: New values for web page elements.
656             :rtype: tuple
657             """
658
659             ctrl_panel = self.ControlPanel(cp_data, self.default)
660
661             start = get_date(start)
662             end = get_date(end)
663
664             # Parse the url:
665             parsed_url = url_decode(href)
666             if parsed_url:
667                 url_params = parsed_url["params"]
668             else:
669                 url_params = None
670
671             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
672             if trigger_id == "ri-duts":
673                 ttype_opts = generate_options(get_ttypes(self.job_info, dut))
674                 ttype_val = ttype_opts[0]["value"]
675                 cad_opts = generate_options(get_cadences(
676                     self.job_info, dut, ttype_val))
677                 cad_val = cad_opts[0]["value"]
678                 tbed_opts = generate_options(get_test_beds(
679                     self.job_info, dut, ttype_val, cad_val))
680                 tbed_val = tbed_opts[0]["value"]
681                 ctrl_panel.set({
682                     "ri-duts-value": dut,
683                     "ri-ttypes-options": ttype_opts,
684                     "ri-ttypes-value": ttype_val,
685                     "ri-cadences-options": cad_opts,
686                     "ri-cadences-value": cad_val,
687                     "dd-tbeds-options": tbed_opts,
688                     "dd-tbeds-value": tbed_val
689                 })
690             elif trigger_id == "ri-ttypes":
691                 cad_opts = generate_options(get_cadences(
692                     self.job_info, ctrl_panel.get("ri-duts-value"), ttype))
693                 cad_val = cad_opts[0]["value"]
694                 tbed_opts = generate_options(get_test_beds(
695                     self.job_info, ctrl_panel.get("ri-duts-value"), ttype,
696                     cad_val))
697                 tbed_val = tbed_opts[0]["value"]
698                 ctrl_panel.set({
699                     "ri-ttypes-value": ttype,
700                     "ri-cadences-options": cad_opts,
701                     "ri-cadences-value": cad_val,
702                     "dd-tbeds-options": tbed_opts,
703                     "dd-tbeds-value": tbed_val
704                 })
705             elif trigger_id == "ri-cadences":
706                 tbed_opts = generate_options(get_test_beds(
707                     self.job_info, ctrl_panel.get("ri-duts-value"),
708                     ctrl_panel.get("ri-ttypes-value"), cadence))
709                 tbed_val = tbed_opts[0]["value"]
710                 ctrl_panel.set({
711                     "ri-cadences-value": cadence,
712                     "dd-tbeds-options": tbed_opts,
713                     "dd-tbeds-value": tbed_val
714                 })
715             elif trigger_id == "dd-tbeds":
716                 ctrl_panel.set({
717                     "dd-tbeds-value": tbed
718                 })
719             elif trigger_id == "dpr-period":
720                 pass
721             elif trigger_id == "url":
722                 # TODO: Add verification
723                 if url_params:
724                     new_job = url_params.get("job", list())[0]
725                     new_start = url_params.get("start", list())[0]
726                     new_end = url_params.get("end", list())[0]
727                     if new_job and new_start and new_end:
728                         start = get_date(new_start)
729                         end = get_date(new_end)
730                         job_params = set_job_params(self.job_info, new_job)
731                         ctrl_panel = self.ControlPanel(None, job_params)
732                 else:
733                     ctrl_panel = self.ControlPanel(cp_data, self.default)
734
735             job = get_job(
736                 self.job_info,
737                 ctrl_panel.get("ri-duts-value"),
738                 ctrl_panel.get("ri-ttypes-value"),
739                 ctrl_panel.get("ri-cadences-value"),
740                 ctrl_panel.get("dd-tbeds-value")
741             )
742
743             ctrl_panel.set({"al-job-children": job})
744             fig_passed, fig_duration = graph_statistics(self.data, job,
745                 self.layout, start, end)
746
747             ret_val = [
748                 ctrl_panel.panel,
749                 fig_passed,
750                 fig_duration,
751                 gen_new_url(
752                     parsed_url,
753                     {
754                         "job": job,
755                         "start": start,
756                         "end": end
757                     }
758                 )
759             ]
760             ret_val.extend(ctrl_panel.values())
761             return ret_val
762
763         @app.callback(
764             Output("download-data", "data"),
765             State("control-panel", "data"),  # Store
766             State("dpr-period", "start_date"),
767             State("dpr-period", "end_date"),
768             Input("btn-download-data", "n_clicks"),
769             prevent_initial_call=True
770         )
771         def _download_data(cp_data: dict, start: str, end: str, n_clicks: int):
772             """Download the data
773
774             :param cp_data: Current status of the control panel stored in
775                 browser.
776             :param start: Date and time where the data processing starts.
777             :param end: Date and time where the data processing ends.
778             :param n_clicks: Number of clicks on the button "Download".
779             :type cp_data: dict
780             :type start: str
781             :type end: str
782             :type n_clicks: int
783             :returns: dict of data frame content (base64 encoded) and meta data
784                 used by the Download component.
785             :rtype: dict
786             """
787             if not (n_clicks):
788                 raise PreventUpdate
789
790             ctrl_panel = self.ControlPanel(cp_data, self.default)
791
792             job = get_job(
793                 self.job_info,
794                 ctrl_panel.get("ri-duts-value"),
795                 ctrl_panel.get("ri-ttypes-value"),
796                 ctrl_panel.get("ri-cadences-value"),
797                 ctrl_panel.get("dd-tbeds-value")
798             )
799
800             data = select_data(self.data, job, get_date(start), get_date(end))
801             data = data.drop(columns=["job", ])
802
803             return dcc.send_data_frame(data.T.to_csv, f"{job}-stats.csv")
804
805         @app.callback(
806             Output("row-metadata", "children"),
807             Output("offcanvas-metadata", "is_open"),
808             Input("graph-passed", "clickData"),
809             Input("graph-duration", "clickData"),
810             prevent_initial_call=True
811         )
812         def _show_metadata_from_graphs(
813                 passed_data: dict, duration_data: dict) -> tuple:
814             """Generates the data for the offcanvas displayed when a particular
815             point in a graph is clicked on.
816
817             :param passed_data: The data from the clicked point in the graph
818                 displaying the pass/fail data.
819             :param duration_data: The data from the clicked point in the graph
820                 displaying the duration data.
821             :type passed_data: dict
822             :type duration data: dict
823             :returns: The data to be displayed on the offcanvas (job statistics
824                 and the list of failed tests) and the information to show the
825                 offcanvas.
826             :rtype: tuple(list, bool)
827             """
828
829             if not (passed_data or duration_data):
830                 raise PreventUpdate
831
832             metadata = no_update
833             open_canvas = False
834             title = "Job Statistics"
835             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
836             if trigger_id == "graph-passed":
837                 graph_data = passed_data["points"][0].get("hovertext", "")
838             elif trigger_id == "graph-duration":
839                 graph_data = duration_data["points"][0].get("text", "")
840             if graph_data:
841                 lst_graph_data = graph_data.split("<br>")
842
843                 # Prepare list of failed tests:
844                 job = str()
845                 build = str()
846                 for itm in lst_graph_data:
847                     if "csit-ref:" in itm:
848                         job, build = itm.split(" ")[-1].split("/")
849                         break
850                 if job and build:
851                     fail_tests = self.data.loc[
852                         (self.data["job"] == job) &
853                         (self.data["build"] == build)
854                     ]["lst_failed"].values[0]
855                     if not fail_tests:
856                         fail_tests = None
857                 else:
858                     fail_tests = None
859
860                 # Create the content of the offcanvas:
861                 metadata = [
862                     dbc.Card(
863                         class_name="gy-2 p-0",
864                         children=[
865                             dbc.CardHeader(children=[
866                                 dcc.Clipboard(
867                                     target_id="metadata",
868                                     title="Copy",
869                                     style={"display": "inline-block"}
870                                 ),
871                                 title
872                             ]),
873                             dbc.CardBody(
874                                 id="metadata",
875                                 class_name="p-0",
876                                 children=[dbc.ListGroup(
877                                     children=[
878                                         dbc.ListGroupItem(
879                                             [
880                                                 dbc.Badge(
881                                                     x.split(":")[0]
882                                                 ),
883                                                 x.split(": ")[1]
884                                             ]
885                                         ) for x in lst_graph_data
886                                     ],
887                                     flush=True),
888                                 ]
889                             )
890                         ]
891                     )
892                 ]
893
894                 if fail_tests is not None:
895                     metadata.append(
896                         dbc.Card(
897                             class_name="gy-2 p-0",
898                             children=[
899                                 dbc.CardHeader(
900                                     f"List of Failed Tests ({len(fail_tests)})"
901                                 ),
902                                 dbc.CardBody(
903                                     id="failed-tests",
904                                     class_name="p-0",
905                                     children=[dbc.ListGroup(
906                                         children=[
907                                             dbc.ListGroupItem(x) \
908                                                 for x in fail_tests
909                                         ],
910                                         flush=True),
911                                     ]
912                                 )
913                             ]
914                         )
915                     )
916
917                 open_canvas = True
918
919             return metadata, open_canvas