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