1 # Copyright (c) 2023 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(
87 [data_mrr, data_ndrpdr],
92 # Pre-process the data:
93 data_stats = data_stats[~data_stats.job.str.contains("-verify-")]
94 data_stats = data_stats[~data_stats.job.str.contains("-coverage-")]
95 data_stats = data_stats[~data_stats.job.str.contains("-iterative-")]
96 data_stats = data_stats[["job", "build", "start_time", "duration"]]
99 (datetime.utcnow() - data_stats["start_time"].min()).days
100 if self._time_period > data_time_period:
101 self._time_period = data_time_period
103 jobs = sorted(list(data_stats["job"].unique()))
112 lst_job = job.split("-")
113 d_job_info["job"].append(job)
114 d_job_info["dut"].append(lst_job[1])
115 d_job_info["ttype"].append(lst_job[3])
116 d_job_info["cadence"].append(lst_job[4])
117 d_job_info["tbed"].append("-".join(lst_job[-2:]))
118 self._job_info = pd.DataFrame.from_dict(d_job_info)
120 self._default = set_job_params(self._job_info, C.STATS_DEFAULT_JOB)
126 "dut_version": list(),
133 df_job = df_tst_info.loc[(df_tst_info["job"] == job)]
134 builds = df_job["build"].unique()
136 df_build = df_job.loc[(df_job["build"] == build)]
137 tst_info["job"].append(job)
138 tst_info["build"].append(build)
139 tst_info["dut_type"].append(df_build["dut_type"].iloc[-1])
140 tst_info["dut_version"].append(df_build["dut_version"].iloc[-1])
141 tst_info["hosts"].append(df_build["hosts"].iloc[-1])
143 passed = df_build.value_counts(subset="passed")[True]
147 failed = df_build.value_counts(subset="passed")[False]
148 failed_tests = df_build.loc[(df_build["passed"] == False)]\
149 ["test_id"].to_list()
151 for tst in failed_tests:
152 lst_tst = tst.split(".")
153 suite = lst_tst[-2].replace("2n1l-", "").\
154 replace("1n1l-", "").replace("2n-", "")
155 l_failed.append(f"{suite.split('-')[0]}-{lst_tst[-1]}")
159 tst_info["passed"].append(passed)
160 tst_info["failed"].append(failed)
161 tst_info["lst_failed"].append(sorted(l_failed))
163 self._data = data_stats.merge(pd.DataFrame.from_dict(tst_info))
166 self._html_layout = str()
167 self._graph_layout = None
168 self._tooltips = dict()
171 with open(self._html_layout_file, "r") as file_read:
172 self._html_layout = file_read.read()
173 except IOError as err:
175 f"Not possible to open the file {self._html_layout_file}\n{err}"
179 with open(self._graph_layout_file, "r") as file_read:
180 self._graph_layout = load(file_read, Loader=FullLoader)
181 except IOError as err:
183 f"Not possible to open the file {self._graph_layout_file}\n"
186 except YAMLError as err:
188 f"An error occurred while parsing the specification file "
189 f"{self._graph_layout_file}\n{err}"
193 with open(self._tooltip_file, "r") as file_read:
194 self._tooltips = load(file_read, Loader=FullLoader)
195 except IOError as err:
197 f"Not possible to open the file {self._tooltip_file}\n{err}"
199 except YAMLError as err:
201 f"An error occurred while parsing the specification file "
202 f"{self._tooltip_file}\n{err}"
205 # Control panel partameters and their default values.
207 "ri-ttypes-options": self._default["ttypes"],
208 "ri-cadences-options": self._default["cadences"],
209 "dd-tbeds-options": self._default["tbeds"],
210 "ri-duts-value": self._default["dut"],
211 "ri-ttypes-value": self._default["ttype"],
212 "ri-cadences-value": self._default["cadence"],
213 "dd-tbeds-value": self._default["tbed"],
214 "al-job-children": self._default["job"]
218 if self._app is not None and hasattr(self, "callbacks"):
219 self.callbacks(self._app)
222 def html_layout(self) -> dict:
223 return self._html_layout
225 def add_content(self):
226 """Top level method which generated the web page.
229 - Store for user input data,
231 - Main area with control panel and ploting area.
233 If no HTML layout is provided, an error message is displayed instead.
235 :returns: The HTML div with the whole page.
244 dcc.Store(id="control-panel"),
245 dcc.Location(id="url", refresh=False),
256 id="offcanvas-metadata",
257 title="Detailed Information",
261 dbc.Row(id="row-metadata")
269 self._add_ctrl_col(),
270 self._add_plotting_col()
288 def _add_navbar(self):
289 """Add nav element with navigation panel. It is placed on the top.
291 :returns: Navigation bar.
292 :rtype: dbc.NavbarSimple
294 return dbc.NavbarSimple(
295 id="navbarsimple-main",
308 brand_external_link=True,
313 def _add_ctrl_col(self) -> dbc.Col:
314 """Add column with controls. It is placed on the left side.
316 :returns: Column with the control panel.
321 children=self._add_ctrl_panel(),
322 className="sticky-top"
326 def _add_plotting_col(self) -> dbc.Col:
327 """Add column with plots and tables. It is placed on the right side.
329 :returns: Column with tables.
333 id="col-plotting-area",
339 class_name="g-0 p-0",
350 def _add_ctrl_panel(self) -> dbc.Row:
351 """Add control panel.
353 :returns: Control panel.
358 class_name="g-0 p-1",
363 children=show_tooltip(
372 value=self._default["dut"],
373 options=self._default["duts"],
374 class_name="form-control"
382 class_name="g-0 p-1",
387 children=show_tooltip(
396 value=self._default["ttype"],
397 options=self._default["ttypes"],
398 class_name="form-control"
406 class_name="g-0 p-1",
411 children=show_tooltip(
420 value=self._default["cadence"],
421 options=self._default["cadences"],
422 class_name="form-control"
430 class_name="g-0 p-1",
435 children=show_tooltip(
443 placeholder="Select a test bed...",
444 value=self._default["tbed"],
445 options=self._default["tbeds"]
453 class_name="g-0 p-1",
458 children=self._default["job"]
464 def _get_plotting_area(
469 """Generate the plotting area with all its content.
471 :param job: The job which data will be displayed.
472 :param url: URL to be displayed in the modal window.
475 :returns: List of rows with elements to be displayed in the plotting
480 figs = graph_statistics(self._data, job, self._graph_layout)
487 id="row-graph-passed",
488 class_name="g-0 p-1",
497 id="row-graph-duration",
498 class_name="g-0 p-1",
516 "text-transform": "none",
517 "padding": "0rem 1rem"
522 dbc.ModalHeader(dbc.ModalTitle("URL")),
531 id="plot-btn-download",
532 children="Download Data",
536 "text-transform": "none",
537 "padding": "0rem 1rem"
540 dcc.Download(id="download-stats-data")
543 "d-grid gap-0 d-md-flex justify-content-md-end"
550 def callbacks(self, app):
551 """Callbacks for the whole application.
553 :param app: The application.
558 Output("control-panel", "data"), # Store
559 Output("plotting-area", "children"),
560 Output("ri-ttypes", "options"),
561 Output("ri-cadences", "options"),
562 Output("dd-tbeds", "options"),
563 Output("ri-duts", "value"),
564 Output("ri-ttypes", "value"),
565 Output("ri-cadences", "value"),
566 Output("dd-tbeds", "value"),
567 Output("al-job", "children"),
568 State("control-panel", "data"), # Store
569 Input("ri-duts", "value"),
570 Input("ri-ttypes", "value"),
571 Input("ri-cadences", "value"),
572 Input("dd-tbeds", "value"),
575 def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str, cadence:str,
576 tbed: str, href: str) -> tuple:
577 """Update the application when the event is detected.
579 :param cp_data: Current status of the control panel stored in
581 :param dut: Input - DUT name.
582 :param ttype: Input - Test type.
583 :param cadence: Input - The cadence of the job.
584 :param tbed: Input - The test bed.
585 :param href: Input - The URL provided by the browser.
592 :returns: New values for web page elements.
596 ctrl_panel = ControlPanel(self._cp_default, cp_data)
599 parsed_url = url_decode(href)
601 url_params = parsed_url["params"]
605 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
606 if trigger_id == "ri-duts":
607 ttype_opts = generate_options(get_ttypes(self._job_info, dut))
608 ttype_val = ttype_opts[0]["value"]
609 cad_opts = generate_options(get_cadences(
610 self._job_info, dut, ttype_val))
611 cad_val = cad_opts[0]["value"]
612 tbed_opts = generate_options(get_test_beds(
613 self._job_info, dut, ttype_val, cad_val))
614 tbed_val = tbed_opts[0]["value"]
616 "ri-duts-value": dut,
617 "ri-ttypes-options": ttype_opts,
618 "ri-ttypes-value": ttype_val,
619 "ri-cadences-options": cad_opts,
620 "ri-cadences-value": cad_val,
621 "dd-tbeds-options": tbed_opts,
622 "dd-tbeds-value": tbed_val
624 elif trigger_id == "ri-ttypes":
625 cad_opts = generate_options(get_cadences(
626 self._job_info, ctrl_panel.get("ri-duts-value"), ttype))
627 cad_val = cad_opts[0]["value"]
628 tbed_opts = generate_options(get_test_beds(
629 self._job_info, ctrl_panel.get("ri-duts-value"), ttype,
631 tbed_val = tbed_opts[0]["value"]
633 "ri-ttypes-value": ttype,
634 "ri-cadences-options": cad_opts,
635 "ri-cadences-value": cad_val,
636 "dd-tbeds-options": tbed_opts,
637 "dd-tbeds-value": tbed_val
639 elif trigger_id == "ri-cadences":
640 tbed_opts = generate_options(get_test_beds(
641 self._job_info, ctrl_panel.get("ri-duts-value"),
642 ctrl_panel.get("ri-ttypes-value"), cadence))
643 tbed_val = tbed_opts[0]["value"]
645 "ri-cadences-value": cadence,
646 "dd-tbeds-options": tbed_opts,
647 "dd-tbeds-value": tbed_val
649 elif trigger_id == "dd-tbeds":
651 "dd-tbeds-value": tbed
653 elif trigger_id == "url":
655 new_job = url_params.get("job", list())[0]
657 job_params = set_job_params(self._job_info, new_job)
658 ctrl_panel = ControlPanel(
660 "ri-ttypes-options": job_params["ttypes"],
661 "ri-cadences-options": job_params["cadences"],
662 "dd-tbeds-options": job_params["tbeds"],
663 "ri-duts-value": job_params["dut"],
664 "ri-ttypes-value": job_params["ttype"],
665 "ri-cadences-value": job_params["cadence"],
666 "dd-tbeds-value": job_params["tbed"],
667 "al-job-children": job_params["job"]
672 ctrl_panel = ControlPanel(self._cp_default, cp_data)
676 ctrl_panel.get("ri-duts-value"),
677 ctrl_panel.get("ri-ttypes-value"),
678 ctrl_panel.get("ri-cadences-value"),
679 ctrl_panel.get("dd-tbeds-value")
682 ctrl_panel.set({"al-job-children": job})
683 plotting_area = self._get_plotting_area(
685 gen_new_url(parsed_url, {"job": job})
692 ret_val.extend(ctrl_panel.values)
696 Output("plot-mod-url", "is_open"),
697 [Input("plot-btn-url", "n_clicks")],
698 [State("plot-mod-url", "is_open")],
700 def toggle_plot_mod_url(n, is_open):
701 """Toggle the modal window with url.
708 Output("download-stats-data", "data"),
709 State("control-panel", "data"), # Store
710 Input("plot-btn-download", "n_clicks"),
711 prevent_initial_call=True
713 def _download_data(cp_data: dict, n_clicks: int):
716 :param cp_data: Current status of the control panel stored in
718 :param n_clicks: Number of clicks on the button "Download".
721 :returns: dict of data frame content (base64 encoded) and meta data
722 used by the Download component.
728 ctrl_panel = ControlPanel(self._cp_default, cp_data)
732 ctrl_panel.get("ri-duts-value"),
733 ctrl_panel.get("ri-ttypes-value"),
734 ctrl_panel.get("ri-cadences-value"),
735 ctrl_panel.get("dd-tbeds-value")
738 data = select_data(self._data, job)
739 data = data.drop(columns=["job", ])
741 return dcc.send_data_frame(
742 data.T.to_csv, f"{job}-{C.STATS_DOWNLOAD_FILE_NAME}")
745 Output("row-metadata", "children"),
746 Output("offcanvas-metadata", "is_open"),
747 Input("graph-passed", "clickData"),
748 Input("graph-duration", "clickData"),
749 prevent_initial_call=True
751 def _show_metadata_from_graphs(
752 passed_data: dict, duration_data: dict) -> tuple:
753 """Generates the data for the offcanvas displayed when a particular
754 point in a graph is clicked on.
756 :param passed_data: The data from the clicked point in the graph
757 displaying the pass/fail data.
758 :param duration_data: The data from the clicked point in the graph
759 displaying the duration data.
760 :type passed_data: dict
761 :type duration data: dict
762 :returns: The data to be displayed on the offcanvas (job statistics
763 and the list of failed tests) and the information to show the
765 :rtype: tuple(list, bool)
768 if not (passed_data or duration_data):
773 title = "Job Statistics"
774 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
775 if trigger_id == "graph-passed":
776 graph_data = passed_data["points"][0].get("hovertext", "")
777 elif trigger_id == "graph-duration":
778 graph_data = duration_data["points"][0].get("text", "")
780 lst_graph_data = graph_data.split("<br>")
782 # Prepare list of failed tests:
785 for itm in lst_graph_data:
786 if "csit-ref:" in itm:
787 job, build = itm.split(" ")[-1].split("/")
790 fail_tests = self._data.loc[
791 (self._data["job"] == job) &
792 (self._data["build"] == build)
793 ]["lst_failed"].values[0]
799 # Create the content of the offcanvas:
802 class_name="gy-2 p-0",
804 dbc.CardHeader(children=[
806 target_id="metadata",
808 style={"display": "inline-block"}
815 children=[dbc.ListGroup(
824 ) for x in lst_graph_data
833 if fail_tests is not None:
836 class_name="gy-2 p-0",
839 f"List of Failed Tests ({len(fail_tests)})"
844 children=[dbc.ListGroup(
846 dbc.ListGroupItem(x) \
858 return metadata, open_canvas