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