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
29 from copy import deepcopy
31 from ..utils.constants import Constants as C
32 from ..utils.utils import show_tooltip, gen_new_url, get_ttypes, get_cadences, \
33 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.
249 dcc.Store(id="control-panel"),
250 dcc.Location(id="url", refresh=False),
261 id="offcanvas-metadata",
262 title="Detailed Information",
266 dbc.Row(id="row-metadata")
274 self._add_ctrl_col(),
275 self._add_plotting_col(),
293 def _add_navbar(self):
294 """Add nav element with navigation panel. It is placed on the top.
296 :returns: Navigation bar.
297 :rtype: dbc.NavbarSimple
299 return dbc.NavbarSimple(
300 id="navbarsimple-main",
304 "Continuous Performance Statistics",
313 brand_external_link=True,
318 def _add_ctrl_col(self) -> dbc.Col:
319 """Add column with controls. It is placed on the left side.
321 :returns: Column with the control panel.
327 self._add_ctrl_panel(),
331 def _add_plotting_col(self) -> dbc.Col:
332 """Add column with plots and tables. It is placed on the right side.
334 :returns: Column with tables.
338 id="col-plotting-area",
340 dbc.Row( # Passed / failed tests
341 id="row-graph-passed",
342 class_name="g-0 p-2",
344 dcc.Loading(children=[
347 figure=self._default_fig_passed
353 id="row-graph-duration",
354 class_name="g-0 p-2",
356 dcc.Loading(children=[
359 figure=self._default_fig_duration
365 class_name="g-0 p-2",
372 dcc.Loading(children=[
374 id="btn-download-data",
375 children=show_tooltip(self._tooltips,
376 "help-download", "Download Data"),
380 dcc.Download(id="download-data")
392 children=show_tooltip(
415 def _add_ctrl_panel(self) -> dbc.Row:
416 """Add control panel.
418 :returns: Control panel.
426 class_name="g-0 p-2",
433 children=show_tooltip(self._tooltips,
434 "help-dut", "Device under Test")
440 value=self.default["dut"],
441 options=self.default["duts"]
451 children=show_tooltip(self._tooltips,
452 "help-ttype", "Test Type"),
457 value=self.default["ttype"],
458 options=self.default["ttypes"]
467 children=show_tooltip(self._tooltips,
468 "help-cadence", "Cadence"),
473 value=self.default["cadence"],
474 options=self.default["cadences"]
483 children=show_tooltip(self._tooltips,
484 "help-tbed", "Test Bed"),
488 placeholder="Select a test bed...",
489 value=self.default["tbed"],
490 options=self.default["tbeds"]
500 children=self.default["job"]
510 """A class representing the control panel.
513 def __init__(self, panel: dict, default: dict) -> None:
514 """Initialisation of the control pannel by default values. If
515 particular values are provided (parameter "panel") they are set
518 :param panel: Custom values to be set to the control panel.
519 :param default: Default values to be set to the control panel.
525 "ri-ttypes-options": default["ttypes"],
526 "ri-cadences-options": default["cadences"],
527 "dd-tbeds-options": default["tbeds"],
528 "ri-duts-value": default["dut"],
529 "ri-ttypes-value": default["ttype"],
530 "ri-cadences-value": default["cadence"],
531 "dd-tbeds-value": default["tbed"],
532 "al-job-children": default["job"]
534 self._panel = deepcopy(self._defaults)
536 for key in self._defaults:
537 self._panel[key] = panel[key]
539 def set(self, kwargs: dict) -> None:
540 """Set the values of the Control panel.
542 :param kwargs: key - value pairs to be set.
544 :raises KeyError: If the key in kwargs is not present in the Control
547 for key, val in kwargs.items():
548 if key in self._panel:
549 self._panel[key] = val
551 raise KeyError(f"The key {key} is not defined.")
554 def defaults(self) -> dict:
555 return self._defaults
558 def panel(self) -> dict:
561 def get(self, key: str) -> any:
562 """Returns the value of a key from the Control panel.
564 :param key: The key which value should be returned.
566 :returns: The value of the key.
568 :raises KeyError: If the key in kwargs is not present in the Control
571 return self._panel[key]
573 def values(self) -> list:
574 """Returns the values from the Control panel as a list.
576 :returns: The values from the Control panel.
579 return list(self._panel.values())
582 def callbacks(self, app):
583 """Callbacks for the whole application.
585 :param app: The application.
590 Output("control-panel", "data"), # Store
591 Output("graph-passed", "figure"),
592 Output("graph-duration", "figure"),
593 Output("input-url", "value"),
594 Output("ri-ttypes", "options"),
595 Output("ri-cadences", "options"),
596 Output("dd-tbeds", "options"),
597 Output("ri-duts", "value"),
598 Output("ri-ttypes", "value"),
599 Output("ri-cadences", "value"),
600 Output("dd-tbeds", "value"),
601 Output("al-job", "children"),
602 State("control-panel", "data"), # Store
603 Input("ri-duts", "value"),
604 Input("ri-ttypes", "value"),
605 Input("ri-cadences", "value"),
606 Input("dd-tbeds", "value"),
609 def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str, cadence:str,
610 tbed: str, href: str) -> tuple:
611 """Update the application when the event is detected.
613 :param cp_data: Current status of the control panel stored in
615 :param dut: Input - DUT name.
616 :param ttype: Input - Test type.
617 :param cadence: Input - The cadence of the job.
618 :param tbed: Input - The test bed.
619 :param href: Input - The URL provided by the browser.
626 :returns: New values for web page elements.
630 ctrl_panel = self.ControlPanel(cp_data, self.default)
633 parsed_url = url_decode(href)
635 url_params = parsed_url["params"]
639 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
640 if trigger_id == "ri-duts":
641 ttype_opts = generate_options(get_ttypes(self.job_info, dut))
642 ttype_val = ttype_opts[0]["value"]
643 cad_opts = generate_options(get_cadences(
644 self.job_info, dut, ttype_val))
645 cad_val = cad_opts[0]["value"]
646 tbed_opts = generate_options(get_test_beds(
647 self.job_info, dut, ttype_val, cad_val))
648 tbed_val = tbed_opts[0]["value"]
650 "ri-duts-value": dut,
651 "ri-ttypes-options": ttype_opts,
652 "ri-ttypes-value": ttype_val,
653 "ri-cadences-options": cad_opts,
654 "ri-cadences-value": cad_val,
655 "dd-tbeds-options": tbed_opts,
656 "dd-tbeds-value": tbed_val
658 elif trigger_id == "ri-ttypes":
659 cad_opts = generate_options(get_cadences(
660 self.job_info, ctrl_panel.get("ri-duts-value"), ttype))
661 cad_val = cad_opts[0]["value"]
662 tbed_opts = generate_options(get_test_beds(
663 self.job_info, ctrl_panel.get("ri-duts-value"), ttype,
665 tbed_val = tbed_opts[0]["value"]
667 "ri-ttypes-value": ttype,
668 "ri-cadences-options": cad_opts,
669 "ri-cadences-value": cad_val,
670 "dd-tbeds-options": tbed_opts,
671 "dd-tbeds-value": tbed_val
673 elif trigger_id == "ri-cadences":
674 tbed_opts = generate_options(get_test_beds(
675 self.job_info, ctrl_panel.get("ri-duts-value"),
676 ctrl_panel.get("ri-ttypes-value"), cadence))
677 tbed_val = tbed_opts[0]["value"]
679 "ri-cadences-value": cadence,
680 "dd-tbeds-options": tbed_opts,
681 "dd-tbeds-value": tbed_val
683 elif trigger_id == "dd-tbeds":
685 "dd-tbeds-value": tbed
687 elif trigger_id == "url":
689 new_job = url_params.get("job", list())[0]
691 job_params = set_job_params(self.job_info, new_job)
692 ctrl_panel = self.ControlPanel(None, job_params)
694 ctrl_panel = self.ControlPanel(cp_data, self.default)
698 ctrl_panel.get("ri-duts-value"),
699 ctrl_panel.get("ri-ttypes-value"),
700 ctrl_panel.get("ri-cadences-value"),
701 ctrl_panel.get("dd-tbeds-value")
704 ctrl_panel.set({"al-job-children": job})
705 fig_passed, fig_duration = \
706 graph_statistics(self.data, job, self.layout)
712 gen_new_url(parsed_url, {"job": job})
714 ret_val.extend(ctrl_panel.values())
718 Output("download-data", "data"),
719 State("control-panel", "data"), # Store
720 Input("btn-download-data", "n_clicks"),
721 prevent_initial_call=True
723 def _download_data(cp_data: dict, n_clicks: int):
726 :param cp_data: Current status of the control panel stored in
728 :param n_clicks: Number of clicks on the button "Download".
731 :returns: dict of data frame content (base64 encoded) and meta data
732 used by the Download component.
738 ctrl_panel = self.ControlPanel(cp_data, self.default)
742 ctrl_panel.get("ri-duts-value"),
743 ctrl_panel.get("ri-ttypes-value"),
744 ctrl_panel.get("ri-cadences-value"),
745 ctrl_panel.get("dd-tbeds-value")
748 data = select_data(self.data, job)
749 data = data.drop(columns=["job", ])
751 return dcc.send_data_frame(
752 data.T.to_csv, f"{job}-{C.STATS_DOWNLOAD_FILE_NAME}")
755 Output("row-metadata", "children"),
756 Output("offcanvas-metadata", "is_open"),
757 Input("graph-passed", "clickData"),
758 Input("graph-duration", "clickData"),
759 prevent_initial_call=True
761 def _show_metadata_from_graphs(
762 passed_data: dict, duration_data: dict) -> tuple:
763 """Generates the data for the offcanvas displayed when a particular
764 point in a graph is clicked on.
766 :param passed_data: The data from the clicked point in the graph
767 displaying the pass/fail data.
768 :param duration_data: The data from the clicked point in the graph
769 displaying the duration data.
770 :type passed_data: dict
771 :type duration data: dict
772 :returns: The data to be displayed on the offcanvas (job statistics
773 and the list of failed tests) and the information to show the
775 :rtype: tuple(list, bool)
778 if not (passed_data or duration_data):
783 title = "Job Statistics"
784 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
785 if trigger_id == "graph-passed":
786 graph_data = passed_data["points"][0].get("hovertext", "")
787 elif trigger_id == "graph-duration":
788 graph_data = duration_data["points"][0].get("text", "")
790 lst_graph_data = graph_data.split("<br>")
792 # Prepare list of failed tests:
795 for itm in lst_graph_data:
796 if "csit-ref:" in itm:
797 job, build = itm.split(" ")[-1].split("/")
800 fail_tests = self.data.loc[
801 (self.data["job"] == job) &
802 (self.data["build"] == build)
803 ]["lst_failed"].values[0]
809 # Create the content of the offcanvas:
812 class_name="gy-2 p-0",
814 dbc.CardHeader(children=[
816 target_id="metadata",
818 style={"display": "inline-block"}
825 children=[dbc.ListGroup(
834 ) for x in lst_graph_data
843 if fail_tests is not None:
846 class_name="gy-2 p-0",
849 f"List of Failed Tests ({len(fail_tests)})"
854 children=[dbc.ListGroup(
856 dbc.ListGroupItem(x) \
868 return metadata, open_canvas