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
30 from ..utils.constants import Constants as C
31 from ..utils.control_panel import ControlPanel
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 = str()
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}"
201 # Control panel partameters and their default values.
203 "ri-ttypes-options": self._default["ttypes"],
204 "ri-cadences-options": self._default["cadences"],
205 "dd-tbeds-options": self._default["tbeds"],
206 "ri-duts-value": self._default["dut"],
207 "ri-ttypes-value": self._default["ttype"],
208 "ri-cadences-value": self._default["cadence"],
209 "dd-tbeds-value": self._default["tbed"],
210 "al-job-children": self._default["job"]
214 if self._app is not None and hasattr(self, "callbacks"):
215 self.callbacks(self._app)
218 def html_layout(self) -> dict:
219 return self._html_layout
221 def add_content(self):
222 """Top level method which generated the web page.
225 - Store for user input data,
227 - Main area with control panel and ploting area.
229 If no HTML layout is provided, an error message is displayed instead.
231 :returns: The HTML div with the whole page.
240 dcc.Store(id="control-panel"),
241 dcc.Location(id="url", refresh=False),
252 id="offcanvas-metadata",
253 title="Detailed Information",
257 dbc.Row(id="row-metadata")
265 self._add_ctrl_col(),
266 self._add_plotting_col()
284 def _add_navbar(self):
285 """Add nav element with navigation panel. It is placed on the top.
287 :returns: Navigation bar.
288 :rtype: dbc.NavbarSimple
290 return dbc.NavbarSimple(
291 id="navbarsimple-main",
304 brand_external_link=True,
309 def _add_ctrl_col(self) -> dbc.Col:
310 """Add column with controls. It is placed on the left side.
312 :returns: Column with the control panel.
317 children=self._add_ctrl_panel(),
318 className="sticky-top"
322 def _add_plotting_col(self) -> dbc.Col:
323 """Add column with plots and tables. It is placed on the right side.
325 :returns: Column with tables.
329 id="col-plotting-area",
335 class_name="g-0 p-0",
346 def _add_ctrl_panel(self) -> dbc.Row:
347 """Add control panel.
349 :returns: Control panel.
354 class_name="g-0 p-1",
359 children=show_tooltip(
368 value=self._default["dut"],
369 options=self._default["duts"],
370 class_name="form-control"
378 class_name="g-0 p-1",
383 children=show_tooltip(
392 value=self._default["ttype"],
393 options=self._default["ttypes"],
394 class_name="form-control"
402 class_name="g-0 p-1",
407 children=show_tooltip(
416 value=self._default["cadence"],
417 options=self._default["cadences"],
418 class_name="form-control"
426 class_name="g-0 p-1",
431 children=show_tooltip(
439 placeholder="Select a test bed...",
440 value=self._default["tbed"],
441 options=self._default["tbeds"]
449 class_name="g-0 p-1",
454 children=self._default["job"]
460 def _get_plotting_area(
465 """Generate the plotting area with all its content.
467 :param job: The job which data will be displayed.
468 :param url: URL to be displayed in the modal window.
471 :returns: List of rows with elements to be displayed in the plotting
476 figs = graph_statistics(self._data, job, self._graph_layout)
483 id="row-graph-passed",
484 class_name="g-0 p-1",
493 id="row-graph-duration",
494 class_name="g-0 p-1",
512 "text-transform": "none",
513 "padding": "0rem 1rem"
518 dbc.ModalHeader(dbc.ModalTitle("URL")),
527 id="plot-btn-download",
528 children="Download Data",
532 "text-transform": "none",
533 "padding": "0rem 1rem"
536 dcc.Download(id="download-stats-data")
539 "d-grid gap-0 d-md-flex justify-content-md-end"
546 def callbacks(self, app):
547 """Callbacks for the whole application.
549 :param app: The application.
554 Output("control-panel", "data"), # Store
555 Output("plotting-area", "children"),
556 Output("ri-ttypes", "options"),
557 Output("ri-cadences", "options"),
558 Output("dd-tbeds", "options"),
559 Output("ri-duts", "value"),
560 Output("ri-ttypes", "value"),
561 Output("ri-cadences", "value"),
562 Output("dd-tbeds", "value"),
563 Output("al-job", "children"),
564 State("control-panel", "data"), # Store
565 Input("ri-duts", "value"),
566 Input("ri-ttypes", "value"),
567 Input("ri-cadences", "value"),
568 Input("dd-tbeds", "value"),
571 def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str, cadence:str,
572 tbed: str, href: str) -> tuple:
573 """Update the application when the event is detected.
575 :param cp_data: Current status of the control panel stored in
577 :param dut: Input - DUT name.
578 :param ttype: Input - Test type.
579 :param cadence: Input - The cadence of the job.
580 :param tbed: Input - The test bed.
581 :param href: Input - The URL provided by the browser.
588 :returns: New values for web page elements.
592 ctrl_panel = ControlPanel(self._cp_default, cp_data)
595 parsed_url = url_decode(href)
597 url_params = parsed_url["params"]
601 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
602 if trigger_id == "ri-duts":
603 ttype_opts = generate_options(get_ttypes(self._job_info, dut))
604 ttype_val = ttype_opts[0]["value"]
605 cad_opts = generate_options(get_cadences(
606 self._job_info, dut, ttype_val))
607 cad_val = cad_opts[0]["value"]
608 tbed_opts = generate_options(get_test_beds(
609 self._job_info, dut, ttype_val, cad_val))
610 tbed_val = tbed_opts[0]["value"]
612 "ri-duts-value": dut,
613 "ri-ttypes-options": ttype_opts,
614 "ri-ttypes-value": ttype_val,
615 "ri-cadences-options": cad_opts,
616 "ri-cadences-value": cad_val,
617 "dd-tbeds-options": tbed_opts,
618 "dd-tbeds-value": tbed_val
620 elif trigger_id == "ri-ttypes":
621 cad_opts = generate_options(get_cadences(
622 self._job_info, ctrl_panel.get("ri-duts-value"), ttype))
623 cad_val = cad_opts[0]["value"]
624 tbed_opts = generate_options(get_test_beds(
625 self._job_info, ctrl_panel.get("ri-duts-value"), ttype,
627 tbed_val = tbed_opts[0]["value"]
629 "ri-ttypes-value": ttype,
630 "ri-cadences-options": cad_opts,
631 "ri-cadences-value": cad_val,
632 "dd-tbeds-options": tbed_opts,
633 "dd-tbeds-value": tbed_val
635 elif trigger_id == "ri-cadences":
636 tbed_opts = generate_options(get_test_beds(
637 self._job_info, ctrl_panel.get("ri-duts-value"),
638 ctrl_panel.get("ri-ttypes-value"), cadence))
639 tbed_val = tbed_opts[0]["value"]
641 "ri-cadences-value": cadence,
642 "dd-tbeds-options": tbed_opts,
643 "dd-tbeds-value": tbed_val
645 elif trigger_id == "dd-tbeds":
647 "dd-tbeds-value": tbed
649 elif trigger_id == "url":
651 new_job = url_params.get("job", list())[0]
653 job_params = set_job_params(self._job_info, new_job)
654 ctrl_panel = ControlPanel(
656 "ri-ttypes-options": job_params["ttypes"],
657 "ri-cadences-options": job_params["cadences"],
658 "dd-tbeds-options": job_params["tbeds"],
659 "ri-duts-value": job_params["dut"],
660 "ri-ttypes-value": job_params["ttype"],
661 "ri-cadences-value": job_params["cadence"],
662 "dd-tbeds-value": job_params["tbed"],
663 "al-job-children": job_params["job"]
668 ctrl_panel = ControlPanel(self._cp_default, cp_data)
672 ctrl_panel.get("ri-duts-value"),
673 ctrl_panel.get("ri-ttypes-value"),
674 ctrl_panel.get("ri-cadences-value"),
675 ctrl_panel.get("dd-tbeds-value")
678 ctrl_panel.set({"al-job-children": job})
679 plotting_area = self._get_plotting_area(
681 gen_new_url(parsed_url, {"job": job})
688 ret_val.extend(ctrl_panel.values)
692 Output("plot-mod-url", "is_open"),
693 [Input("plot-btn-url", "n_clicks")],
694 [State("plot-mod-url", "is_open")],
696 def toggle_plot_mod_url(n, is_open):
697 """Toggle the modal window with url.
704 Output("download-stats-data", "data"),
705 State("control-panel", "data"), # Store
706 Input("plot-btn-download", "n_clicks"),
707 prevent_initial_call=True
709 def _download_data(cp_data: dict, n_clicks: int):
712 :param cp_data: Current status of the control panel stored in
714 :param n_clicks: Number of clicks on the button "Download".
717 :returns: dict of data frame content (base64 encoded) and meta data
718 used by the Download component.
724 ctrl_panel = ControlPanel(self._cp_default, cp_data)
728 ctrl_panel.get("ri-duts-value"),
729 ctrl_panel.get("ri-ttypes-value"),
730 ctrl_panel.get("ri-cadences-value"),
731 ctrl_panel.get("dd-tbeds-value")
734 data = select_data(self._data, job)
735 data = data.drop(columns=["job", ])
737 return dcc.send_data_frame(
738 data.T.to_csv, f"{job}-{C.STATS_DOWNLOAD_FILE_NAME}")
741 Output("row-metadata", "children"),
742 Output("offcanvas-metadata", "is_open"),
743 Input("graph-passed", "clickData"),
744 Input("graph-duration", "clickData"),
745 prevent_initial_call=True
747 def _show_metadata_from_graphs(
748 passed_data: dict, duration_data: dict) -> tuple:
749 """Generates the data for the offcanvas displayed when a particular
750 point in a graph is clicked on.
752 :param passed_data: The data from the clicked point in the graph
753 displaying the pass/fail data.
754 :param duration_data: The data from the clicked point in the graph
755 displaying the duration data.
756 :type passed_data: dict
757 :type duration data: dict
758 :returns: The data to be displayed on the offcanvas (job statistics
759 and the list of failed tests) and the information to show the
761 :rtype: tuple(list, bool)
764 if not (passed_data or duration_data):
769 title = "Job Statistics"
770 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
771 if trigger_id == "graph-passed":
772 graph_data = passed_data["points"][0].get("hovertext", "")
773 elif trigger_id == "graph-duration":
774 graph_data = duration_data["points"][0].get("text", "")
776 lst_graph_data = graph_data.split("<br>")
778 # Prepare list of failed tests:
781 for itm in lst_graph_data:
782 if "csit-ref:" in itm:
783 job, build = itm.split(" ")[-1].split("/")
786 fail_tests = self._data.loc[
787 (self._data["job"] == job) &
788 (self._data["build"] == build)
789 ]["lst_failed"].values[0]
795 # Create the content of the offcanvas:
798 class_name="gy-2 p-0",
800 dbc.CardHeader(children=[
802 target_id="metadata",
804 style={"display": "inline-block"}
811 children=[dbc.ListGroup(
820 ) for x in lst_graph_data
829 if fail_tests is not None:
832 class_name="gy-2 p-0",
835 f"List of Failed Tests ({len(fail_tests)})"
840 children=[dbc.ListGroup(
842 dbc.ListGroupItem(x) \
854 return metadata, open_canvas