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",
357 children=show_tooltip(
366 value=self._default["dut"],
367 options=self._default["duts"],
368 input_class_name="border-info bg-info"
373 class_name="g-0 p-1",
376 children=show_tooltip(
385 value=self._default["ttype"],
386 options=self._default["ttypes"],
387 input_class_name="border-info bg-info"
392 class_name="g-0 p-1",
395 children=show_tooltip(
404 value=self._default["cadence"],
405 options=self._default["cadences"],
406 input_class_name="border-info bg-info"
411 class_name="g-0 p-1",
414 children=show_tooltip(
422 placeholder="Select a test bed...",
423 value=self._default["tbed"],
424 options=self._default["tbeds"]
429 class_name="g-0 p-1",
434 children=self._default["job"]
440 def _get_plotting_area(
445 """Generate the plotting area with all its content.
447 :param job: The job which data will be displayed.
448 :param url: URL to be displayed in the modal window.
451 :returns: List of rows with elements to be displayed in the plotting
456 figs = graph_statistics(self._data, job, self._graph_layout)
463 id="row-graph-passed",
464 class_name="g-0 p-1",
473 id="row-graph-duration",
474 class_name="g-0 p-1",
492 "text-transform": "none",
493 "padding": "0rem 1rem"
498 dbc.ModalHeader(dbc.ModalTitle("URL")),
507 id="plot-btn-download",
508 children="Download Data",
512 "text-transform": "none",
513 "padding": "0rem 1rem"
516 dcc.Download(id="download-stats-data")
519 "d-grid gap-0 d-md-flex justify-content-md-end"
526 def callbacks(self, app):
527 """Callbacks for the whole application.
529 :param app: The application.
534 Output("control-panel", "data"), # Store
535 Output("plotting-area", "children"),
536 Output("ri-ttypes", "options"),
537 Output("ri-cadences", "options"),
538 Output("dd-tbeds", "options"),
539 Output("ri-duts", "value"),
540 Output("ri-ttypes", "value"),
541 Output("ri-cadences", "value"),
542 Output("dd-tbeds", "value"),
543 Output("al-job", "children"),
544 State("control-panel", "data"), # Store
545 Input("ri-duts", "value"),
546 Input("ri-ttypes", "value"),
547 Input("ri-cadences", "value"),
548 Input("dd-tbeds", "value"),
551 def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str, cadence:str,
552 tbed: str, href: str) -> tuple:
553 """Update the application when the event is detected.
555 :param cp_data: Current status of the control panel stored in
557 :param dut: Input - DUT name.
558 :param ttype: Input - Test type.
559 :param cadence: Input - The cadence of the job.
560 :param tbed: Input - The test bed.
561 :param href: Input - The URL provided by the browser.
568 :returns: New values for web page elements.
572 ctrl_panel = ControlPanel(self._cp_default, cp_data)
575 parsed_url = url_decode(href)
577 url_params = parsed_url["params"]
581 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
582 if trigger_id == "ri-duts":
583 ttype_opts = generate_options(get_ttypes(self._job_info, dut))
584 ttype_val = ttype_opts[0]["value"]
585 cad_opts = generate_options(get_cadences(
586 self._job_info, dut, ttype_val))
587 cad_val = cad_opts[0]["value"]
588 tbed_opts = generate_options(get_test_beds(
589 self._job_info, dut, ttype_val, cad_val))
590 tbed_val = tbed_opts[0]["value"]
592 "ri-duts-value": dut,
593 "ri-ttypes-options": ttype_opts,
594 "ri-ttypes-value": ttype_val,
595 "ri-cadences-options": cad_opts,
596 "ri-cadences-value": cad_val,
597 "dd-tbeds-options": tbed_opts,
598 "dd-tbeds-value": tbed_val
600 elif trigger_id == "ri-ttypes":
601 cad_opts = generate_options(get_cadences(
602 self._job_info, ctrl_panel.get("ri-duts-value"), ttype))
603 cad_val = cad_opts[0]["value"]
604 tbed_opts = generate_options(get_test_beds(
605 self._job_info, ctrl_panel.get("ri-duts-value"), ttype,
607 tbed_val = tbed_opts[0]["value"]
609 "ri-ttypes-value": ttype,
610 "ri-cadences-options": cad_opts,
611 "ri-cadences-value": cad_val,
612 "dd-tbeds-options": tbed_opts,
613 "dd-tbeds-value": tbed_val
615 elif trigger_id == "ri-cadences":
616 tbed_opts = generate_options(get_test_beds(
617 self._job_info, ctrl_panel.get("ri-duts-value"),
618 ctrl_panel.get("ri-ttypes-value"), cadence))
619 tbed_val = tbed_opts[0]["value"]
621 "ri-cadences-value": cadence,
622 "dd-tbeds-options": tbed_opts,
623 "dd-tbeds-value": tbed_val
625 elif trigger_id == "dd-tbeds":
627 "dd-tbeds-value": tbed
629 elif trigger_id == "url":
631 new_job = url_params.get("job", list())[0]
633 job_params = set_job_params(self._job_info, new_job)
634 ctrl_panel = ControlPanel(
636 "ri-ttypes-options": job_params["ttypes"],
637 "ri-cadences-options": job_params["cadences"],
638 "dd-tbeds-options": job_params["tbeds"],
639 "ri-duts-value": job_params["dut"],
640 "ri-ttypes-value": job_params["ttype"],
641 "ri-cadences-value": job_params["cadence"],
642 "dd-tbeds-value": job_params["tbed"],
643 "al-job-children": job_params["job"]
648 ctrl_panel = ControlPanel(self._cp_default, cp_data)
652 ctrl_panel.get("ri-duts-value"),
653 ctrl_panel.get("ri-ttypes-value"),
654 ctrl_panel.get("ri-cadences-value"),
655 ctrl_panel.get("dd-tbeds-value")
658 ctrl_panel.set({"al-job-children": job})
659 plotting_area = self._get_plotting_area(
661 gen_new_url(parsed_url, {"job": job})
668 ret_val.extend(ctrl_panel.values)
672 Output("plot-mod-url", "is_open"),
673 [Input("plot-btn-url", "n_clicks")],
674 [State("plot-mod-url", "is_open")],
676 def toggle_plot_mod_url(n, is_open):
677 """Toggle the modal window with url.
684 Output("download-stats-data", "data"),
685 State("control-panel", "data"), # Store
686 Input("plot-btn-download", "n_clicks"),
687 prevent_initial_call=True
689 def _download_data(cp_data: dict, n_clicks: int):
692 :param cp_data: Current status of the control panel stored in
694 :param n_clicks: Number of clicks on the button "Download".
697 :returns: dict of data frame content (base64 encoded) and meta data
698 used by the Download component.
704 ctrl_panel = ControlPanel(self._cp_default, cp_data)
708 ctrl_panel.get("ri-duts-value"),
709 ctrl_panel.get("ri-ttypes-value"),
710 ctrl_panel.get("ri-cadences-value"),
711 ctrl_panel.get("dd-tbeds-value")
714 data = select_data(self._data, job)
715 data = data.drop(columns=["job", ])
717 return dcc.send_data_frame(
718 data.T.to_csv, f"{job}-{C.STATS_DOWNLOAD_FILE_NAME}")
721 Output("row-metadata", "children"),
722 Output("offcanvas-metadata", "is_open"),
723 Input("graph-passed", "clickData"),
724 Input("graph-duration", "clickData"),
725 prevent_initial_call=True
727 def _show_metadata_from_graphs(
728 passed_data: dict, duration_data: dict) -> tuple:
729 """Generates the data for the offcanvas displayed when a particular
730 point in a graph is clicked on.
732 :param passed_data: The data from the clicked point in the graph
733 displaying the pass/fail data.
734 :param duration_data: The data from the clicked point in the graph
735 displaying the duration data.
736 :type passed_data: dict
737 :type duration data: dict
738 :returns: The data to be displayed on the offcanvas (job statistics
739 and the list of failed tests) and the information to show the
741 :rtype: tuple(list, bool)
744 if not (passed_data or duration_data):
749 title = "Job Statistics"
750 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
751 if trigger_id == "graph-passed":
752 graph_data = passed_data["points"][0].get("hovertext", "")
753 elif trigger_id == "graph-duration":
754 graph_data = duration_data["points"][0].get("text", "")
756 lst_graph_data = graph_data.split("<br>")
758 # Prepare list of failed tests:
761 for itm in lst_graph_data:
762 if "csit-ref:" in itm:
763 job, build = itm.split(" ")[-1].split("/")
766 fail_tests = self._data.loc[
767 (self._data["job"] == job) &
768 (self._data["build"] == build)
769 ]["lst_failed"].values[0]
775 # Create the content of the offcanvas:
778 class_name="gy-2 p-0",
780 dbc.CardHeader(children=[
782 target_id="metadata",
784 style={"display": "inline-block"}
791 children=[dbc.ListGroup(
800 ) for x in lst_graph_data
809 if fail_tests is not None:
812 class_name="gy-2 p-0",
815 f"List of Failed Tests ({len(fail_tests)})"
820 children=[dbc.ListGroup(
822 dbc.ListGroupItem(x) \
834 return metadata, open_canvas