2e74fb8b3c298cba76b98ebe6138d9f7545af43d
[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         )
614         def _update_ctrl_panel(cp_data: dict, dut:str, ttype: str, cadence:str,
615                 tbed: str, start: str, end: str, href: str) -> tuple:
616             """
617             """
618
619             ctrl_panel = self.ControlPanel(cp_data, self.default)
620
621             start = self._get_date(start)
622             end = self._get_date(end)
623
624             # Parse the url:
625             parsed_url = url_decode(href)
626             if parsed_url:
627                 url_params = parsed_url["params"]
628             else:
629                 url_params = None
630
631             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
632             if trigger_id == "ri-duts":
633                 ttype_opts = self._generate_options(self._get_ttypes(dut))
634                 ttype_val = ttype_opts[0]["value"]
635                 cad_opts = self._generate_options(
636                     self._get_cadences(dut, ttype_val))
637                 cad_val = cad_opts[0]["value"]
638                 tbed_opts = self._generate_options(
639                     self._get_test_beds(dut, ttype_val, cad_val))
640                 tbed_val = tbed_opts[0]["value"]
641                 ctrl_panel.set({
642                     "ri-duts-value": dut,
643                     "ri-ttypes-options": ttype_opts,
644                     "ri-ttypes-value": ttype_val,
645                     "ri-cadences-options": cad_opts,
646                     "ri-cadences-value": cad_val,
647                     "dd-tbeds-options": tbed_opts,
648                     "dd-tbeds-value": tbed_val
649                 })
650             elif trigger_id == "ri-ttypes":
651                 cad_opts = self._generate_options(
652                     self._get_cadences(ctrl_panel.get("ri-duts-value"), ttype))
653                 cad_val = cad_opts[0]["value"]
654                 tbed_opts = self._generate_options(
655                     self._get_test_beds(ctrl_panel.get("ri-duts-value"),
656                     ttype, cad_val))
657                 tbed_val = tbed_opts[0]["value"]
658                 ctrl_panel.set({
659                     "ri-ttypes-value": ttype,
660                     "ri-cadences-options": cad_opts,
661                     "ri-cadences-value": cad_val,
662                     "dd-tbeds-options": tbed_opts,
663                     "dd-tbeds-value": tbed_val
664                 })
665             elif trigger_id == "ri-cadences":
666                 tbed_opts = self._generate_options(
667                     self._get_test_beds(ctrl_panel.get("ri-duts-value"),
668                     ctrl_panel.get("ri-ttypes-value"), cadence))
669                 tbed_val = tbed_opts[0]["value"]
670                 ctrl_panel.set({
671                     "ri-cadences-value": cadence,
672                     "dd-tbeds-options": tbed_opts,
673                     "dd-tbeds-value": tbed_val
674                 })
675             elif trigger_id == "dd-tbeds":
676                 ctrl_panel.set({
677                     "dd-tbeds-value": tbed
678                 })
679             elif trigger_id == "dpr-period":
680                 pass
681             elif trigger_id == "url":
682                 # TODO: Add verification
683                 if url_params:
684                     new_job = url_params.get("job", list())[0]
685                     new_start = url_params.get("start", list())[0]
686                     new_end = url_params.get("end", list())[0]
687                     if new_job and new_start and new_end:
688                         start = self._get_date(new_start)
689                         end = self._get_date(new_end)
690                         job_params = self._set_job_params(new_job)
691                         ctrl_panel = self.ControlPanel(None, job_params)
692                 else:
693                     ctrl_panel = self.ControlPanel(cp_data, self.default)
694                     job = self._get_job(
695                         ctrl_panel.get("ri-duts-value"),
696                         ctrl_panel.get("ri-ttypes-value"),
697                         ctrl_panel.get("ri-cadences-value"),
698                         ctrl_panel.get("dd-tbeds-value")
699                     )
700
701             job = self._get_job(
702                 ctrl_panel.get("ri-duts-value"),
703                 ctrl_panel.get("ri-ttypes-value"),
704                 ctrl_panel.get("ri-cadences-value"),
705                 ctrl_panel.get("dd-tbeds-value")
706             )
707
708             ctrl_panel.set({"al-job-children": job})
709             fig_passed, fig_duration = graph_statistics(self.data, job,
710                 self.layout, start, end)
711
712             ret_val = [
713                 ctrl_panel.panel,
714                 fig_passed,
715                 fig_duration,
716                 gen_new_url(
717                     parsed_url,
718                     {
719                         "job": job,
720                         "start": start,
721                         "end": end
722                     }
723                 )
724             ]
725             ret_val.extend(ctrl_panel.values())
726             return ret_val
727
728         @app.callback(
729             Output("download-data", "data"),
730             State("control-panel", "data"),  # Store
731             State("dpr-period", "start_date"),
732             State("dpr-period", "end_date"),
733             Input("btn-download-data", "n_clicks"),
734             prevent_initial_call=True
735         )
736         def _download_data(cp_data: dict, start: str, end: str, n_clicks: int):
737             """
738             """
739             if not (n_clicks):
740                 raise PreventUpdate
741
742             ctrl_panel = self.ControlPanel(cp_data, self.default)
743
744             job = self._get_job(
745                 ctrl_panel.get("ri-duts-value"),
746                 ctrl_panel.get("ri-ttypes-value"),
747                 ctrl_panel.get("ri-cadences-value"),
748                 ctrl_panel.get("dd-tbeds-value")
749             )
750
751             start = datetime(int(start[0:4]), int(start[5:7]), int(start[8:10]))
752             end = datetime(int(end[0:4]), int(end[5:7]), int(end[8:10]))
753             data = select_data(self.data, job, start, end)
754             data = data.drop(columns=["job", ])
755
756             return dcc.send_data_frame(data.T.to_csv, f"{job}-stats.csv")
757
758         @app.callback(
759             Output("row-metadata", "children"),
760             Output("offcanvas-metadata", "is_open"),
761             Input("graph-passed", "clickData"),
762             Input("graph-duration", "clickData"),
763             prevent_initial_call=True
764         )
765         def _show_metadata_from_graphs(
766                 passed_data: dict, duration_data: dict) -> tuple:
767             """
768             """
769
770             if not (passed_data or duration_data):
771                 raise PreventUpdate
772
773             metadata = no_update
774             open_canvas = False
775             title = "Job Statistics"
776             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
777             if trigger_id == "graph-passed":
778                 graph_data = passed_data["points"][0].get("hovertext", "")
779             elif trigger_id == "graph-duration":
780                 graph_data = duration_data["points"][0].get("text", "")
781             if graph_data:
782                 lst_graph_data = graph_data.split("<br>")
783
784                 # Prepare list of failed tests:
785                 job = str()
786                 build = str()
787                 for itm in lst_graph_data:
788                     if "csit-ref:" in itm:
789                         job, build = itm.split(" ")[-1].split("/")
790                         break
791                 if job and build:
792                     fail_tests = self.data.loc[
793                         (self.data["job"] == job) &
794                         (self.data["build"] == build)
795                     ]["lst_failed"].values[0]
796                     if not fail_tests:
797                         fail_tests = None
798                 else:
799                     fail_tests = None
800
801                 # Create the content of the offcanvas:
802                 metadata = [
803                     dbc.Card(
804                         class_name="gy-2 p-0",
805                         children=[
806                             dbc.CardHeader(children=[
807                                 dcc.Clipboard(
808                                     target_id="metadata",
809                                     title="Copy",
810                                     style={"display": "inline-block"}
811                                 ),
812                                 title
813                             ]),
814                             dbc.CardBody(
815                                 id="metadata",
816                                 class_name="p-0",
817                                 children=[dbc.ListGroup(
818                                     children=[
819                                         dbc.ListGroupItem(
820                                             [
821                                                 dbc.Badge(
822                                                     x.split(":")[0]
823                                                 ),
824                                                 x.split(": ")[1]
825                                             ]
826                                         ) for x in lst_graph_data
827                                     ],
828                                     flush=True),
829                                 ]
830                             )
831                         ]
832                     )
833                 ]
834
835                 if fail_tests is not None:
836                     metadata.append(
837                         dbc.Card(
838                             class_name="gy-2 p-0",
839                             children=[
840                                 dbc.CardHeader(
841                                     f"List of Failed Tests ({len(fail_tests)})"
842                                 ),
843                                 dbc.CardBody(
844                                     id="failed-tests",
845                                     class_name="p-0",
846                                     children=[dbc.ListGroup(
847                                         children=[
848                                             dbc.ListGroupItem(x) \
849                                                 for x in fail_tests
850                                         ],
851                                         flush=True),
852                                     ]
853                                 )
854                             ]
855                         )
856                     )
857
858                 open_canvas = True
859
860             return metadata, open_canvas