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