feat(uti): Statistics - add url
[csit.git] / resources / tools / dash / app / pal / stats / layout.py
1 # Copyright (c) 2022 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """Plotly Dash HTML layout override.
15 """
16
17 import logging
18 import urllib
19 import pandas as pd
20 import dash_bootstrap_components as dbc
21
22 from flask import Flask
23 from dash import dcc
24 from dash import html
25 from dash import callback_context, no_update
26 from dash import Input, Output, State
27 from dash.exceptions import PreventUpdate
28 from yaml import load, FullLoader, YAMLError
29 from datetime import datetime, timedelta
30 from copy import deepcopy
31
32 from ..data.data import Data
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         """
582         """
583         return [{"label": i, "value": i} for i in opts]
584
585     def callbacks(self, app):
586
587         @app.callback(
588             Output("control-panel", "data"),  # Store
589             Output("graph-passed", "figure"),
590             Output("graph-duration", "figure"),
591             Output("card-url", "children"),
592             Output("ri-ttypes", "options"),
593             Output("ri-cadences", "options"),
594             Output("dd-tbeds", "options"),
595             Output("ri-duts", "value"),
596             Output("ri-ttypes", "value"),
597             Output("ri-cadences", "value"),
598             Output("dd-tbeds", "value"),
599             Output("al-job", "children"),
600             State("control-panel", "data"),  # Store
601             Input("ri-duts", "value"),
602             Input("ri-ttypes", "value"),
603             Input("ri-cadences", "value"),
604             Input("dd-tbeds", "value"),
605             Input("dpr-period", "start_date"),
606             Input("dpr-period", "end_date"),
607             Input("url", "href")
608             # prevent_initial_call=True
609         )
610         def _update_ctrl_panel(cp_data: dict, dut:str, ttype: str, cadence:str,
611                 tbed: str, start: str, end: str, href: str) -> tuple:
612             """
613             """
614
615             ctrl_panel = self.ControlPanel(cp_data, self.default)
616
617             start = datetime(int(start[0:4]), int(start[5:7]), int(start[8:10]))
618             end = datetime(int(end[0:4]), int(end[5:7]), int(end[8:10]))
619
620             parsed_url = urllib.parse.urlparse(href)
621             url = f"{parsed_url.netloc}{parsed_url.path}"
622             url_params = urllib.parse.parse_qs(parsed_url.fragment)
623
624             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
625             if trigger_id == "ri-duts":
626                 ttype_opts = self._generate_options(self._get_ttypes(dut))
627                 ttype_val = ttype_opts[0]["value"]
628                 cad_opts = self._generate_options(
629                     self._get_cadences(dut, ttype_val))
630                 cad_val = cad_opts[0]["value"]
631                 tbed_opts = self._generate_options(
632                     self._get_test_beds(dut, ttype_val, cad_val))
633                 tbed_val = tbed_opts[0]["value"]
634                 ctrl_panel.set({
635                     "ri-duts-value": dut,
636                     "ri-ttypes-options": ttype_opts,
637                     "ri-ttypes-value": ttype_val,
638                     "ri-cadences-options": cad_opts,
639                     "ri-cadences-value": cad_val,
640                     "dd-tbeds-options": tbed_opts,
641                     "dd-tbeds-value": tbed_val
642                 })
643             elif trigger_id == "ri-ttypes":
644                 cad_opts = self._generate_options(
645                     self._get_cadences(ctrl_panel.get("ri-duts-value"), ttype))
646                 cad_val = cad_opts[0]["value"]
647                 tbed_opts = self._generate_options(
648                     self._get_test_beds(ctrl_panel.get("ri-duts-value"),
649                     ttype, cad_val))
650                 tbed_val = tbed_opts[0]["value"]
651                 ctrl_panel.set({
652                     "ri-ttypes-value": ttype,
653                     "ri-cadences-options": cad_opts,
654                     "ri-cadences-value": cad_val,
655                     "dd-tbeds-options": tbed_opts,
656                     "dd-tbeds-value": tbed_val
657                 })
658             elif trigger_id == "ri-cadences":
659                 tbed_opts = self._generate_options(
660                     self._get_test_beds(ctrl_panel.get("ri-duts-value"),
661                     ctrl_panel.get("ri-ttypes-value"), cadence))
662                 tbed_val = tbed_opts[0]["value"]
663                 ctrl_panel.set({
664                     "ri-cadences-value": cadence,
665                     "dd-tbeds-options": tbed_opts,
666                     "dd-tbeds-value": tbed_val
667                 })
668             elif trigger_id == "dd-tbeds":
669                 ctrl_panel.set({
670                     "dd-tbeds-value": tbed
671                 })
672             elif trigger_id == "dpr-period":
673                 pass
674             elif trigger_id == "url":
675                 # TODO: Add verification
676                 if url_params:
677                     new_job = url_params.get("job", list())[0]
678                     new_start = url_params.get("start", list())[0]
679                     new_end = url_params.get("end", list())[0]
680                     if new_job and new_start and new_end:
681                         start = datetime(
682                             int(new_start[0:4]), int(new_start[5:7]),
683                             int(new_start[8:10]))
684                         end = datetime(
685                             int(new_end[0:4]), int(new_end[5:7]),
686                             int(new_end[8:10]))
687                         job_params = self._set_job_params(new_job)
688                         ctrl_panel = self.ControlPanel(None, job_params)
689                 else:
690                     ctrl_panel = self.ControlPanel(cp_data, self.default)
691                     job = self._get_job(
692                         ctrl_panel.get("ri-duts-value"),
693                         ctrl_panel.get("ri-ttypes-value"),
694                         ctrl_panel.get("ri-cadences-value"),
695                         ctrl_panel.get("dd-tbeds-value")
696                     )
697
698             job = self._get_job(
699                 ctrl_panel.get("ri-duts-value"),
700                 ctrl_panel.get("ri-ttypes-value"),
701                 ctrl_panel.get("ri-cadences-value"),
702                 ctrl_panel.get("dd-tbeds-value")
703             )
704             url_params = {
705                 "job": job,
706                 "start": start,
707                 "end": end
708             }
709
710             ctrl_panel.set({"al-job-children": job})
711             fig_passed, fig_duration = graph_statistics(
712                 self.data, job, self.layout, start, end)
713
714             ret_val = [
715                 ctrl_panel.panel,
716                 fig_passed,
717                 fig_duration,
718                 [
719                     dcc.Clipboard(
720                         target_id="card-url",
721                         title="Copy URL",
722                         style={"display": "inline-block"}
723                     ),
724                     f"{url}#{urllib.parse.urlencode(url_params)}"
725                 ]
726             ]
727             ret_val.extend(ctrl_panel.values())
728             return ret_val
729
730         @app.callback(
731             Output("download-data", "data"),
732             State("control-panel", "data"),  # Store
733             State("dpr-period", "start_date"),
734             State("dpr-period", "end_date"),
735             Input("btn-download-data", "n_clicks"),
736             prevent_initial_call=True
737         )
738         def _download_data(cp_data: dict, start: str, end: str, n_clicks: int):
739             """
740             """
741             if not (n_clicks):
742                 raise PreventUpdate
743
744             ctrl_panel = self.ControlPanel(cp_data, self.default)
745
746             job = self._get_job(
747                 ctrl_panel.get("ri-duts-value"),
748                 ctrl_panel.get("ri-ttypes-value"),
749                 ctrl_panel.get("ri-cadences-value"),
750                 ctrl_panel.get("dd-tbeds-value")
751             )
752
753             start = datetime(int(start[0:4]), int(start[5:7]), int(start[8:10]))
754             end = datetime(int(end[0:4]), int(end[5:7]), int(end[8:10]))
755             data = select_data(self.data, job, start, end)
756             data = data.drop(columns=["job", ])
757
758             return dcc.send_data_frame(data.T.to_csv, f"{job}-stats.csv")
759
760         @app.callback(
761             Output("row-metadata", "children"),
762             Output("offcanvas-metadata", "is_open"),
763             Input("graph-passed", "clickData"),
764             Input("graph-duration", "clickData"),
765             prevent_initial_call=True
766         )
767         def _show_metadata_from_graphs(
768                 passed_data: dict, duration_data: dict) -> tuple:
769             """
770             """
771
772             if not (passed_data or duration_data):
773                 raise PreventUpdate
774
775             metadata = no_update
776             open_canvas = False
777             title = "Job Statistics"
778             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
779             if trigger_id == "graph-passed":
780                 graph_data = passed_data["points"][0].get("hovertext", "")
781             elif trigger_id == "graph-duration":
782                 graph_data = duration_data["points"][0].get("text", "")
783             if graph_data:
784                 metadata = [
785                     dbc.Card(
786                         class_name="gy-2 p-0",
787                         children=[
788                             dbc.CardHeader(children=[
789                                 dcc.Clipboard(
790                                     target_id="metadata",
791                                     title="Copy",
792                                     style={"display": "inline-block"}
793                                 ),
794                                 title
795                             ]),
796                             dbc.CardBody(
797                                 id="metadata",
798                                 class_name="p-0",
799                                 children=[dbc.ListGroup(
800                                     children=[
801                                         dbc.ListGroupItem(
802                                             [
803                                                 dbc.Badge(
804                                                     x.split(":")[0]
805                                                 ),
806                                                 x.split(": ")[1]
807                                             ]
808                                         ) for x in graph_data.split("<br>")
809                                     ],
810                                     flush=True),
811                                 ]
812                             )
813                         ]
814                     )
815                 ]
816                 open_canvas = True
817
818             return metadata, open_canvas