fix(uti): News: build number processing
[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, table_summary
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         self._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 self._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 self._jobs:
122             # Create lists of failed tests:
123             df_job = df_tst_info.loc[(df_tst_info["job"] == job)]
124             last_build = str(max(pd.to_numeric(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 = \
258             table_news(self.data, self._default["job"], C.NEWS_TIME_PERIOD)
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 add_content(self):
277         """Top level method which generated the web page.
278
279         It generates:
280         - Store for user input data,
281         - Navigation bar,
282         - Main area with control panel and ploting area.
283
284         If no HTML layout is provided, an error message is displayed instead.
285
286         :returns: The HTML div with the whole page.
287         :rtype: html.Div
288         """
289
290         if self.html_layout:
291             return html.Div(
292                 id="div-main",
293                 children=[
294                     dcc.Store(id="control-panel"),
295                     dcc.Location(id="url", refresh=False),
296                     dbc.Row(
297                         id="row-navbar",
298                         class_name="g-0",
299                         children=[
300                             self._add_navbar(),
301                         ]
302                     ),
303                     dbc.Row(
304                         id="row-main",
305                         class_name="g-0",
306                         children=[
307                             self._add_ctrl_col(),
308                             self._add_plotting_col(),
309                         ]
310                     )
311                 ]
312             )
313         else:
314             return html.Div(
315                 id="div-main-error",
316                 children=[
317                     dbc.Alert(
318                         [
319                             "An Error Occured",
320                         ],
321                         color="danger",
322                     ),
323                 ]
324             )
325
326     def _add_navbar(self):
327         """Add nav element with navigation panel. It is placed on the top.
328
329         :returns: Navigation bar.
330         :rtype: dbc.NavbarSimple
331         """
332
333         return dbc.NavbarSimple(
334             id="navbarsimple-main",
335             children=[
336                 dbc.NavItem(
337                     dbc.NavLink(
338                         "Continuous Performance News",
339                         disabled=True,
340                         external_link=True,
341                         href="#"
342                     )
343                 )
344             ],
345             brand="Dashboard",
346             brand_href="/",
347             brand_external_link=True,
348             class_name="p-2",
349             fluid=True,
350         )
351
352     def _add_ctrl_col(self) -> dbc.Col:
353         """Add column with control panel. It is placed on the left side.
354
355         :returns: Column with the control panel.
356         :rtype: dbc.Col
357         """
358
359         return dbc.Col(
360             id="col-controls",
361             children=[
362                 self._add_ctrl_panel(),
363             ],
364         )
365
366     def _add_plotting_col(self) -> dbc.Col:
367         """Add column with tables. It is placed on the right side.
368
369         :returns: Column with tables.
370         :rtype: dbc.Col
371         """
372
373         return dbc.Col(
374             id="col-plotting-area",
375             children=[
376                 dbc.Row(  # Failed tests
377                     id="row-table-failed",
378                     class_name="g-0 p-2",
379                     children=self._default_tab_failed
380                 ),
381                 dbc.Row(
382                     class_name="g-0 p-2",
383                     align="center",
384                     justify="start",
385                     children=[
386                         dbc.InputGroup(
387                             class_name="me-1",
388                             children=[
389                                 dbc.InputGroupText(
390                                     style=C.URL_STYLE,
391                                     children=show_tooltip(
392                                         self._tooltips,
393                                         "help-url", "URL",
394                                         "input-url"
395                                     )
396                                 ),
397                                 dbc.Input(
398                                     id="input-url",
399                                     readonly=True,
400                                     type="url",
401                                     style=C.URL_STYLE,
402                                     value=""
403                                 )
404                             ]
405                         )
406                     ]
407                 )
408             ],
409             width=9,
410         )
411
412     def _add_ctrl_panel(self) -> dbc.Row:
413         """Add control panel.
414
415         :returns: Control panel.
416         :rtype: dbc.Row
417         """
418         return dbc.Row(
419             id="row-ctrl-panel",
420             class_name="g-0",
421             children=[
422                 dbc.Row(
423                     class_name="g-0 p-2",
424                     children=[
425                         dbc.Row(
426                             class_name="gy-1",
427                             children=[
428                                 dbc.Label(
429                                     class_name="p-0",
430                                     children=show_tooltip(self._tooltips,
431                                         "help-dut", "Device under Test")
432                                 ),
433                                 dbc.Row(
434                                     dbc.RadioItems(
435                                         id="ri-duts",
436                                         inline=True,
437                                         value=self.default["dut"],
438                                         options=self.default["duts"]
439                                     )
440                                 )
441                             ]
442                         ),
443                         dbc.Row(
444                             class_name="gy-1",
445                             children=[
446                                 dbc.Label(
447                                     class_name="p-0",
448                                     children=show_tooltip(self._tooltips,
449                                         "help-ttype", "Test Type"),
450                                 ),
451                                 dbc.RadioItems(
452                                     id="ri-ttypes",
453                                     inline=True,
454                                     value=self.default["ttype"],
455                                     options=self.default["ttypes"]
456                                 )
457                             ]
458                         ),
459                         dbc.Row(
460                             class_name="gy-1",
461                             children=[
462                                 dbc.Label(
463                                     class_name="p-0",
464                                     children=show_tooltip(self._tooltips,
465                                         "help-cadence", "Cadence"),
466                                 ),
467                                 dbc.RadioItems(
468                                     id="ri-cadences",
469                                     inline=True,
470                                     value=self.default["cadence"],
471                                     options=self.default["cadences"]
472                                 )
473                             ]
474                         ),
475                         dbc.Row(
476                             class_name="gy-1",
477                             children=[
478                                 dbc.Label(
479                                     class_name="p-0",
480                                     children=show_tooltip(self._tooltips,
481                                         "help-tbed", "Test Bed"),
482                                 ),
483                                 dbc.Select(
484                                     id="dd-tbeds",
485                                     placeholder="Select a test bed...",
486                                     value=self.default["tbed"],
487                                     options=self.default["tbeds"]
488                                 )
489                             ]
490                         ),
491                         dbc.Row(
492                             class_name="gy-1 p-0",
493                             children=[
494                                 dbc.ButtonGroup(
495                                     [
496                                         dbc.Button(
497                                             id="btn-summary",
498                                             children=(
499                                                 f"Show Summary from the last "
500                                                 f"{C.NEWS_SUMMARY_PERIOD} Days"
501                                             ),
502                                             class_name="me-1",
503                                             color="info"
504                                         )
505                                     ],
506                                     size="md",
507                                 )
508                             ]
509                         ),
510                         dbc.Row(
511                             class_name="gy-1",
512                             children=[
513                                 dbc.Alert(
514                                     id="al-job",
515                                     color="info",
516                                     children=self.default["job"]
517                                 )
518                             ]
519                         )
520                     ]
521                 )
522             ]
523         )
524
525     class ControlPanel:
526         """A class representing the control panel.
527         """
528
529         def __init__(self, panel: dict, default: dict) -> None:
530             """Initialisation of the control pannel by default values. If
531             particular values are provided (parameter "panel") they are set
532             afterwards.
533
534             :param panel: Custom values to be set to the control panel.
535             :param default: Default values to be set to the control panel.
536             :type panel: dict
537             :type defaults: dict
538             """
539
540             self._defaults = {
541                 "ri-ttypes-options": default["ttypes"],
542                 "ri-cadences-options": default["cadences"],
543                 "dd-tbeds-options": default["tbeds"],
544                 "ri-duts-value": default["dut"],
545                 "ri-ttypes-value": default["ttype"],
546                 "ri-cadences-value": default["cadence"],
547                 "dd-tbeds-value": default["tbed"],
548                 "al-job-children": default["job"]
549             }
550             self._panel = deepcopy(self._defaults)
551             if panel:
552                 for key in self._defaults:
553                     self._panel[key] = panel[key]
554
555         def set(self, kwargs: dict) -> None:
556             """Set the values of the Control panel.
557
558             :param kwargs: key - value pairs to be set.
559             :type kwargs: dict
560             :raises KeyError: If the key in kwargs is not present in the Control
561                 panel.
562             """
563             for key, val in kwargs.items():
564                 if key in self._panel:
565                     self._panel[key] = val
566                 else:
567                     raise KeyError(f"The key {key} is not defined.")
568
569         @property
570         def defaults(self) -> dict:
571             return self._defaults
572
573         @property
574         def panel(self) -> dict:
575             return self._panel
576
577         def get(self, key: str) -> any:
578             """Returns the value of a key from the Control panel.
579
580             :param key: The key which value should be returned.
581             :type key: str
582             :returns: The value of the key.
583             :rtype: any
584             :raises KeyError: If the key in kwargs is not present in the Control
585                 panel.
586             """
587             return self._panel[key]
588
589         def values(self) -> list:
590             """Returns the values from the Control panel as a list.
591
592             :returns: The values from the Control panel.
593             :rtype: list
594             """
595             return list(self._panel.values())
596
597     def callbacks(self, app):
598         """Callbacks for the whole application.
599
600         :param app: The application.
601         :type app: Flask
602         """
603
604         @app.callback(
605             Output("control-panel", "data"),  # Store
606             Output("row-table-failed", "children"),
607             Output("input-url", "value"),
608             Output("ri-ttypes", "options"),
609             Output("ri-cadences", "options"),
610             Output("dd-tbeds", "options"),
611             Output("ri-duts", "value"),
612             Output("ri-ttypes", "value"),
613             Output("ri-cadences", "value"),
614             Output("dd-tbeds", "value"),
615             Output("al-job", "children"),
616             State("control-panel", "data"),  # Store
617             Input("ri-duts", "value"),
618             Input("ri-ttypes", "value"),
619             Input("ri-cadences", "value"),
620             Input("dd-tbeds", "value"),
621             Input("url", "href"),
622             Input("btn-summary", "n_clicks")
623         )
624         def _update_application(cp_data: dict, dut: str, ttype: str,
625                 cadence:str, tbed: str, href: str, btn_all: int) -> tuple:
626             """Update the application when the event is detected.
627
628             :param cp_data: Current status of the control panel stored in
629                 browser.
630             :param dut: Input - DUT name.
631             :param ttype: Input - Test type.
632             :param cadence: Input - The cadence of the job.
633             :param tbed: Input - The test bed.
634             :param href: Input - The URL provided by the browser.
635             :type cp_data: dict
636             :type dut: str
637             :type ttype: str
638             :type cadence: str
639             :type tbed: str
640             :type href: str
641             :returns: New values for web page elements.
642             :rtype: tuple
643             """
644
645             ctrl_panel = self.ControlPanel(cp_data, self.default)
646
647             # Parse the url:
648             parsed_url = url_decode(href)
649             if parsed_url:
650                 url_params = parsed_url["params"]
651             else:
652                 url_params = None
653
654             show_summary = False
655
656             trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
657             if trigger_id == "ri-duts":
658                 ttype_opts = generate_options(get_ttypes(self.job_info, dut))
659                 ttype_val = ttype_opts[0]["value"]
660                 cad_opts = generate_options(
661                     get_cadences(self.job_info, dut, ttype_val))
662                 cad_val = cad_opts[0]["value"]
663                 tbed_opts = generate_options(get_test_beds(
664                     self.job_info, dut, ttype_val, cad_val))
665                 tbed_val = tbed_opts[0]["value"]
666                 ctrl_panel.set({
667                     "ri-duts-value": dut,
668                     "ri-ttypes-options": ttype_opts,
669                     "ri-ttypes-value": ttype_val,
670                     "ri-cadences-options": cad_opts,
671                     "ri-cadences-value": cad_val,
672                     "dd-tbeds-options": tbed_opts,
673                     "dd-tbeds-value": tbed_val
674                 })
675             elif trigger_id == "ri-ttypes":
676                 cad_opts = generate_options(get_cadences(
677                     self.job_info, ctrl_panel.get("ri-duts-value"), ttype))
678                 cad_val = cad_opts[0]["value"]
679                 tbed_opts = generate_options(get_test_beds(
680                     self.job_info, ctrl_panel.get("ri-duts-value"),
681                     ttype, cad_val))
682                 tbed_val = tbed_opts[0]["value"]
683                 ctrl_panel.set({
684                     "ri-ttypes-value": ttype,
685                     "ri-cadences-options": cad_opts,
686                     "ri-cadences-value": cad_val,
687                     "dd-tbeds-options": tbed_opts,
688                     "dd-tbeds-value": tbed_val
689                 })
690             elif trigger_id == "ri-cadences":
691                 tbed_opts = generate_options(get_test_beds(
692                     self.job_info, ctrl_panel.get("ri-duts-value"),
693                     ctrl_panel.get("ri-ttypes-value"), cadence))
694                 tbed_val = tbed_opts[0]["value"]
695                 ctrl_panel.set({
696                     "ri-cadences-value": cadence,
697                     "dd-tbeds-options": tbed_opts,
698                     "dd-tbeds-value": tbed_val
699                 })
700             elif trigger_id == "dd-tbeds":
701                 ctrl_panel.set({
702                     "dd-tbeds-value": tbed
703                 })
704             elif trigger_id == "url":
705                 # TODO: Add verification
706                 if url_params:
707                     new_job = url_params.get("job", list())[0]
708                     if new_job and new_job != "all":
709                         job_params = set_job_params(self.job_info, new_job)
710                         ctrl_panel = self.ControlPanel(None, job_params)
711                     if new_job and new_job == "all":
712                         show_summary = True
713                 else:
714                     ctrl_panel = self.ControlPanel(cp_data, self.default)
715             elif trigger_id == "btn-summary":
716                 show_summary = True
717
718             if show_summary:
719                 ctrl_panel.set({
720                     "al-job-children": \
721                         f"Summary from the last {C.NEWS_SUMMARY_PERIOD} days"
722                 })
723                 job = "all"
724                 tables = table_summary(self.data, self._jobs)
725             else:
726                 job = get_job(
727                     self.job_info,
728                     ctrl_panel.get("ri-duts-value"),
729                     ctrl_panel.get("ri-ttypes-value"),
730                     ctrl_panel.get("ri-cadences-value"),
731                     ctrl_panel.get("dd-tbeds-value")
732                 )
733                 ctrl_panel.set({"al-job-children": job})
734                 tables = table_news(self.data, job, C.NEWS_TIME_PERIOD)
735
736             ret_val = [
737                 ctrl_panel.panel,
738                 tables,
739                 gen_new_url(parsed_url, {"job": job})
740             ]
741             ret_val.extend(ctrl_panel.values())
742             return ret_val