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