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:
6 # http://www.apache.org/licenses/LICENSE-2.0
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.
14 """Plotly Dash HTML layout override.
19 import dash_bootstrap_components as dbc
21 from flask import Flask
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
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, \
34 from ..utils.url_processing import url_decode
35 from ..data.data import Data
36 from .tables import table_news, table_summary
40 """The layout of the dash app and the callbacks.
43 def __init__(self, app: Flask, html_layout_file: str, data_spec_file: str,
44 tooltip_file: str) -> None:
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.
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
60 :type html_layout_file: str
61 :type data_spec_file: str
62 :type tooltip_file: str
67 self._html_layout_file = html_layout_file
68 self._data_spec_file = data_spec_file
69 self._tooltip_file = tooltip_file
72 data_stats, data_mrr, data_ndrpdr = Data(
73 data_spec_file=self._data_spec_file,
75 ).read_stats(days=C.NEWS_TIME_PERIOD)
77 df_tst_info = pd.concat([data_mrr, data_ndrpdr], ignore_index=True)
79 # Prepare information for the control panel:
80 self._jobs = sorted(list(df_tst_info["job"].unique()))
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)
97 self._default = set_job_params(self.job_info, C.NEWS_DEFAULT_JOB)
99 # Pre-process the data:
101 def _create_test_name(test: str) -> str:
102 lst_tst = test.split(".")
103 suite = lst_tst[-2].replace("2n1l-", "").replace("1n1l-", "").\
105 return f"{suite.split('-')[0]}-{lst_tst[-1]}"
107 def _get_rindex(array: list, itm: any) -> int:
108 return len(array) - 1 - array[::-1].index(itm)
115 "dut_version": list(),
118 "regressions": list(),
119 "progressions": list()
121 logging.debug("Processing jobs ...")
122 for job in self._jobs:
123 logging.debug(f"+ {job}")
124 # Create lists of failed tests:
125 df_job = df_tst_info.loc[(df_tst_info["job"] == job)]
126 last_build = max(df_job["build"].unique())
127 df_build = df_job.loc[(df_job["build"] == last_build)]
128 tst_info["job"].append(job)
129 tst_info["build"].append(last_build)
130 tst_info["start"].append(data_stats.loc[
131 (data_stats["job"] == job) &
132 (data_stats["build"] == last_build)
133 ]["start_time"].iloc[-1].strftime('%Y-%m-%d %H:%M'))
134 tst_info["dut_type"].append(df_build["dut_type"].iloc[-1])
135 tst_info["dut_version"].append(df_build["dut_version"].iloc[-1])
136 tst_info["hosts"].append(df_build["hosts"].iloc[-1])
137 failed_tests = df_build.loc[(df_build["passed"] == False)]\
138 ["test_id"].to_list()
141 for tst in failed_tests:
142 l_failed.append(_create_test_name(tst))
145 tst_info["failed"].append(sorted(l_failed))
147 # Create lists of regressions and progressions:
151 tests = df_job["test_id"].unique()
153 tst_data = df_job.loc[df_job["test_id"] == test].sort_values(
154 by="start_time", ignore_index=True)
155 x_axis = tst_data["start_time"].tolist()
156 if "-ndrpdr" in test:
157 tst_data = tst_data.dropna(
158 subset=["result_pdr_lower_rate_value", ]
163 anomalies, _, _ = classify_anomalies({
164 k: v for k, v in zip(
166 tst_data["result_ndr_lower_rate_value"].tolist()
171 if "progression" in anomalies:
173 _create_test_name(test).replace("-ndrpdr", "-ndr"),
174 x_axis[_get_rindex(anomalies, "progression")]
176 if "regression" in anomalies:
178 _create_test_name(test).replace("-ndrpdr", "-ndr"),
179 x_axis[_get_rindex(anomalies, "regression")]
182 anomalies, _, _ = classify_anomalies({
183 k: v for k, v in zip(
185 tst_data["result_pdr_lower_rate_value"].tolist()
190 if "progression" in anomalies:
192 _create_test_name(test).replace("-ndrpdr", "-pdr"),
193 x_axis[_get_rindex(anomalies, "progression")]
195 if "regression" in anomalies:
197 _create_test_name(test).replace("-ndrpdr", "-pdr"),
198 x_axis[_get_rindex(anomalies, "regression")]
201 tst_data = tst_data.dropna(
202 subset=["result_receive_rate_rate_avg", ]
207 anomalies, _, _ = classify_anomalies({
208 k: v for k, v in zip(
210 tst_data["result_receive_rate_rate_avg"].\
216 if "progression" in anomalies:
218 _create_test_name(test),
219 x_axis[_get_rindex(anomalies, "progression")]
221 if "regression" in anomalies:
223 _create_test_name(test),
224 x_axis[_get_rindex(anomalies, "regression")]
227 tst_info["regressions"].append(
228 sorted(l_reg, key=lambda k: k[1], reverse=True))
229 tst_info["progressions"].append(
230 sorted(l_prog, key=lambda k: k[1], reverse=True))
232 self._data = pd.DataFrame.from_dict(tst_info)
235 self._html_layout = str()
236 self._tooltips = dict()
239 with open(self._html_layout_file, "r") as file_read:
240 self._html_layout = file_read.read()
241 except IOError as err:
243 f"Not possible to open the file {self._html_layout_file}\n{err}"
247 with open(self._tooltip_file, "r") as file_read:
248 self._tooltips = load(file_read, Loader=FullLoader)
249 except IOError as err:
251 f"Not possible to open the file {self._tooltip_file}\n{err}"
253 except YAMLError as err:
255 f"An error occurred while parsing the specification file "
256 f"{self._tooltip_file}\n{err}"
259 self._default_tab_failed = \
260 table_news(self.data, self._default["job"], C.NEWS_TIME_PERIOD)
263 if self._app is not None and hasattr(self, 'callbacks'):
264 self.callbacks(self._app)
267 def html_layout(self) -> dict:
268 return self._html_layout
271 def data(self) -> pd.DataFrame:
275 def default(self) -> dict:
278 def add_content(self):
279 """Top level method which generated the web page.
282 - Store for user input data,
284 - Main area with control panel and ploting area.
286 If no HTML layout is provided, an error message is displayed instead.
288 :returns: The HTML div with the whole page.
296 dcc.Store(id="control-panel"),
297 dcc.Location(id="url", refresh=False),
309 self._add_ctrl_col(),
310 self._add_plotting_col(),
328 def _add_navbar(self):
329 """Add nav element with navigation panel. It is placed on the top.
331 :returns: Navigation bar.
332 :rtype: dbc.NavbarSimple
335 return dbc.NavbarSimple(
336 id="navbarsimple-main",
340 "Continuous Performance News",
349 brand_external_link=True,
354 def _add_ctrl_col(self) -> dbc.Col:
355 """Add column with control panel. It is placed on the left side.
357 :returns: Column with the control panel.
364 self._add_ctrl_panel(),
368 def _add_plotting_col(self) -> dbc.Col:
369 """Add column with tables. It is placed on the right side.
371 :returns: Column with tables.
376 id="col-plotting-area",
378 dbc.Row( # Failed tests
379 id="row-table-failed",
380 class_name="g-0 p-2",
381 children=self._default_tab_failed
384 class_name="g-0 p-2",
393 children=show_tooltip(
414 def _add_ctrl_panel(self) -> dbc.Row:
415 """Add control panel.
417 :returns: Control panel.
425 class_name="g-0 p-2",
432 children=show_tooltip(self._tooltips,
433 "help-dut", "Device under Test")
439 value=self.default["dut"],
440 options=self.default["duts"]
450 children=show_tooltip(self._tooltips,
451 "help-ttype", "Test Type"),
456 value=self.default["ttype"],
457 options=self.default["ttypes"]
466 children=show_tooltip(self._tooltips,
467 "help-cadence", "Cadence"),
472 value=self.default["cadence"],
473 options=self.default["cadences"]
482 children=show_tooltip(self._tooltips,
483 "help-tbed", "Test Bed"),
487 placeholder="Select a test bed...",
488 value=self.default["tbed"],
489 options=self.default["tbeds"]
494 class_name="gy-1 p-0",
501 f"Show Summary from the last "
502 f"{C.NEWS_SUMMARY_PERIOD} Days"
518 children=self.default["job"]
528 """A class representing the control panel.
531 def __init__(self, panel: dict, default: dict) -> None:
532 """Initialisation of the control pannel by default values. If
533 particular values are provided (parameter "panel") they are set
536 :param panel: Custom values to be set to the control panel.
537 :param default: Default values to be set to the control panel.
543 "ri-ttypes-options": default["ttypes"],
544 "ri-cadences-options": default["cadences"],
545 "dd-tbeds-options": default["tbeds"],
546 "ri-duts-value": default["dut"],
547 "ri-ttypes-value": default["ttype"],
548 "ri-cadences-value": default["cadence"],
549 "dd-tbeds-value": default["tbed"],
550 "al-job-children": default["job"]
552 self._panel = deepcopy(self._defaults)
554 for key in self._defaults:
555 self._panel[key] = panel[key]
557 def set(self, kwargs: dict) -> None:
558 """Set the values of the Control panel.
560 :param kwargs: key - value pairs to be set.
562 :raises KeyError: If the key in kwargs is not present in the Control
565 for key, val in kwargs.items():
566 if key in self._panel:
567 self._panel[key] = val
569 raise KeyError(f"The key {key} is not defined.")
572 def defaults(self) -> dict:
573 return self._defaults
576 def panel(self) -> dict:
579 def get(self, key: str) -> any:
580 """Returns the value of a key from the Control panel.
582 :param key: The key which value should be returned.
584 :returns: The value of the key.
586 :raises KeyError: If the key in kwargs is not present in the Control
589 return self._panel[key]
591 def values(self) -> list:
592 """Returns the values from the Control panel as a list.
594 :returns: The values from the Control panel.
597 return list(self._panel.values())
599 def callbacks(self, app):
600 """Callbacks for the whole application.
602 :param app: The application.
607 Output("control-panel", "data"), # Store
608 Output("row-table-failed", "children"),
609 Output("input-url", "value"),
610 Output("ri-ttypes", "options"),
611 Output("ri-cadences", "options"),
612 Output("dd-tbeds", "options"),
613 Output("ri-duts", "value"),
614 Output("ri-ttypes", "value"),
615 Output("ri-cadences", "value"),
616 Output("dd-tbeds", "value"),
617 Output("al-job", "children"),
618 State("control-panel", "data"), # Store
619 Input("ri-duts", "value"),
620 Input("ri-ttypes", "value"),
621 Input("ri-cadences", "value"),
622 Input("dd-tbeds", "value"),
623 Input("url", "href"),
624 Input("btn-summary", "n_clicks")
626 def _update_application(cp_data: dict, dut: str, ttype: str,
627 cadence:str, tbed: str, href: str, btn_all: int) -> tuple:
628 """Update the application when the event is detected.
630 :param cp_data: Current status of the control panel stored in
632 :param dut: Input - DUT name.
633 :param ttype: Input - Test type.
634 :param cadence: Input - The cadence of the job.
635 :param tbed: Input - The test bed.
636 :param href: Input - The URL provided by the browser.
643 :returns: New values for web page elements.
647 ctrl_panel = self.ControlPanel(cp_data, self.default)
650 parsed_url = url_decode(href)
652 url_params = parsed_url["params"]
658 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
659 if trigger_id == "ri-duts":
660 ttype_opts = generate_options(get_ttypes(self.job_info, dut))
661 ttype_val = ttype_opts[0]["value"]
662 cad_opts = generate_options(
663 get_cadences(self.job_info, dut, ttype_val))
664 cad_val = cad_opts[0]["value"]
665 tbed_opts = generate_options(get_test_beds(
666 self.job_info, dut, ttype_val, cad_val))
667 tbed_val = tbed_opts[0]["value"]
669 "ri-duts-value": dut,
670 "ri-ttypes-options": ttype_opts,
671 "ri-ttypes-value": ttype_val,
672 "ri-cadences-options": cad_opts,
673 "ri-cadences-value": cad_val,
674 "dd-tbeds-options": tbed_opts,
675 "dd-tbeds-value": tbed_val
677 elif trigger_id == "ri-ttypes":
678 cad_opts = generate_options(get_cadences(
679 self.job_info, ctrl_panel.get("ri-duts-value"), ttype))
680 cad_val = cad_opts[0]["value"]
681 tbed_opts = generate_options(get_test_beds(
682 self.job_info, ctrl_panel.get("ri-duts-value"),
684 tbed_val = tbed_opts[0]["value"]
686 "ri-ttypes-value": ttype,
687 "ri-cadences-options": cad_opts,
688 "ri-cadences-value": cad_val,
689 "dd-tbeds-options": tbed_opts,
690 "dd-tbeds-value": tbed_val
692 elif trigger_id == "ri-cadences":
693 tbed_opts = generate_options(get_test_beds(
694 self.job_info, ctrl_panel.get("ri-duts-value"),
695 ctrl_panel.get("ri-ttypes-value"), cadence))
696 tbed_val = tbed_opts[0]["value"]
698 "ri-cadences-value": cadence,
699 "dd-tbeds-options": tbed_opts,
700 "dd-tbeds-value": tbed_val
702 elif trigger_id == "dd-tbeds":
704 "dd-tbeds-value": tbed
706 elif trigger_id == "url":
707 # TODO: Add verification
709 new_job = url_params.get("job", list())[0]
710 if new_job and new_job != "all":
711 job_params = set_job_params(self.job_info, new_job)
712 ctrl_panel = self.ControlPanel(None, job_params)
713 if new_job and new_job == "all":
716 ctrl_panel = self.ControlPanel(cp_data, self.default)
717 elif trigger_id == "btn-summary":
723 f"Summary from the last {C.NEWS_SUMMARY_PERIOD} days"
726 tables = table_summary(self.data, self._jobs)
730 ctrl_panel.get("ri-duts-value"),
731 ctrl_panel.get("ri-ttypes-value"),
732 ctrl_panel.get("ri-cadences-value"),
733 ctrl_panel.get("dd-tbeds-value")
735 ctrl_panel.set({"al-job-children": job})
736 tables = table_news(self.data, job, C.NEWS_TIME_PERIOD)
741 gen_new_url(parsed_url, {"job": job})
743 ret_val.extend(ctrl_panel.values())