6743b5a33c2c0311842d17ababa71813d599503e
[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, show_tooltip
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 add_content(self):
393         """Top level method which generated the web page.
394
395         It generates:
396         - Store for user input data,
397         - Navigation bar,
398         - Main area with control panel and ploting area.
399
400         If no HTML layout is provided, an error message is displayed instead.
401
402         :returns: The HTML div with teh whole page.
403         :rtype: html.Div
404         """
405
406         if self.html_layout:
407             return html.Div(
408                 id="div-main",
409                 children=[
410                     dcc.Store(id="control-panel"),
411                     dbc.Row(
412                         id="row-navbar",
413                         class_name="g-0",
414                         children=[
415                             self._add_navbar(),
416                         ]
417                     ),
418                     dbc.Row(
419                         id="row-main",
420                         class_name="g-0",
421                         children=[
422                             self._add_ctrl_col(),
423                             self._add_plotting_col(),
424                         ]
425                     )
426                 ]
427             )
428         else:
429             return html.Div(
430                 id="div-main-error",
431                 children=[
432                     dbc.Alert(
433                         [
434                             "An Error Occured",
435                         ],
436                         color="danger",
437                     ),
438                 ]
439             )
440
441     def _add_navbar(self):
442         """Add nav element with navigation panel. It is placed on the top.
443
444         :returns: Navigation bar.
445         :rtype: dbc.NavbarSimple
446         """
447
448         return dbc.NavbarSimple(
449             id="navbarsimple-main",
450             children=[
451                 dbc.NavItem(
452                     dbc.NavLink(
453                         "Continuous Performance News",
454                         disabled=True,
455                         external_link=True,
456                         href="#"
457                     )
458                 )
459             ],
460             brand="Dashboard",
461             brand_href="/",
462             brand_external_link=True,
463             class_name="p-2",
464             fluid=True,
465         )
466
467     def _add_ctrl_col(self) -> dbc.Col:
468         """Add column with control panel. It is placed on the left side.
469
470         :returns: Column with the control panel.
471         :rtype: dbc.col
472         """
473
474         return dbc.Col(
475             id="col-controls",
476             children=[
477                 self._add_ctrl_panel(),
478             ],
479         )
480
481     def _add_plotting_col(self) -> dbc.Col:
482         """Add column with tables. It is placed on the right side.
483
484         :returns: Column with tables.
485         :rtype: dbc.col
486         """
487
488         return dbc.Col(
489             id="col-plotting-area",
490             children=[
491                 dbc.Row(  # Failed tests
492                     id="row-table-failed",
493                     class_name="g-0 p-2",
494                     children=self._default_tab_failed
495                 )
496             ],
497             width=9,
498         )
499
500     def _add_ctrl_panel(self) -> dbc.Row:
501         """Add control panel.
502
503         :returns: Control panel.
504         :rtype: dbc.Row
505         """
506         return dbc.Row(
507             id="row-ctrl-panel",
508             class_name="g-0",
509             children=[
510                 dbc.Row(
511                     class_name="g-0 p-2",
512                     children=[
513                         dbc.Row(
514                             class_name="gy-1",
515                             children=[
516                                 dbc.Label(
517                                     class_name="p-0",
518                                     children=show_tooltip(self._tooltips,
519                                         "help-dut", "Device under Test")
520                                 ),
521                                 dbc.Row(
522                                     dbc.RadioItems(
523                                         id="ri-duts",
524                                         inline=True,
525                                         value=self.default["dut"],
526                                         options=self.default["duts"]
527                                     )
528                                 )
529                             ]
530                         ),
531                         dbc.Row(
532                             class_name="gy-1",
533                             children=[
534                                 dbc.Label(
535                                     class_name="p-0",
536                                     children=show_tooltip(self._tooltips,
537                                         "help-ttype", "Test Type"),
538                                 ),
539                                 dbc.RadioItems(
540                                     id="ri-ttypes",
541                                     inline=True,
542                                     value=self.default["ttype"],
543                                     options=self.default["ttypes"]
544                                 )
545                             ]
546                         ),
547                         dbc.Row(
548                             class_name="gy-1",
549                             children=[
550                                 dbc.Label(
551                                     class_name="p-0",
552                                     children=show_tooltip(self._tooltips,
553                                         "help-cadence", "Cadence"),
554                                 ),
555                                 dbc.RadioItems(
556                                     id="ri-cadences",
557                                     inline=True,
558                                     value=self.default["cadence"],
559                                     options=self.default["cadences"]
560                                 )
561                             ]
562                         ),
563                         dbc.Row(
564                             class_name="gy-1",
565                             children=[
566                                 dbc.Label(
567                                     class_name="p-0",
568                                     children=show_tooltip(self._tooltips,
569                                         "help-tbed", "Test Bed"),
570                                 ),
571                                 dbc.Select(
572                                     id="dd-tbeds",
573                                     placeholder="Select a test bed...",
574                                     value=self.default["tbed"],
575                                     options=self.default["tbeds"]
576                                 )
577                             ]
578                         ),
579                         dbc.Row(
580                             class_name="gy-1",
581                             children=[
582                                 dbc.Alert(
583                                     id="al-job",
584                                     color="info",
585                                     children=self.default["job"]
586                                 )
587                             ]
588                         )
589                     ]
590                 ),
591             ]
592         )
593
594     class ControlPanel:
595         """
596         """
597
598         def __init__(self, panel: dict, default: dict) -> None:
599             """
600             """
601
602             self._defaults = {
603                 "ri-ttypes-options": default["ttypes"],
604                 "ri-cadences-options": default["cadences"],
605                 "dd-tbeds-options": default["tbeds"],
606                 "ri-duts-value": default["dut"],
607                 "ri-ttypes-value": default["ttype"],
608                 "ri-cadences-value": default["cadence"],
609                 "dd-tbeds-value": default["tbed"],
610                 "al-job-children": default["job"]
611             }
612             self._panel = deepcopy(self._defaults)
613             if panel:
614                 for key in self._defaults:
615                     self._panel[key] = panel[key]
616
617         def set(self, kwargs: dict) -> None:
618             for key, val in kwargs.items():
619                 if key in self._panel:
620                     self._panel[key] = val
621                 else:
622                     raise KeyError(f"The key {key} is not defined.")
623
624         @property
625         def defaults(self) -> dict:
626             return self._defaults
627
628         @property
629         def panel(self) -> dict:
630             return self._panel
631
632         def get(self, key: str) -> any:
633             return self._panel[key]
634
635         def values(self) -> list:
636             return list(self._panel.values())
637
638     def callbacks(self, app):
639
640         @app.callback(
641             Output("control-panel", "data"),  # Store
642             Output("row-table-failed", "children"),
643             Output("ri-ttypes", "options"),
644             Output("ri-cadences", "options"),
645             Output("dd-tbeds", "options"),
646             Output("ri-duts", "value"),
647             Output("ri-ttypes", "value"),
648             Output("ri-cadences", "value"),
649             Output("dd-tbeds", "value"),
650             Output("al-job", "children"),
651             State("control-panel", "data"),  # Store
652             Input("ri-duts", "value"),
653             Input("ri-ttypes", "value"),
654             Input("ri-cadences", "value"),
655             Input("dd-tbeds", "value"),
656         )
657         def _update_ctrl_panel(cp_data: dict, dut:str, ttype: str, cadence:str,
658                 tbed: str) -> tuple:
659             """
660             """
661
662             ctrl_panel = self.ControlPanel(cp_data, self.default)
663
664             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
665             if trigger_id == "ri-duts":
666                 ttype_opts = self._generate_options(self._get_ttypes(dut))
667                 ttype_val = ttype_opts[0]["value"]
668                 cad_opts = self._generate_options(
669                     self._get_cadences(dut, ttype_val))
670                 cad_val = cad_opts[0]["value"]
671                 tbed_opts = self._generate_options(
672                     self._get_test_beds(dut, ttype_val, cad_val))
673                 tbed_val = tbed_opts[0]["value"]
674                 ctrl_panel.set({
675                     "ri-duts-value": dut,
676                     "ri-ttypes-options": ttype_opts,
677                     "ri-ttypes-value": ttype_val,
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-ttypes":
684                 cad_opts = self._generate_options(
685                     self._get_cadences(ctrl_panel.get("ri-duts-value"), ttype))
686                 cad_val = cad_opts[0]["value"]
687                 tbed_opts = self._generate_options(
688                     self._get_test_beds(ctrl_panel.get("ri-duts-value"),
689                     ttype, cad_val))
690                 tbed_val = tbed_opts[0]["value"]
691                 ctrl_panel.set({
692                     "ri-ttypes-value": ttype,
693                     "ri-cadences-options": cad_opts,
694                     "ri-cadences-value": cad_val,
695                     "dd-tbeds-options": tbed_opts,
696                     "dd-tbeds-value": tbed_val
697                 })
698             elif trigger_id == "ri-cadences":
699                 tbed_opts = self._generate_options(
700                     self._get_test_beds(ctrl_panel.get("ri-duts-value"),
701                     ctrl_panel.get("ri-ttypes-value"), cadence))
702                 tbed_val = tbed_opts[0]["value"]
703                 ctrl_panel.set({
704                     "ri-cadences-value": cadence,
705                     "dd-tbeds-options": tbed_opts,
706                     "dd-tbeds-value": tbed_val
707                 })
708             elif trigger_id == "dd-tbeds":
709                 ctrl_panel.set({
710                     "dd-tbeds-value": tbed
711                 })
712
713             job = self._get_job(
714                 ctrl_panel.get("ri-duts-value"),
715                 ctrl_panel.get("ri-ttypes-value"),
716                 ctrl_panel.get("ri-cadences-value"),
717                 ctrl_panel.get("dd-tbeds-value")
718             )
719             ctrl_panel.set({"al-job-children": job})
720             tab_failed = table_news(self.data, job)
721
722             ret_val = [
723                 ctrl_panel.panel,
724                 tab_failed
725             ]
726             ret_val.extend(ctrl_panel.values())
727             return ret_val