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, no_update
25 from dash import Input, Output, State
26 from dash.exceptions import PreventUpdate
27 from yaml import load, FullLoader, YAMLError
28 from datetime import datetime, timedelta
29 from copy import deepcopy
31 from ..utils.constants import Constants as C
32 from ..utils.utils import show_tooltip, gen_new_url, get_date, get_ttypes, \
33 get_cadences, get_test_beds, get_job, generate_options, set_job_params
34 from ..utils.url_processing import url_decode
35 from ..data.data import Data
36 from .graphs import graph_statistics, select_data
40 """The layout of the dash app and the callbacks.
43 def __init__(self, app: Flask, html_layout_file: str,
44 graph_layout_file: str, data_spec_file: str, tooltip_file: str,
45 time_period: int=None) -> None:
47 - save the input parameters,
48 - read and pre-process the data,
49 - prepare data for 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 graph_layout_file: Path and name of the file with layout of
58 :param data_spec_file: Path and name of the file specifying the data to
59 be read from parquets for this application.
60 :param tooltip_file: Path and name of the yaml file specifying the
62 :param time_period: It defines the time period for data read from the
63 parquets in days from now back to the past.
65 :type html_layout_file: str
66 :type graph_layout_file: str
67 :type data_spec_file: str
68 :type tooltip_file: str
69 :type time_period: int
74 self._html_layout_file = html_layout_file
75 self._graph_layout_file = graph_layout_file
76 self._data_spec_file = data_spec_file
77 self._tooltip_file = tooltip_file
78 self._time_period = time_period
81 data_stats, data_mrr, data_ndrpdr = Data(
82 data_spec_file=self._data_spec_file,
84 ).read_stats(days=self._time_period)
86 df_tst_info = pd.concat([data_mrr, data_ndrpdr], ignore_index=True)
88 # Pre-process the data:
89 data_stats = data_stats[~data_stats.job.str.contains("-verify-")]
90 data_stats = data_stats[~data_stats.job.str.contains("-coverage-")]
91 data_stats = data_stats[~data_stats.job.str.contains("-iterative-")]
92 data_stats = data_stats[["job", "build", "start_time", "duration"]]
95 (datetime.utcnow() - data_stats["start_time"].min()).days
96 if self._time_period > data_time_period:
97 self._time_period = data_time_period
99 jobs = sorted(list(data_stats["job"].unique()))
108 lst_job = job.split("-")
109 d_job_info["job"].append(job)
110 d_job_info["dut"].append(lst_job[1])
111 d_job_info["ttype"].append(lst_job[3])
112 d_job_info["cadence"].append(lst_job[4])
113 d_job_info["tbed"].append("-".join(lst_job[-2:]))
114 self.job_info = pd.DataFrame.from_dict(d_job_info)
116 self._default = set_job_params(self.job_info, C.STATS_DEFAULT_JOB)
122 "dut_version": list(),
129 df_job = df_tst_info.loc[(df_tst_info["job"] == job)]
130 builds = df_job["build"].unique()
132 df_build = df_job.loc[(df_job["build"] == build)]
133 tst_info["job"].append(job)
134 tst_info["build"].append(build)
135 tst_info["dut_type"].append(df_build["dut_type"].iloc[-1])
136 tst_info["dut_version"].append(df_build["dut_version"].iloc[-1])
137 tst_info["hosts"].append(df_build["hosts"].iloc[-1])
139 passed = df_build.value_counts(subset="passed")[True]
143 failed = df_build.value_counts(subset="passed")[False]
144 failed_tests = df_build.loc[(df_build["passed"] == False)]\
145 ["test_id"].to_list()
147 for tst in failed_tests:
148 lst_tst = tst.split(".")
149 suite = lst_tst[-2].replace("2n1l-", "").\
150 replace("1n1l-", "").replace("2n-", "")
151 l_failed.append(f"{suite.split('-')[0]}-{lst_tst[-1]}")
155 tst_info["passed"].append(passed)
156 tst_info["failed"].append(failed)
157 tst_info["lst_failed"].append(sorted(l_failed))
159 self._data = data_stats.merge(pd.DataFrame.from_dict(tst_info))
162 self._html_layout = ""
163 self._graph_layout = None
164 self._tooltips = dict()
167 with open(self._html_layout_file, "r") as file_read:
168 self._html_layout = file_read.read()
169 except IOError as err:
171 f"Not possible to open the file {self._html_layout_file}\n{err}"
175 with open(self._graph_layout_file, "r") as file_read:
176 self._graph_layout = load(file_read, Loader=FullLoader)
177 except IOError as err:
179 f"Not possible to open the file {self._graph_layout_file}\n"
182 except YAMLError as err:
184 f"An error occurred while parsing the specification file "
185 f"{self._graph_layout_file}\n{err}"
189 with open(self._tooltip_file, "r") as file_read:
190 self._tooltips = load(file_read, Loader=FullLoader)
191 except IOError as err:
193 f"Not possible to open the file {self._tooltip_file}\n{err}"
195 except YAMLError as err:
197 f"An error occurred while parsing the specification file "
198 f"{self._tooltip_file}\n{err}"
202 self._default_fig_passed, self._default_fig_duration = graph_statistics(
203 self.data, self._default["job"], self.layout
207 if self._app is not None and hasattr(self, 'callbacks'):
208 self.callbacks(self._app)
211 def html_layout(self) -> dict:
212 return self._html_layout
215 def data(self) -> pd.DataFrame:
219 def layout(self) -> dict:
220 return self._graph_layout
223 def time_period(self) -> int:
224 return self._time_period
227 def default(self) -> any:
230 def add_content(self):
231 """Top level method which generated the web page.
234 - Store for user input data,
236 - Main area with control panel and ploting area.
238 If no HTML layout is provided, an error message is displayed instead.
240 :returns: The HTML div with the whole page.
248 dcc.Store(id="control-panel"),
249 dcc.Location(id="url", refresh=False),
260 id="offcanvas-metadata",
261 title="Detailed Information",
265 dbc.Row(id="row-metadata")
273 self._add_ctrl_col(),
274 self._add_plotting_col(),
292 def _add_navbar(self):
293 """Add nav element with navigation panel. It is placed on the top.
295 :returns: Navigation bar.
296 :rtype: dbc.NavbarSimple
298 return dbc.NavbarSimple(
299 id="navbarsimple-main",
303 "Continuous Performance Statistics",
312 brand_external_link=True,
317 def _add_ctrl_col(self) -> dbc.Col:
318 """Add column with controls. It is placed on the left side.
320 :returns: Column with the control panel.
326 self._add_ctrl_panel(),
330 def _add_plotting_col(self) -> dbc.Col:
331 """Add column with plots and tables. It is placed on the right side.
333 :returns: Column with tables.
337 id="col-plotting-area",
339 dbc.Row( # Passed / failed tests
340 id="row-graph-passed",
341 class_name="g-0 p-2",
343 dcc.Loading(children=[
346 figure=self._default_fig_passed
352 id="row-graph-duration",
353 class_name="g-0 p-2",
355 dcc.Loading(children=[
358 figure=self._default_fig_duration
364 class_name="g-0 p-2",
371 dcc.Loading(children=[
373 id="btn-download-data",
374 children=show_tooltip(self._tooltips,
375 "help-download", "Download Data"),
379 dcc.Download(id="download-data")
391 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"]
499 children=self.default["job"]
504 class_name="g-0 p-2",
508 children=show_tooltip(self._tooltips,
509 "help-time-period", "Time Period"),
513 className="d-flex justify-content-center",
515 datetime.utcnow() - timedelta(
516 days=self.time_period),
517 max_date_allowed=datetime.utcnow(),
518 initial_visible_month=datetime.utcnow(),
520 datetime.utcnow() - timedelta(
521 days=self.time_period),
522 end_date=datetime.utcnow(),
523 display_format="D MMM YY"
533 """A class representing the control panel.
536 def __init__(self, panel: dict, default: dict) -> None:
537 """Initialisation of the control pannel by default values. If
538 particular values are provided (parameter "panel") they are set
541 :param panel: Custom values to be set to the control panel.
542 :param default: Default values to be set to the control panel.
548 "ri-ttypes-options": default["ttypes"],
549 "ri-cadences-options": default["cadences"],
550 "dd-tbeds-options": default["tbeds"],
551 "ri-duts-value": default["dut"],
552 "ri-ttypes-value": default["ttype"],
553 "ri-cadences-value": default["cadence"],
554 "dd-tbeds-value": default["tbed"],
555 "al-job-children": default["job"],
556 "dpr-start-date": datetime.utcnow() - \
557 timedelta(days=C.TIME_PERIOD),
558 "dpr-end-date": datetime.utcnow()
560 self._panel = deepcopy(self._defaults)
562 for key in self._defaults:
563 self._panel[key] = panel[key]
565 def set(self, kwargs: dict) -> None:
566 """Set the values of the Control panel.
568 :param kwargs: key - value pairs to be set.
570 :raises KeyError: If the key in kwargs is not present in the Control
573 for key, val in kwargs.items():
574 if key in self._panel:
575 self._panel[key] = val
577 raise KeyError(f"The key {key} is not defined.")
580 def defaults(self) -> dict:
581 return self._defaults
584 def panel(self) -> dict:
587 def get(self, key: str) -> any:
588 """Returns the value of a key from the Control panel.
590 :param key: The key which value should be returned.
592 :returns: The value of the key.
594 :raises KeyError: If the key in kwargs is not present in the Control
597 return self._panel[key]
599 def values(self) -> list:
600 """Returns the values from the Control panel as a list.
602 :returns: The values from the Control panel.
605 return list(self._panel.values())
608 def callbacks(self, app):
609 """Callbacks for the whole application.
611 :param app: The application.
616 Output("control-panel", "data"), # Store
617 Output("graph-passed", "figure"),
618 Output("graph-duration", "figure"),
619 Output("input-url", "value"),
620 Output("ri-ttypes", "options"),
621 Output("ri-cadences", "options"),
622 Output("dd-tbeds", "options"),
623 Output("ri-duts", "value"),
624 Output("ri-ttypes", "value"),
625 Output("ri-cadences", "value"),
626 Output("dd-tbeds", "value"),
627 Output("al-job", "children"),
628 Output("dpr-period", "start_date"),
629 Output("dpr-period", "end_date"),
630 State("control-panel", "data"), # Store
631 Input("ri-duts", "value"),
632 Input("ri-ttypes", "value"),
633 Input("ri-cadences", "value"),
634 Input("dd-tbeds", "value"),
635 Input("dpr-period", "start_date"),
636 Input("dpr-period", "end_date"),
639 def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str, cadence:str,
640 tbed: str, start: str, end: str, href: str) -> tuple:
641 """Update the application when the event is detected.
643 :param cp_data: Current status of the control panel stored in
645 :param dut: Input - DUT name.
646 :param ttype: Input - Test type.
647 :param cadence: Input - The cadence of the job.
648 :param tbed: Input - The test bed.
649 :param start: Date and time where the data processing starts.
650 :param end: Date and time where the data processing ends.
651 :param href: Input - The URL provided by the browser.
660 :returns: New values for web page elements.
664 ctrl_panel = self.ControlPanel(cp_data, self.default)
666 start = get_date(start)
670 parsed_url = url_decode(href)
672 url_params = parsed_url["params"]
676 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
677 if trigger_id == "ri-duts":
678 ttype_opts = generate_options(get_ttypes(self.job_info, dut))
679 ttype_val = ttype_opts[0]["value"]
680 cad_opts = generate_options(get_cadences(
681 self.job_info, dut, ttype_val))
682 cad_val = cad_opts[0]["value"]
683 tbed_opts = generate_options(get_test_beds(
684 self.job_info, dut, ttype_val, cad_val))
685 tbed_val = tbed_opts[0]["value"]
687 "ri-duts-value": dut,
688 "ri-ttypes-options": ttype_opts,
689 "ri-ttypes-value": ttype_val,
690 "ri-cadences-options": cad_opts,
691 "ri-cadences-value": cad_val,
692 "dd-tbeds-options": tbed_opts,
693 "dd-tbeds-value": tbed_val
695 elif trigger_id == "ri-ttypes":
696 cad_opts = generate_options(get_cadences(
697 self.job_info, ctrl_panel.get("ri-duts-value"), ttype))
698 cad_val = cad_opts[0]["value"]
699 tbed_opts = generate_options(get_test_beds(
700 self.job_info, ctrl_panel.get("ri-duts-value"), ttype,
702 tbed_val = tbed_opts[0]["value"]
704 "ri-ttypes-value": ttype,
705 "ri-cadences-options": cad_opts,
706 "ri-cadences-value": cad_val,
707 "dd-tbeds-options": tbed_opts,
708 "dd-tbeds-value": tbed_val
710 elif trigger_id == "ri-cadences":
711 tbed_opts = generate_options(get_test_beds(
712 self.job_info, ctrl_panel.get("ri-duts-value"),
713 ctrl_panel.get("ri-ttypes-value"), cadence))
714 tbed_val = tbed_opts[0]["value"]
716 "ri-cadences-value": cadence,
717 "dd-tbeds-options": tbed_opts,
718 "dd-tbeds-value": tbed_val
720 elif trigger_id == "dd-tbeds":
722 "dd-tbeds-value": tbed
724 elif trigger_id == "dpr-period":
726 elif trigger_id == "url":
728 new_job = url_params.get("job", list())[0]
729 new_start = url_params.get("start", list())[0]
730 new_end = url_params.get("end", list())[0]
731 if new_job and new_start and new_end:
732 start = get_date(new_start)
733 end = get_date(new_end)
734 job_params = set_job_params(self.job_info, new_job)
735 ctrl_panel = self.ControlPanel(None, job_params)
737 ctrl_panel = self.ControlPanel(cp_data, self.default)
741 ctrl_panel.get("ri-duts-value"),
742 ctrl_panel.get("ri-ttypes-value"),
743 ctrl_panel.get("ri-cadences-value"),
744 ctrl_panel.get("dd-tbeds-value")
748 "al-job-children": job,
749 "dpr-start-date": start,
752 fig_passed, fig_duration = graph_statistics(self.data, job,
753 self.layout, start, end)
768 ret_val.extend(ctrl_panel.values())
772 Output("download-data", "data"),
773 State("control-panel", "data"), # Store
774 State("dpr-period", "start_date"),
775 State("dpr-period", "end_date"),
776 Input("btn-download-data", "n_clicks"),
777 prevent_initial_call=True
779 def _download_data(cp_data: dict, start: str, end: str, n_clicks: int):
782 :param cp_data: Current status of the control panel stored in
784 :param start: Date and time where the data processing starts.
785 :param end: Date and time where the data processing ends.
786 :param n_clicks: Number of clicks on the button "Download".
791 :returns: dict of data frame content (base64 encoded) and meta data
792 used by the Download component.
798 ctrl_panel = self.ControlPanel(cp_data, self.default)
802 ctrl_panel.get("ri-duts-value"),
803 ctrl_panel.get("ri-ttypes-value"),
804 ctrl_panel.get("ri-cadences-value"),
805 ctrl_panel.get("dd-tbeds-value")
808 data = select_data(self.data, job, get_date(start), get_date(end))
809 data = data.drop(columns=["job", ])
811 return dcc.send_data_frame(
812 data.T.to_csv, f"{job}-{C.STATS_DOWNLOAD_FILE_NAME}")
815 Output("row-metadata", "children"),
816 Output("offcanvas-metadata", "is_open"),
817 Input("graph-passed", "clickData"),
818 Input("graph-duration", "clickData"),
819 prevent_initial_call=True
821 def _show_metadata_from_graphs(
822 passed_data: dict, duration_data: dict) -> tuple:
823 """Generates the data for the offcanvas displayed when a particular
824 point in a graph is clicked on.
826 :param passed_data: The data from the clicked point in the graph
827 displaying the pass/fail data.
828 :param duration_data: The data from the clicked point in the graph
829 displaying the duration data.
830 :type passed_data: dict
831 :type duration data: dict
832 :returns: The data to be displayed on the offcanvas (job statistics
833 and the list of failed tests) and the information to show the
835 :rtype: tuple(list, bool)
838 if not (passed_data or duration_data):
843 title = "Job Statistics"
844 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
845 if trigger_id == "graph-passed":
846 graph_data = passed_data["points"][0].get("hovertext", "")
847 elif trigger_id == "graph-duration":
848 graph_data = duration_data["points"][0].get("text", "")
850 lst_graph_data = graph_data.split("<br>")
852 # Prepare list of failed tests:
855 for itm in lst_graph_data:
856 if "csit-ref:" in itm:
857 job, build = itm.split(" ")[-1].split("/")
860 fail_tests = self.data.loc[
861 (self.data["job"] == job) &
862 (self.data["build"] == build)
863 ]["lst_failed"].values[0]
869 # Create the content of the offcanvas:
872 class_name="gy-2 p-0",
874 dbc.CardHeader(children=[
876 target_id="metadata",
878 style={"display": "inline-block"}
885 children=[dbc.ListGroup(
894 ) for x in lst_graph_data
903 if fail_tests is not None:
906 class_name="gy-2 p-0",
909 f"List of Failed Tests ({len(fail_tests)})"
914 children=[dbc.ListGroup(
916 dbc.ListGroupItem(x) \
928 return metadata, open_canvas