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