UTI: Add regressions and progressions
[csit.git] / resources / tools / dash / app / pal / news / 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
25 from dash import Input, Output, State
26 from yaml import load, FullLoader, YAMLError
27 from copy import deepcopy
28
29 from ..data.data import Data
30 from ..data.utils import classify_anomalies
31 from .tables import table_news
32
33
34 class Layout:
35     """The layout of the dash app and the callbacks.
36     """
37
38     # The default job displayed when the page is loaded first time.
39     DEFAULT_JOB = "csit-vpp-perf-mrr-daily-master-2n-icx"
40
41     # Time period for regressions and progressions.
42     TIME_PERIOD = 21  # [days]
43
44     def __init__(self, app: Flask, html_layout_file: str, data_spec_file: str,
45         tooltip_file: str) -> None:
46         """Initialization:
47         - save the input parameters,
48         - read and pre-process the data,
49         - prepare data fro the control panel,
50         - read HTML layout file,
51         - read tooltips from the tooltip file.
52
53         :param app: Flask application running the dash application.
54         :param html_layout_file: Path and name of the file specifying the HTML
55             layout of the dash application.
56         :param data_spec_file: Path and name of the file specifying the data to
57             be read from parquets for this application.
58         :param tooltip_file: Path and name of the yaml file specifying the
59             tooltips.
60         :type app: Flask
61         :type html_layout_file: str
62         :type data_spec_file: str
63         :type tooltip_file: str
64         """
65
66         # Inputs
67         self._app = app
68         self._html_layout_file = html_layout_file
69         self._data_spec_file = data_spec_file
70         self._tooltip_file = tooltip_file
71
72         # Read the data:
73         data_stats, data_mrr, data_ndrpdr = Data(
74             data_spec_file=self._data_spec_file,
75             debug=True
76         ).read_stats(days=self.TIME_PERIOD)
77
78         df_tst_info = pd.concat([data_mrr, data_ndrpdr], ignore_index=True)
79
80         # Prepare information for the control panel:
81         jobs = sorted(list(df_tst_info["job"].unique()))
82         job_info = {
83             "job": list(),
84             "dut": list(),
85             "ttype": list(),
86             "cadence": list(),
87             "tbed": list()
88         }
89         for job in jobs:
90             lst_job = job.split("-")
91             job_info["job"].append(job)
92             job_info["dut"].append(lst_job[1])
93             job_info["ttype"].append(lst_job[3])
94             job_info["cadence"].append(lst_job[4])
95             job_info["tbed"].append("-".join(lst_job[-2:]))
96         self.df_job_info = pd.DataFrame.from_dict(job_info)
97
98         self._default = self._set_job_params(self.DEFAULT_JOB)
99
100         # Pre-process the data:
101
102         def _create_test_name(test: str) -> str:
103             lst_tst = test.split(".")
104             suite = lst_tst[-2].replace("2n1l-", "").replace("1n1l-", "").\
105                 replace("2n-", "")
106             return f"{suite.split('-')[0]}-{lst_tst[-1]}"
107
108         def _get_rindex(array: list, itm: any) -> int:
109             return len(array) - 1 - array[::-1].index(itm)
110
111         tst_info = {
112             "job": list(),
113             "build": list(),
114             "start": list(),
115             "dut_type": list(),
116             "dut_version": list(),
117             "hosts": list(),
118             "failed": list(),
119             "regressions": list(),
120             "progressions": list()
121         }
122         for job in jobs:
123             # Create lists of failed tests:
124             df_job = df_tst_info.loc[(df_tst_info["job"] == job)]
125             last_build = max(df_job["build"].unique())
126             df_build = df_job.loc[(df_job["build"] == last_build)]
127             tst_info["job"].append(job)
128             tst_info["build"].append(last_build)
129             tst_info["start"].append(data_stats.loc[
130                 (data_stats["job"] == job) &
131                 (data_stats["build"] == last_build)
132             ]["start_time"].iloc[-1].strftime('%Y-%m-%d %H:%M'))
133             tst_info["dut_type"].append(df_build["dut_type"].iloc[-1])
134             tst_info["dut_version"].append(df_build["dut_version"].iloc[-1])
135             tst_info["hosts"].append(df_build["hosts"].iloc[-1])
136             failed_tests = df_build.loc[(df_build["passed"] == False)]\
137                 ["test_id"].to_list()
138             l_failed = list()
139             try:
140                 for tst in failed_tests:
141                     l_failed.append(_create_test_name(tst))
142             except KeyError:
143                 l_failed = list()
144             tst_info["failed"].append(sorted(l_failed))
145
146             # Create lists of regressions and progressions:
147             l_reg = list()
148             l_prog = list()
149
150             tests = df_job["test_id"].unique()
151             for test in tests:
152                 tst_data = df_job.loc[df_job["test_id"] == test].sort_values(
153                     by="start_time", ignore_index=True)
154                 x_axis = tst_data["start_time"].tolist()
155                 if "-ndrpdr" in test:
156                     tst_data = tst_data.dropna(
157                         subset=["result_pdr_lower_rate_value", ]
158                     )
159                     if tst_data.empty:
160                         continue
161                     try:
162                         anomalies, _, _ = classify_anomalies({
163                             k: v for k, v in zip(
164                                 x_axis,
165                                 tst_data["result_ndr_lower_rate_value"].tolist()
166                             )
167                         })
168                     except ValueError:
169                         continue
170                     if "progression" in anomalies:
171                         l_prog.append((
172                             _create_test_name(test).replace("-ndrpdr", "-ndr"),
173                             x_axis[_get_rindex(anomalies, "progression")]
174                         ))
175                     if "regression" in anomalies:
176                         l_reg.append((
177                             _create_test_name(test).replace("-ndrpdr", "-ndr"),
178                             x_axis[_get_rindex(anomalies, "regression")]
179                         ))
180                     try:
181                         anomalies, _, _ = classify_anomalies({
182                             k: v for k, v in zip(
183                                 x_axis,
184                                 tst_data["result_pdr_lower_rate_value"].tolist()
185                             )
186                         })
187                     except ValueError:
188                         continue
189                     if "progression" in anomalies:
190                         l_prog.append((
191                             _create_test_name(test).replace("-ndrpdr", "-pdr"),
192                             x_axis[_get_rindex(anomalies, "progression")]
193                         ))
194                     if "regression" in anomalies:
195                         l_reg.append((
196                             _create_test_name(test).replace("-ndrpdr", "-pdr"),
197                             x_axis[_get_rindex(anomalies, "regression")]
198                         ))
199                 else:  # mrr
200                     tst_data = tst_data.dropna(
201                         subset=["result_receive_rate_rate_avg", ]
202                     )
203                     if tst_data.empty:
204                         continue
205                     try:
206                         anomalies, _, _ = classify_anomalies({
207                             k: v for k, v in zip(
208                                 x_axis,
209                                 tst_data["result_receive_rate_rate_avg"].\
210                                     tolist()
211                             )
212                         })
213                     except ValueError:
214                         continue
215                     if "progression" in anomalies:
216                         l_prog.append((
217                             _create_test_name(test),
218                             x_axis[_get_rindex(anomalies, "progression")]
219                         ))
220                     if "regression" in anomalies:
221                         l_reg.append((
222                             _create_test_name(test),
223                             x_axis[_get_rindex(anomalies, "regression")]
224                         ))
225
226             tst_info["regressions"].append(
227                 sorted(l_reg, key=lambda k: k[1], reverse=True))
228             tst_info["progressions"].append(
229                 sorted(l_prog, key=lambda k: k[1], reverse=True))
230
231         self._data = pd.DataFrame.from_dict(tst_info)
232
233         # Read from files:
234         self._html_layout = str()
235         self._tooltips = dict()
236
237         try:
238             with open(self._html_layout_file, "r") as file_read:
239                 self._html_layout = file_read.read()
240         except IOError as err:
241             raise RuntimeError(
242                 f"Not possible to open the file {self._html_layout_file}\n{err}"
243             )
244
245         try:
246             with open(self._tooltip_file, "r") as file_read:
247                 self._tooltips = load(file_read, Loader=FullLoader)
248         except IOError as err:
249             logging.warning(
250                 f"Not possible to open the file {self._tooltip_file}\n{err}"
251             )
252         except YAMLError as err:
253             logging.warning(
254                 f"An error occurred while parsing the specification file "
255                 f"{self._tooltip_file}\n{err}"
256             )
257
258         self._default_tab_failed = table_news(self.data, self._default["job"])
259
260         # Callbacks:
261         if self._app is not None and hasattr(self, 'callbacks'):
262             self.callbacks(self._app)
263
264     @property
265     def html_layout(self) -> dict:
266         return self._html_layout
267
268     @property
269     def data(self) -> pd.DataFrame:
270         return self._data
271
272     @property
273     def default(self) -> dict:
274         return self._default
275
276     def _get_duts(self) -> list:
277         """Get the list of DUTs from the pre-processed information about jobs.
278
279         :returns: Alphabeticaly sorted list of DUTs.
280         :rtype: list
281         """
282         return sorted(list(self.df_job_info["dut"].unique()))
283
284     def _get_ttypes(self, dut: str) -> list:
285         """Get the list of test types from the pre-processed information about
286         jobs.
287
288         :param dut: The DUT for which the list of test types will be populated.
289         :type dut: str
290         :returns: Alphabeticaly sorted list of test types.
291         :rtype: list
292         """
293         return sorted(list(self.df_job_info.loc[(
294             self.df_job_info["dut"] == dut
295         )]["ttype"].unique()))
296
297     def _get_cadences(self, dut: str, ttype: str) -> list:
298         """Get the list of cadences from the pre-processed information about
299         jobs.
300
301         :param dut: The DUT for which the list of cadences will be populated.
302         :param ttype: The test type for which the list of cadences will be
303             populated.
304         :type dut: str
305         :type ttype: str
306         :returns: Alphabeticaly sorted list of cadences.
307         :rtype: list
308         """
309         return sorted(list(self.df_job_info.loc[(
310             (self.df_job_info["dut"] == dut) &
311             (self.df_job_info["ttype"] == ttype)
312         )]["cadence"].unique()))
313
314     def _get_test_beds(self, dut: str, ttype: str, cadence: str) -> list:
315         """Get the list of test beds from the pre-processed information about
316         jobs.
317
318         :param dut: The DUT for which the list of test beds will be populated.
319         :param ttype: The test type for which the list of test beds will be
320             populated.
321         :param cadence: The cadence for which the list of test beds will be
322             populated.
323         :type dut: str
324         :type ttype: str
325         :type cadence: str
326         :returns: Alphabeticaly sorted list of test beds.
327         :rtype: list
328         """
329         return sorted(list(self.df_job_info.loc[(
330             (self.df_job_info["dut"] == dut) &
331             (self.df_job_info["ttype"] == ttype) &
332             (self.df_job_info["cadence"] == cadence)
333         )]["tbed"].unique()))
334
335     def _get_job(self, dut, ttype, cadence, testbed):
336         """Get the name of a job defined by dut, ttype, cadence, test bed.
337         Input information comes from the control panel.
338
339         :param dut: The DUT for which the job name will be created.
340         :param ttype: The test type for which the job name will be created.
341         :param cadence: The cadence for which the job name will be created.
342         :param testbed: The test bed for which the job name will be created.
343         :type dut: str
344         :type ttype: str
345         :type cadence: str
346         :type testbed: str
347         :returns: Job name.
348         :rtype: str
349         """
350         return self.df_job_info.loc[(
351             (self.df_job_info["dut"] == dut) &
352             (self.df_job_info["ttype"] == ttype) &
353             (self.df_job_info["cadence"] == cadence) &
354             (self.df_job_info["tbed"] == testbed)
355         )]["job"].item()
356
357     @staticmethod
358     def _generate_options(opts: list) -> list:
359         """Return list of options for radio items in control panel. The items in
360         the list are dictionaries with keys "label" and "value".
361
362         :params opts: List of options (str) to be used for the generated list.
363         :type opts: list
364         :returns: List of options (dict).
365         :rtype: list
366         """
367         return [{"label": i, "value": i} for i in opts]
368
369     def _set_job_params(self, job: str) -> dict:
370         """Create a dictionary with all options and values for (and from) the
371         given job.
372
373         :params job: The name of job for and from which the dictionary will be
374             created.
375         :type job: str
376         :returns: Dictionary with all options and values for (and from) the
377             given job.
378         :rtype: dict
379         """
380
381         lst_job = job.split("-")
382         return {
383             "job": job,
384             "dut": lst_job[1],
385             "ttype": lst_job[3],
386             "cadence": lst_job[4],
387             "tbed": "-".join(lst_job[-2:]),
388             "duts": self._generate_options(self._get_duts()),
389             "ttypes": self._generate_options(self._get_ttypes(lst_job[1])),
390             "cadences": self._generate_options(self._get_cadences(
391                 lst_job[1], lst_job[3])),
392             "tbeds": self._generate_options(self._get_test_beds(
393                 lst_job[1], lst_job[3], lst_job[4]))
394         }
395
396     def _show_tooltip(self, id: str, title: str,
397             clipboard_id: str=None) -> list:
398         """Generate list of elements to display a text (e.g. a title) with a
399         tooltip and optionaly with Copy&Paste icon and the clipboard
400         functionality enabled.
401
402         :param id: Tooltip ID.
403         :param title: A text for which the tooltip will be displayed.
404         :param clipboard_id: If defined, a Copy&Paste icon is displayed and the
405             clipboard functionality is enabled.
406         :type id: str
407         :type title: str
408         :type clipboard_id: str
409         :returns: List of elements to display a text with a tooltip and
410             optionaly with Copy&Paste icon.
411         :rtype: list
412         """
413
414         return [
415             dcc.Clipboard(target_id=clipboard_id, title="Copy URL") \
416                 if clipboard_id else str(),
417             f"{title} ",
418             dbc.Badge(
419                 id=id,
420                 children="?",
421                 pill=True,
422                 color="white",
423                 text_color="info",
424                 class_name="border ms-1",
425             ),
426             dbc.Tooltip(
427                 children=self._tooltips.get(id, str()),
428                 target=id,
429                 placement="auto"
430             )
431         ]
432
433     def add_content(self):
434         """Top level method which generated the web page.
435
436         It generates:
437         - Store for user input data,
438         - Navigation bar,
439         - Main area with control panel and ploting area.
440
441         If no HTML layout is provided, an error message is displayed instead.
442
443         :returns: The HTML div with teh whole page.
444         :rtype: html.Div
445         """
446
447         if self.html_layout:
448             return html.Div(
449                 id="div-main",
450                 children=[
451                     dcc.Store(id="control-panel"),
452                     dbc.Row(
453                         id="row-navbar",
454                         class_name="g-0",
455                         children=[
456                             self._add_navbar(),
457                         ]
458                     ),
459                     dbc.Row(
460                         id="row-main",
461                         class_name="g-0",
462                         children=[
463                             self._add_ctrl_col(),
464                             self._add_plotting_col(),
465                         ]
466                     )
467                 ]
468             )
469         else:
470             return html.Div(
471                 id="div-main-error",
472                 children=[
473                     dbc.Alert(
474                         [
475                             "An Error Occured",
476                         ],
477                         color="danger",
478                     ),
479                 ]
480             )
481
482     def _add_navbar(self):
483         """Add nav element with navigation panel. It is placed on the top.
484
485         :returns: Navigation bar.
486         :rtype: dbc.NavbarSimple
487         """
488
489         return dbc.NavbarSimple(
490             id="navbarsimple-main",
491             children=[
492                 dbc.NavItem(
493                     dbc.NavLink(
494                         "Continuous Performance News",
495                         disabled=True,
496                         external_link=True,
497                         href="#"
498                     )
499                 )
500             ],
501             brand="Dashboard",
502             brand_href="/",
503             brand_external_link=True,
504             class_name="p-2",
505             fluid=True,
506         )
507
508     def _add_ctrl_col(self) -> dbc.Col:
509         """Add column with control panel. It is placed on the left side.
510
511         :returns: Column with the control panel.
512         :rtype: dbc.col
513         """
514
515         return dbc.Col(
516             id="col-controls",
517             children=[
518                 self._add_ctrl_panel(),
519             ],
520         )
521
522     def _add_plotting_col(self) -> dbc.Col:
523         """Add column with tables. It is placed on the right side.
524
525         :returns: Column with tables.
526         :rtype: dbc.col
527         """
528
529         return dbc.Col(
530             id="col-plotting-area",
531             children=[
532                 dbc.Row(  # Failed tests
533                     id="row-table-failed",
534                     class_name="g-0 p-2",
535                     children=self._default_tab_failed
536                 )
537             ],
538             width=9,
539         )
540
541     def _add_ctrl_panel(self) -> dbc.Row:
542         """Add control panel.
543
544         :returns: Control panel.
545         :rtype: dbc.Row
546         """
547         return dbc.Row(
548             id="row-ctrl-panel",
549             class_name="g-0",
550             children=[
551                 dbc.Row(
552                     class_name="g-0 p-2",
553                     children=[
554                         dbc.Row(
555                             class_name="gy-1",
556                             children=[
557                                 dbc.Label(
558                                     class_name="p-0",
559                                     children=self._show_tooltip(
560                                         "help-dut", "Device under Test")
561                                 ),
562                                 dbc.Row(
563                                     dbc.RadioItems(
564                                         id="ri-duts",
565                                         inline=True,
566                                         value=self.default["dut"],
567                                         options=self.default["duts"]
568                                     )
569                                 )
570                             ]
571                         ),
572                         dbc.Row(
573                             class_name="gy-1",
574                             children=[
575                                 dbc.Label(
576                                     class_name="p-0",
577                                     children=self._show_tooltip(
578                                         "help-ttype", "Test Type"),
579                                 ),
580                                 dbc.RadioItems(
581                                     id="ri-ttypes",
582                                     inline=True,
583                                     value=self.default["ttype"],
584                                     options=self.default["ttypes"]
585                                 )
586                             ]
587                         ),
588                         dbc.Row(
589                             class_name="gy-1",
590                             children=[
591                                 dbc.Label(
592                                     class_name="p-0",
593                                     children=self._show_tooltip(
594                                         "help-cadence", "Cadence"),
595                                 ),
596                                 dbc.RadioItems(
597                                     id="ri-cadences",
598                                     inline=True,
599                                     value=self.default["cadence"],
600                                     options=self.default["cadences"]
601                                 )
602                             ]
603                         ),
604                         dbc.Row(
605                             class_name="gy-1",
606                             children=[
607                                 dbc.Label(
608                                     class_name="p-0",
609                                     children=self._show_tooltip(
610                                         "help-tbed", "Test Bed"),
611                                 ),
612                                 dbc.Select(
613                                     id="dd-tbeds",
614                                     placeholder="Select a test bed...",
615                                     value=self.default["tbed"],
616                                     options=self.default["tbeds"]
617                                 )
618                             ]
619                         ),
620                         dbc.Row(
621                             class_name="gy-1",
622                             children=[
623                                 dbc.Alert(
624                                     id="al-job",
625                                     color="info",
626                                     children=self.default["job"]
627                                 )
628                             ]
629                         )
630                     ]
631                 ),
632             ]
633         )
634
635     class ControlPanel:
636         """
637         """
638
639         def __init__(self, panel: dict, default: dict) -> None:
640             """
641             """
642
643             self._defaults = {
644                 "ri-ttypes-options": default["ttypes"],
645                 "ri-cadences-options": default["cadences"],
646                 "dd-tbeds-options": default["tbeds"],
647                 "ri-duts-value": default["dut"],
648                 "ri-ttypes-value": default["ttype"],
649                 "ri-cadences-value": default["cadence"],
650                 "dd-tbeds-value": default["tbed"],
651                 "al-job-children": default["job"]
652             }
653             self._panel = deepcopy(self._defaults)
654             if panel:
655                 for key in self._defaults:
656                     self._panel[key] = panel[key]
657
658         def set(self, kwargs: dict) -> None:
659             for key, val in kwargs.items():
660                 if key in self._panel:
661                     self._panel[key] = val
662                 else:
663                     raise KeyError(f"The key {key} is not defined.")
664
665         @property
666         def defaults(self) -> dict:
667             return self._defaults
668
669         @property
670         def panel(self) -> dict:
671             return self._panel
672
673         def get(self, key: str) -> any:
674             return self._panel[key]
675
676         def values(self) -> list:
677             return list(self._panel.values())
678
679     def callbacks(self, app):
680
681         @app.callback(
682             Output("control-panel", "data"),  # Store
683             Output("row-table-failed", "children"),
684             Output("ri-ttypes", "options"),
685             Output("ri-cadences", "options"),
686             Output("dd-tbeds", "options"),
687             Output("ri-duts", "value"),
688             Output("ri-ttypes", "value"),
689             Output("ri-cadences", "value"),
690             Output("dd-tbeds", "value"),
691             Output("al-job", "children"),
692             State("control-panel", "data"),  # Store
693             Input("ri-duts", "value"),
694             Input("ri-ttypes", "value"),
695             Input("ri-cadences", "value"),
696             Input("dd-tbeds", "value"),
697         )
698         def _update_ctrl_panel(cp_data: dict, dut:str, ttype: str, cadence:str,
699                 tbed: str) -> tuple:
700             """
701             """
702
703             ctrl_panel = self.ControlPanel(cp_data, self.default)
704
705             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
706             if trigger_id == "ri-duts":
707                 ttype_opts = self._generate_options(self._get_ttypes(dut))
708                 ttype_val = ttype_opts[0]["value"]
709                 cad_opts = self._generate_options(
710                     self._get_cadences(dut, ttype_val))
711                 cad_val = cad_opts[0]["value"]
712                 tbed_opts = self._generate_options(
713                     self._get_test_beds(dut, ttype_val, cad_val))
714                 tbed_val = tbed_opts[0]["value"]
715                 ctrl_panel.set({
716                     "ri-duts-value": dut,
717                     "ri-ttypes-options": ttype_opts,
718                     "ri-ttypes-value": ttype_val,
719                     "ri-cadences-options": cad_opts,
720                     "ri-cadences-value": cad_val,
721                     "dd-tbeds-options": tbed_opts,
722                     "dd-tbeds-value": tbed_val
723                 })
724             elif trigger_id == "ri-ttypes":
725                 cad_opts = self._generate_options(
726                     self._get_cadences(ctrl_panel.get("ri-duts-value"), ttype))
727                 cad_val = cad_opts[0]["value"]
728                 tbed_opts = self._generate_options(
729                     self._get_test_beds(ctrl_panel.get("ri-duts-value"),
730                     ttype, cad_val))
731                 tbed_val = tbed_opts[0]["value"]
732                 ctrl_panel.set({
733                     "ri-ttypes-value": ttype,
734                     "ri-cadences-options": cad_opts,
735                     "ri-cadences-value": cad_val,
736                     "dd-tbeds-options": tbed_opts,
737                     "dd-tbeds-value": tbed_val
738                 })
739             elif trigger_id == "ri-cadences":
740                 tbed_opts = self._generate_options(
741                     self._get_test_beds(ctrl_panel.get("ri-duts-value"),
742                     ctrl_panel.get("ri-ttypes-value"), cadence))
743                 tbed_val = tbed_opts[0]["value"]
744                 ctrl_panel.set({
745                     "ri-cadences-value": cadence,
746                     "dd-tbeds-options": tbed_opts,
747                     "dd-tbeds-value": tbed_val
748                 })
749             elif trigger_id == "dd-tbeds":
750                 ctrl_panel.set({
751                     "dd-tbeds-value": tbed
752                 })
753
754             job = self._get_job(
755                 ctrl_panel.get("ri-duts-value"),
756                 ctrl_panel.get("ri-ttypes-value"),
757                 ctrl_panel.get("ri-cadences-value"),
758                 ctrl_panel.get("dd-tbeds-value")
759             )
760             ctrl_panel.set({"al-job-children": job})
761             tab_failed = table_news(self.data, job)
762
763             ret_val = [
764                 ctrl_panel.panel,
765                 tab_failed
766             ]
767             ret_val.extend(ctrl_panel.values())
768             return ret_val