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 teh 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"]
557 self._panel = deepcopy(self._defaults)
559 for key in self._defaults:
560 self._panel[key] = panel[key]
562 def set(self, kwargs: dict) -> None:
563 """Set the values of the Control panel.
565 :param kwargs: key - value pairs to be set.
567 :raises KeyError: If the key in kwargs is not present in the Control
570 for key, val in kwargs.items():
571 if key in self._panel:
572 self._panel[key] = val
574 raise KeyError(f"The key {key} is not defined.")
577 def defaults(self) -> dict:
578 return self._defaults
581 def panel(self) -> dict:
584 def get(self, key: str) -> any:
585 """Returns the value of a key from the Control panel.
587 :param key: The key which value should be returned.
589 :returns: The value of the key.
591 :raises KeyError: If the key in kwargs is not present in the Control
594 return self._panel[key]
596 def values(self) -> list:
597 """Returns the values from the Control panel as a list.
599 :returns: The values from the Control panel.
602 return list(self._panel.values())
605 def callbacks(self, app):
606 """Callbacks for the whole application.
608 :param app: The application.
613 Output("control-panel", "data"), # Store
614 Output("graph-passed", "figure"),
615 Output("graph-duration", "figure"),
616 Output("input-url", "value"),
617 Output("ri-ttypes", "options"),
618 Output("ri-cadences", "options"),
619 Output("dd-tbeds", "options"),
620 Output("ri-duts", "value"),
621 Output("ri-ttypes", "value"),
622 Output("ri-cadences", "value"),
623 Output("dd-tbeds", "value"),
624 Output("al-job", "children"),
625 State("control-panel", "data"), # Store
626 Input("ri-duts", "value"),
627 Input("ri-ttypes", "value"),
628 Input("ri-cadences", "value"),
629 Input("dd-tbeds", "value"),
630 Input("dpr-period", "start_date"),
631 Input("dpr-period", "end_date"),
634 def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str, cadence:str,
635 tbed: str, start: str, end: str, href: str) -> tuple:
636 """Update the application when the event is detected.
638 :param cp_data: Current status of the control panel stored in
640 :param dut: Input - DUT name.
641 :param ttype: Input - Test type.
642 :param cadence: Input - The cadence of the job.
643 :param tbed: Input - The test bed.
644 :param start: Date and time where the data processing starts.
645 :param end: Date and time where the data processing ends.
646 :param href: Input - The URL provided by the browser.
655 :returns: New values for web page elements.
659 ctrl_panel = self.ControlPanel(cp_data, self.default)
661 start = get_date(start)
665 parsed_url = url_decode(href)
667 url_params = parsed_url["params"]
671 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
672 if trigger_id == "ri-duts":
673 ttype_opts = generate_options(get_ttypes(self.job_info, dut))
674 ttype_val = ttype_opts[0]["value"]
675 cad_opts = generate_options(get_cadences(
676 self.job_info, dut, ttype_val))
677 cad_val = cad_opts[0]["value"]
678 tbed_opts = generate_options(get_test_beds(
679 self.job_info, dut, ttype_val, cad_val))
680 tbed_val = tbed_opts[0]["value"]
682 "ri-duts-value": dut,
683 "ri-ttypes-options": ttype_opts,
684 "ri-ttypes-value": ttype_val,
685 "ri-cadences-options": cad_opts,
686 "ri-cadences-value": cad_val,
687 "dd-tbeds-options": tbed_opts,
688 "dd-tbeds-value": tbed_val
690 elif trigger_id == "ri-ttypes":
691 cad_opts = generate_options(get_cadences(
692 self.job_info, ctrl_panel.get("ri-duts-value"), ttype))
693 cad_val = cad_opts[0]["value"]
694 tbed_opts = generate_options(get_test_beds(
695 self.job_info, ctrl_panel.get("ri-duts-value"), ttype,
697 tbed_val = tbed_opts[0]["value"]
699 "ri-ttypes-value": ttype,
700 "ri-cadences-options": cad_opts,
701 "ri-cadences-value": cad_val,
702 "dd-tbeds-options": tbed_opts,
703 "dd-tbeds-value": tbed_val
705 elif trigger_id == "ri-cadences":
706 tbed_opts = generate_options(get_test_beds(
707 self.job_info, ctrl_panel.get("ri-duts-value"),
708 ctrl_panel.get("ri-ttypes-value"), cadence))
709 tbed_val = tbed_opts[0]["value"]
711 "ri-cadences-value": cadence,
712 "dd-tbeds-options": tbed_opts,
713 "dd-tbeds-value": tbed_val
715 elif trigger_id == "dd-tbeds":
717 "dd-tbeds-value": tbed
719 elif trigger_id == "dpr-period":
721 elif trigger_id == "url":
722 # TODO: Add verification
724 new_job = url_params.get("job", list())[0]
725 new_start = url_params.get("start", list())[0]
726 new_end = url_params.get("end", list())[0]
727 if new_job and new_start and new_end:
728 start = get_date(new_start)
729 end = get_date(new_end)
730 job_params = set_job_params(self.job_info, new_job)
731 ctrl_panel = self.ControlPanel(None, job_params)
733 ctrl_panel = self.ControlPanel(cp_data, self.default)
737 ctrl_panel.get("ri-duts-value"),
738 ctrl_panel.get("ri-ttypes-value"),
739 ctrl_panel.get("ri-cadences-value"),
740 ctrl_panel.get("dd-tbeds-value")
743 ctrl_panel.set({"al-job-children": job})
744 fig_passed, fig_duration = graph_statistics(self.data, job,
745 self.layout, start, end)
760 ret_val.extend(ctrl_panel.values())
764 Output("download-data", "data"),
765 State("control-panel", "data"), # Store
766 State("dpr-period", "start_date"),
767 State("dpr-period", "end_date"),
768 Input("btn-download-data", "n_clicks"),
769 prevent_initial_call=True
771 def _download_data(cp_data: dict, start: str, end: str, n_clicks: int):
774 :param cp_data: Current status of the control panel stored in
776 :param start: Date and time where the data processing starts.
777 :param end: Date and time where the data processing ends.
778 :param n_clicks: Number of clicks on the button "Download".
783 :returns: dict of data frame content (base64 encoded) and meta data
784 used by the Download component.
790 ctrl_panel = self.ControlPanel(cp_data, self.default)
794 ctrl_panel.get("ri-duts-value"),
795 ctrl_panel.get("ri-ttypes-value"),
796 ctrl_panel.get("ri-cadences-value"),
797 ctrl_panel.get("dd-tbeds-value")
800 data = select_data(self.data, job, get_date(start), get_date(end))
801 data = data.drop(columns=["job", ])
803 return dcc.send_data_frame(data.T.to_csv, f"{job}-stats.csv")
806 Output("row-metadata", "children"),
807 Output("offcanvas-metadata", "is_open"),
808 Input("graph-passed", "clickData"),
809 Input("graph-duration", "clickData"),
810 prevent_initial_call=True
812 def _show_metadata_from_graphs(
813 passed_data: dict, duration_data: dict) -> tuple:
814 """Generates the data for the offcanvas displayed when a particular
815 point in a graph is clicked on.
817 :param passed_data: The data from the clicked point in the graph
818 displaying the pass/fail data.
819 :param duration_data: The data from the clicked point in the graph
820 displaying the duration data.
821 :type passed_data: dict
822 :type duration data: dict
823 :returns: The data to be displayed on the offcanvas (job statistics
824 and the list of failed tests) and the information to show the
826 :rtype: tuple(list, bool)
829 if not (passed_data or duration_data):
834 title = "Job Statistics"
835 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
836 if trigger_id == "graph-passed":
837 graph_data = passed_data["points"][0].get("hovertext", "")
838 elif trigger_id == "graph-duration":
839 graph_data = duration_data["points"][0].get("text", "")
841 lst_graph_data = graph_data.split("<br>")
843 # Prepare list of failed tests:
846 for itm in lst_graph_data:
847 if "csit-ref:" in itm:
848 job, build = itm.split(" ")[-1].split("/")
851 fail_tests = self.data.loc[
852 (self.data["job"] == job) &
853 (self.data["build"] == build)
854 ]["lst_failed"].values[0]
860 # Create the content of the offcanvas:
863 class_name="gy-2 p-0",
865 dbc.CardHeader(children=[
867 target_id="metadata",
869 style={"display": "inline-block"}
876 children=[dbc.ListGroup(
885 ) for x in lst_graph_data
894 if fail_tests is not None:
897 class_name="gy-2 p-0",
900 f"List of Failed Tests ({len(fail_tests)})"
905 children=[dbc.ListGroup(
907 dbc.ListGroupItem(x) \
919 return metadata, open_canvas