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.
326 children=self._add_ctrl_panel(),
327 className="sticky-top"
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.
423 class_name="g-0 p-1",
426 children=show_tooltip(self._tooltips,
427 "help-dut", "Device under Test")
432 value=self.default["dut"],
433 options=self.default["duts"]
438 class_name="g-0 p-1",
441 children=show_tooltip(self._tooltips,
442 "help-ttype", "Test Type"),
447 value=self.default["ttype"],
448 options=self.default["ttypes"]
453 class_name="g-0 p-1",
456 children=show_tooltip(self._tooltips,
457 "help-cadence", "Cadence"),
462 value=self.default["cadence"],
463 options=self.default["cadences"]
468 class_name="g-0 p-1",
471 children=show_tooltip(self._tooltips,
472 "help-tbed", "Test Bed"),
476 placeholder="Select a test bed...",
477 value=self.default["tbed"],
478 options=self.default["tbeds"]
483 class_name="g-0 p-1",
488 children=self.default["job"]
495 """A class representing the control panel.
498 def __init__(self, panel: dict, default: dict) -> None:
499 """Initialisation of the control pannel by default values. If
500 particular values are provided (parameter "panel") they are set
503 :param panel: Custom values to be set to the control panel.
504 :param default: Default values to be set to the control panel.
510 "ri-ttypes-options": default["ttypes"],
511 "ri-cadences-options": default["cadences"],
512 "dd-tbeds-options": default["tbeds"],
513 "ri-duts-value": default["dut"],
514 "ri-ttypes-value": default["ttype"],
515 "ri-cadences-value": default["cadence"],
516 "dd-tbeds-value": default["tbed"],
517 "al-job-children": default["job"]
519 self._panel = deepcopy(self._defaults)
521 for key in self._defaults:
522 self._panel[key] = panel[key]
524 def set(self, kwargs: dict) -> None:
525 """Set the values of the Control panel.
527 :param kwargs: key - value pairs to be set.
529 :raises KeyError: If the key in kwargs is not present in the Control
532 for key, val in kwargs.items():
533 if key in self._panel:
534 self._panel[key] = val
536 raise KeyError(f"The key {key} is not defined.")
539 def defaults(self) -> dict:
540 return self._defaults
543 def panel(self) -> dict:
546 def get(self, key: str) -> any:
547 """Returns the value of a key from the Control panel.
549 :param key: The key which value should be returned.
551 :returns: The value of the key.
553 :raises KeyError: If the key in kwargs is not present in the Control
556 return self._panel[key]
558 def values(self) -> list:
559 """Returns the values from the Control panel as a list.
561 :returns: The values from the Control panel.
564 return list(self._panel.values())
567 def callbacks(self, app):
568 """Callbacks for the whole application.
570 :param app: The application.
575 Output("control-panel", "data"), # Store
576 Output("graph-passed", "figure"),
577 Output("graph-duration", "figure"),
578 Output("input-url", "value"),
579 Output("ri-ttypes", "options"),
580 Output("ri-cadences", "options"),
581 Output("dd-tbeds", "options"),
582 Output("ri-duts", "value"),
583 Output("ri-ttypes", "value"),
584 Output("ri-cadences", "value"),
585 Output("dd-tbeds", "value"),
586 Output("al-job", "children"),
587 State("control-panel", "data"), # Store
588 Input("ri-duts", "value"),
589 Input("ri-ttypes", "value"),
590 Input("ri-cadences", "value"),
591 Input("dd-tbeds", "value"),
594 def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str, cadence:str,
595 tbed: str, href: str) -> tuple:
596 """Update the application when the event is detected.
598 :param cp_data: Current status of the control panel stored in
600 :param dut: Input - DUT name.
601 :param ttype: Input - Test type.
602 :param cadence: Input - The cadence of the job.
603 :param tbed: Input - The test bed.
604 :param href: Input - The URL provided by the browser.
611 :returns: New values for web page elements.
615 ctrl_panel = self.ControlPanel(cp_data, self.default)
618 parsed_url = url_decode(href)
620 url_params = parsed_url["params"]
624 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
625 if trigger_id == "ri-duts":
626 ttype_opts = generate_options(get_ttypes(self.job_info, dut))
627 ttype_val = ttype_opts[0]["value"]
628 cad_opts = generate_options(get_cadences(
629 self.job_info, dut, ttype_val))
630 cad_val = cad_opts[0]["value"]
631 tbed_opts = generate_options(get_test_beds(
632 self.job_info, dut, ttype_val, cad_val))
633 tbed_val = tbed_opts[0]["value"]
635 "ri-duts-value": dut,
636 "ri-ttypes-options": ttype_opts,
637 "ri-ttypes-value": ttype_val,
638 "ri-cadences-options": cad_opts,
639 "ri-cadences-value": cad_val,
640 "dd-tbeds-options": tbed_opts,
641 "dd-tbeds-value": tbed_val
643 elif trigger_id == "ri-ttypes":
644 cad_opts = generate_options(get_cadences(
645 self.job_info, ctrl_panel.get("ri-duts-value"), ttype))
646 cad_val = cad_opts[0]["value"]
647 tbed_opts = generate_options(get_test_beds(
648 self.job_info, ctrl_panel.get("ri-duts-value"), ttype,
650 tbed_val = tbed_opts[0]["value"]
652 "ri-ttypes-value": ttype,
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-cadences":
659 tbed_opts = generate_options(get_test_beds(
660 self.job_info, ctrl_panel.get("ri-duts-value"),
661 ctrl_panel.get("ri-ttypes-value"), cadence))
662 tbed_val = tbed_opts[0]["value"]
664 "ri-cadences-value": cadence,
665 "dd-tbeds-options": tbed_opts,
666 "dd-tbeds-value": tbed_val
668 elif trigger_id == "dd-tbeds":
670 "dd-tbeds-value": tbed
672 elif trigger_id == "url":
674 new_job = url_params.get("job", list())[0]
676 job_params = set_job_params(self.job_info, new_job)
677 ctrl_panel = self.ControlPanel(None, job_params)
679 ctrl_panel = self.ControlPanel(cp_data, self.default)
683 ctrl_panel.get("ri-duts-value"),
684 ctrl_panel.get("ri-ttypes-value"),
685 ctrl_panel.get("ri-cadences-value"),
686 ctrl_panel.get("dd-tbeds-value")
689 ctrl_panel.set({"al-job-children": job})
690 fig_passed, fig_duration = \
691 graph_statistics(self.data, job, self.layout)
697 gen_new_url(parsed_url, {"job": job})
699 ret_val.extend(ctrl_panel.values())
703 Output("download-data", "data"),
704 State("control-panel", "data"), # Store
705 Input("btn-download-data", "n_clicks"),
706 prevent_initial_call=True
708 def _download_data(cp_data: dict, n_clicks: int):
711 :param cp_data: Current status of the control panel stored in
713 :param n_clicks: Number of clicks on the button "Download".
716 :returns: dict of data frame content (base64 encoded) and meta data
717 used by the Download component.
723 ctrl_panel = self.ControlPanel(cp_data, self.default)
727 ctrl_panel.get("ri-duts-value"),
728 ctrl_panel.get("ri-ttypes-value"),
729 ctrl_panel.get("ri-cadences-value"),
730 ctrl_panel.get("dd-tbeds-value")
733 data = select_data(self.data, job)
734 data = data.drop(columns=["job", ])
736 return dcc.send_data_frame(
737 data.T.to_csv, f"{job}-{C.STATS_DOWNLOAD_FILE_NAME}")
740 Output("row-metadata", "children"),
741 Output("offcanvas-metadata", "is_open"),
742 Input("graph-passed", "clickData"),
743 Input("graph-duration", "clickData"),
744 prevent_initial_call=True
746 def _show_metadata_from_graphs(
747 passed_data: dict, duration_data: dict) -> tuple:
748 """Generates the data for the offcanvas displayed when a particular
749 point in a graph is clicked on.
751 :param passed_data: The data from the clicked point in the graph
752 displaying the pass/fail data.
753 :param duration_data: The data from the clicked point in the graph
754 displaying the duration data.
755 :type passed_data: dict
756 :type duration data: dict
757 :returns: The data to be displayed on the offcanvas (job statistics
758 and the list of failed tests) and the information to show the
760 :rtype: tuple(list, bool)
763 if not (passed_data or duration_data):
768 title = "Job Statistics"
769 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
770 if trigger_id == "graph-passed":
771 graph_data = passed_data["points"][0].get("hovertext", "")
772 elif trigger_id == "graph-duration":
773 graph_data = duration_data["points"][0].get("text", "")
775 lst_graph_data = graph_data.split("<br>")
777 # Prepare list of failed tests:
780 for itm in lst_graph_data:
781 if "csit-ref:" in itm:
782 job, build = itm.split(" ")[-1].split("/")
785 fail_tests = self.data.loc[
786 (self.data["job"] == job) &
787 (self.data["build"] == build)
788 ]["lst_failed"].values[0]
794 # Create the content of the offcanvas:
797 class_name="gy-2 p-0",
799 dbc.CardHeader(children=[
801 target_id="metadata",
803 style={"display": "inline-block"}
810 children=[dbc.ListGroup(
819 ) for x in lst_graph_data
828 if fail_tests is not None:
831 class_name="gy-2 p-0",
834 f"List of Failed Tests ({len(fail_tests)})"
839 children=[dbc.ListGroup(
841 dbc.ListGroupItem(x) \
853 return metadata, open_canvas