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