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 ..data.utils import classify_anomalies
31 from .tables import table_news
35 """The layout of the dash app and the callbacks.
38 # The default job displayed when the page is loaded first time.
39 DEFAULT_JOB = "csit-vpp-perf-mrr-daily-master-2n-icx"
41 # Time period for regressions and progressions.
42 TIME_PERIOD = 21 # [days]
44 def __init__(self, app: Flask, html_layout_file: str, data_spec_file: str,
45 tooltip_file: str) -> None:
47 - save the input parameters,
48 - read and pre-process the data,
49 - prepare data fro the control panel,
50 - read HTML layout file,
51 - read tooltips from the tooltip file.
53 :param app: Flask application running the dash application.
54 :param html_layout_file: Path and name of the file specifying the HTML
55 layout of the dash application.
56 :param data_spec_file: Path and name of the file specifying the data to
57 be read from parquets for this application.
58 :param tooltip_file: Path and name of the yaml file specifying the
61 :type html_layout_file: str
62 :type data_spec_file: str
63 :type tooltip_file: str
68 self._html_layout_file = html_layout_file
69 self._data_spec_file = data_spec_file
70 self._tooltip_file = tooltip_file
73 data_stats, data_mrr, data_ndrpdr = Data(
74 data_spec_file=self._data_spec_file,
76 ).read_stats(days=self.TIME_PERIOD)
78 df_tst_info = pd.concat([data_mrr, data_ndrpdr], ignore_index=True)
80 # Prepare information for the control panel:
81 jobs = sorted(list(df_tst_info["job"].unique()))
90 lst_job = job.split("-")
91 job_info["job"].append(job)
92 job_info["dut"].append(lst_job[1])
93 job_info["ttype"].append(lst_job[3])
94 job_info["cadence"].append(lst_job[4])
95 job_info["tbed"].append("-".join(lst_job[-2:]))
96 self.df_job_info = pd.DataFrame.from_dict(job_info)
98 self._default = self._set_job_params(self.DEFAULT_JOB)
100 # Pre-process the data:
102 def _create_test_name(test: str) -> str:
103 lst_tst = test.split(".")
104 suite = lst_tst[-2].replace("2n1l-", "").replace("1n1l-", "").\
106 return f"{suite.split('-')[0]}-{lst_tst[-1]}"
108 def _get_rindex(array: list, itm: any) -> int:
109 return len(array) - 1 - array[::-1].index(itm)
116 "dut_version": list(),
119 "regressions": list(),
120 "progressions": list()
123 # Create lists of failed tests:
124 df_job = df_tst_info.loc[(df_tst_info["job"] == job)]
125 last_build = max(df_job["build"].unique())
126 df_build = df_job.loc[(df_job["build"] == last_build)]
127 tst_info["job"].append(job)
128 tst_info["build"].append(last_build)
129 tst_info["start"].append(data_stats.loc[
130 (data_stats["job"] == job) &
131 (data_stats["build"] == last_build)
132 ]["start_time"].iloc[-1].strftime('%Y-%m-%d %H:%M'))
133 tst_info["dut_type"].append(df_build["dut_type"].iloc[-1])
134 tst_info["dut_version"].append(df_build["dut_version"].iloc[-1])
135 tst_info["hosts"].append(df_build["hosts"].iloc[-1])
136 failed_tests = df_build.loc[(df_build["passed"] == False)]\
137 ["test_id"].to_list()
140 for tst in failed_tests:
141 l_failed.append(_create_test_name(tst))
144 tst_info["failed"].append(sorted(l_failed))
146 # Create lists of regressions and progressions:
150 tests = df_job["test_id"].unique()
152 tst_data = df_job.loc[df_job["test_id"] == test].sort_values(
153 by="start_time", ignore_index=True)
154 x_axis = tst_data["start_time"].tolist()
155 if "-ndrpdr" in test:
156 tst_data = tst_data.dropna(
157 subset=["result_pdr_lower_rate_value", ]
162 anomalies, _, _ = classify_anomalies({
163 k: v for k, v in zip(
165 tst_data["result_ndr_lower_rate_value"].tolist()
170 if "progression" in anomalies:
172 _create_test_name(test).replace("-ndrpdr", "-ndr"),
173 x_axis[_get_rindex(anomalies, "progression")]
175 if "regression" in anomalies:
177 _create_test_name(test).replace("-ndrpdr", "-ndr"),
178 x_axis[_get_rindex(anomalies, "regression")]
181 anomalies, _, _ = classify_anomalies({
182 k: v for k, v in zip(
184 tst_data["result_pdr_lower_rate_value"].tolist()
189 if "progression" in anomalies:
191 _create_test_name(test).replace("-ndrpdr", "-pdr"),
192 x_axis[_get_rindex(anomalies, "progression")]
194 if "regression" in anomalies:
196 _create_test_name(test).replace("-ndrpdr", "-pdr"),
197 x_axis[_get_rindex(anomalies, "regression")]
200 tst_data = tst_data.dropna(
201 subset=["result_receive_rate_rate_avg", ]
206 anomalies, _, _ = classify_anomalies({
207 k: v for k, v in zip(
209 tst_data["result_receive_rate_rate_avg"].\
215 if "progression" in anomalies:
217 _create_test_name(test),
218 x_axis[_get_rindex(anomalies, "progression")]
220 if "regression" in anomalies:
222 _create_test_name(test),
223 x_axis[_get_rindex(anomalies, "regression")]
226 tst_info["regressions"].append(
227 sorted(l_reg, key=lambda k: k[1], reverse=True))
228 tst_info["progressions"].append(
229 sorted(l_prog, key=lambda k: k[1], reverse=True))
231 self._data = pd.DataFrame.from_dict(tst_info)
234 self._html_layout = str()
235 self._tooltips = dict()
238 with open(self._html_layout_file, "r") as file_read:
239 self._html_layout = file_read.read()
240 except IOError as err:
242 f"Not possible to open the file {self._html_layout_file}\n{err}"
246 with open(self._tooltip_file, "r") as file_read:
247 self._tooltips = load(file_read, Loader=FullLoader)
248 except IOError as err:
250 f"Not possible to open the file {self._tooltip_file}\n{err}"
252 except YAMLError as err:
254 f"An error occurred while parsing the specification file "
255 f"{self._tooltip_file}\n{err}"
258 self._default_tab_failed = table_news(self.data, self._default["job"])
261 if self._app is not None and hasattr(self, 'callbacks'):
262 self.callbacks(self._app)
265 def html_layout(self) -> dict:
266 return self._html_layout
269 def data(self) -> pd.DataFrame:
273 def default(self) -> dict:
276 def _get_duts(self) -> list:
277 """Get the list of DUTs from the pre-processed information about jobs.
279 :returns: Alphabeticaly sorted list of DUTs.
282 return sorted(list(self.df_job_info["dut"].unique()))
284 def _get_ttypes(self, dut: str) -> list:
285 """Get the list of test types from the pre-processed information about
288 :param dut: The DUT for which the list of test types will be populated.
290 :returns: Alphabeticaly sorted list of test types.
293 return sorted(list(self.df_job_info.loc[(
294 self.df_job_info["dut"] == dut
295 )]["ttype"].unique()))
297 def _get_cadences(self, dut: str, ttype: str) -> list:
298 """Get the list of cadences from the pre-processed information about
301 :param dut: The DUT for which the list of cadences will be populated.
302 :param ttype: The test type for which the list of cadences will be
306 :returns: Alphabeticaly sorted list of cadences.
309 return sorted(list(self.df_job_info.loc[(
310 (self.df_job_info["dut"] == dut) &
311 (self.df_job_info["ttype"] == ttype)
312 )]["cadence"].unique()))
314 def _get_test_beds(self, dut: str, ttype: str, cadence: str) -> list:
315 """Get the list of test beds from the pre-processed information about
318 :param dut: The DUT for which the list of test beds will be populated.
319 :param ttype: The test type for which the list of test beds will be
321 :param cadence: The cadence for which the list of test beds will be
326 :returns: Alphabeticaly sorted list of test beds.
329 return sorted(list(self.df_job_info.loc[(
330 (self.df_job_info["dut"] == dut) &
331 (self.df_job_info["ttype"] == ttype) &
332 (self.df_job_info["cadence"] == cadence)
333 )]["tbed"].unique()))
335 def _get_job(self, dut, ttype, cadence, testbed):
336 """Get the name of a job defined by dut, ttype, cadence, test bed.
337 Input information comes from the control panel.
339 :param dut: The DUT for which the job name will be created.
340 :param ttype: The test type for which the job name will be created.
341 :param cadence: The cadence for which the job name will be created.
342 :param testbed: The test bed for which the job name will be created.
350 return self.df_job_info.loc[(
351 (self.df_job_info["dut"] == dut) &
352 (self.df_job_info["ttype"] == ttype) &
353 (self.df_job_info["cadence"] == cadence) &
354 (self.df_job_info["tbed"] == testbed)
358 def _generate_options(opts: list) -> list:
359 """Return list of options for radio items in control panel. The items in
360 the list are dictionaries with keys "label" and "value".
362 :params opts: List of options (str) to be used for the generated list.
364 :returns: List of options (dict).
367 return [{"label": i, "value": i} for i in opts]
369 def _set_job_params(self, job: str) -> dict:
370 """Create a dictionary with all options and values for (and from) the
373 :params job: The name of job for and from which the dictionary will be
376 :returns: Dictionary with all options and values for (and from) the
381 lst_job = job.split("-")
386 "cadence": lst_job[4],
387 "tbed": "-".join(lst_job[-2:]),
388 "duts": self._generate_options(self._get_duts()),
389 "ttypes": self._generate_options(self._get_ttypes(lst_job[1])),
390 "cadences": self._generate_options(self._get_cadences(
391 lst_job[1], lst_job[3])),
392 "tbeds": self._generate_options(self._get_test_beds(
393 lst_job[1], lst_job[3], lst_job[4]))
396 def _show_tooltip(self, id: str, title: str,
397 clipboard_id: str=None) -> list:
398 """Generate list of elements to display a text (e.g. a title) with a
399 tooltip and optionaly with Copy&Paste icon and the clipboard
400 functionality enabled.
402 :param id: Tooltip ID.
403 :param title: A text for which the tooltip will be displayed.
404 :param clipboard_id: If defined, a Copy&Paste icon is displayed and the
405 clipboard functionality is enabled.
408 :type clipboard_id: str
409 :returns: List of elements to display a text with a tooltip and
410 optionaly with Copy&Paste icon.
415 dcc.Clipboard(target_id=clipboard_id, title="Copy URL") \
416 if clipboard_id else str(),
424 class_name="border ms-1",
427 children=self._tooltips.get(id, str()),
433 def add_content(self):
434 """Top level method which generated the web page.
437 - Store for user input data,
439 - Main area with control panel and ploting area.
441 If no HTML layout is provided, an error message is displayed instead.
443 :returns: The HTML div with teh whole page.
451 dcc.Store(id="control-panel"),
463 self._add_ctrl_col(),
464 self._add_plotting_col(),
482 def _add_navbar(self):
483 """Add nav element with navigation panel. It is placed on the top.
485 :returns: Navigation bar.
486 :rtype: dbc.NavbarSimple
489 return dbc.NavbarSimple(
490 id="navbarsimple-main",
494 "Continuous Performance News",
503 brand_external_link=True,
508 def _add_ctrl_col(self) -> dbc.Col:
509 """Add column with control panel. It is placed on the left side.
511 :returns: Column with the control panel.
518 self._add_ctrl_panel(),
522 def _add_plotting_col(self) -> dbc.Col:
523 """Add column with tables. It is placed on the right side.
525 :returns: Column with tables.
530 id="col-plotting-area",
532 dbc.Row( # Failed tests
533 id="row-table-failed",
534 class_name="g-0 p-2",
535 children=self._default_tab_failed
541 def _add_ctrl_panel(self) -> dbc.Row:
542 """Add control panel.
544 :returns: Control panel.
552 class_name="g-0 p-2",
559 children=self._show_tooltip(
560 "help-dut", "Device under Test")
566 value=self.default["dut"],
567 options=self.default["duts"]
577 children=self._show_tooltip(
578 "help-ttype", "Test Type"),
583 value=self.default["ttype"],
584 options=self.default["ttypes"]
593 children=self._show_tooltip(
594 "help-cadence", "Cadence"),
599 value=self.default["cadence"],
600 options=self.default["cadences"]
609 children=self._show_tooltip(
610 "help-tbed", "Test Bed"),
614 placeholder="Select a test bed...",
615 value=self.default["tbed"],
616 options=self.default["tbeds"]
626 children=self.default["job"]
639 def __init__(self, panel: dict, default: dict) -> None:
644 "ri-ttypes-options": default["ttypes"],
645 "ri-cadences-options": default["cadences"],
646 "dd-tbeds-options": default["tbeds"],
647 "ri-duts-value": default["dut"],
648 "ri-ttypes-value": default["ttype"],
649 "ri-cadences-value": default["cadence"],
650 "dd-tbeds-value": default["tbed"],
651 "al-job-children": default["job"]
653 self._panel = deepcopy(self._defaults)
655 for key in self._defaults:
656 self._panel[key] = panel[key]
658 def set(self, kwargs: dict) -> None:
659 for key, val in kwargs.items():
660 if key in self._panel:
661 self._panel[key] = val
663 raise KeyError(f"The key {key} is not defined.")
666 def defaults(self) -> dict:
667 return self._defaults
670 def panel(self) -> dict:
673 def get(self, key: str) -> any:
674 return self._panel[key]
676 def values(self) -> list:
677 return list(self._panel.values())
679 def callbacks(self, app):
682 Output("control-panel", "data"), # Store
683 Output("row-table-failed", "children"),
684 Output("ri-ttypes", "options"),
685 Output("ri-cadences", "options"),
686 Output("dd-tbeds", "options"),
687 Output("ri-duts", "value"),
688 Output("ri-ttypes", "value"),
689 Output("ri-cadences", "value"),
690 Output("dd-tbeds", "value"),
691 Output("al-job", "children"),
692 State("control-panel", "data"), # Store
693 Input("ri-duts", "value"),
694 Input("ri-ttypes", "value"),
695 Input("ri-cadences", "value"),
696 Input("dd-tbeds", "value"),
698 def _update_ctrl_panel(cp_data: dict, dut:str, ttype: str, cadence:str,
703 ctrl_panel = self.ControlPanel(cp_data, self.default)
705 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
706 if trigger_id == "ri-duts":
707 ttype_opts = self._generate_options(self._get_ttypes(dut))
708 ttype_val = ttype_opts[0]["value"]
709 cad_opts = self._generate_options(
710 self._get_cadences(dut, ttype_val))
711 cad_val = cad_opts[0]["value"]
712 tbed_opts = self._generate_options(
713 self._get_test_beds(dut, ttype_val, cad_val))
714 tbed_val = tbed_opts[0]["value"]
716 "ri-duts-value": dut,
717 "ri-ttypes-options": ttype_opts,
718 "ri-ttypes-value": ttype_val,
719 "ri-cadences-options": cad_opts,
720 "ri-cadences-value": cad_val,
721 "dd-tbeds-options": tbed_opts,
722 "dd-tbeds-value": tbed_val
724 elif trigger_id == "ri-ttypes":
725 cad_opts = self._generate_options(
726 self._get_cadences(ctrl_panel.get("ri-duts-value"), ttype))
727 cad_val = cad_opts[0]["value"]
728 tbed_opts = self._generate_options(
729 self._get_test_beds(ctrl_panel.get("ri-duts-value"),
731 tbed_val = tbed_opts[0]["value"]
733 "ri-ttypes-value": ttype,
734 "ri-cadences-options": cad_opts,
735 "ri-cadences-value": cad_val,
736 "dd-tbeds-options": tbed_opts,
737 "dd-tbeds-value": tbed_val
739 elif trigger_id == "ri-cadences":
740 tbed_opts = self._generate_options(
741 self._get_test_beds(ctrl_panel.get("ri-duts-value"),
742 ctrl_panel.get("ri-ttypes-value"), cadence))
743 tbed_val = tbed_opts[0]["value"]
745 "ri-cadences-value": cadence,
746 "dd-tbeds-options": tbed_opts,
747 "dd-tbeds-value": tbed_val
749 elif trigger_id == "dd-tbeds":
751 "dd-tbeds-value": tbed
755 ctrl_panel.get("ri-duts-value"),
756 ctrl_panel.get("ri-ttypes-value"),
757 ctrl_panel.get("ri-cadences-value"),
758 ctrl_panel.get("dd-tbeds-value")
760 ctrl_panel.set({"al-job-children": job})
761 tab_failed = table_news(self.data, job)
767 ret_val.extend(ctrl_panel.values())