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 replace("_title_", C.STATS_TITLE)
170 except IOError as err:
172 f"Not possible to open the file {self._html_layout_file}\n{err}"
176 with open(self._graph_layout_file, "r") as file_read:
177 self._graph_layout = load(file_read, Loader=FullLoader)
178 except IOError as err:
180 f"Not possible to open the file {self._graph_layout_file}\n"
183 except YAMLError as err:
185 f"An error occurred while parsing the specification file "
186 f"{self._graph_layout_file}\n{err}"
190 with open(self._tooltip_file, "r") as file_read:
191 self._tooltips = load(file_read, Loader=FullLoader)
192 except IOError as err:
194 f"Not possible to open the file {self._tooltip_file}\n{err}"
196 except YAMLError as err:
198 f"An error occurred while parsing the specification file "
199 f"{self._tooltip_file}\n{err}"
203 self._default_fig_passed, self._default_fig_duration = graph_statistics(
204 self.data, self._default["job"], self.layout
208 if self._app is not None and hasattr(self, 'callbacks'):
209 self.callbacks(self._app)
212 def html_layout(self) -> dict:
213 return self._html_layout
216 def data(self) -> pd.DataFrame:
220 def layout(self) -> dict:
221 return self._graph_layout
224 def time_period(self) -> int:
225 return self._time_period
228 def default(self) -> any:
231 def add_content(self):
232 """Top level method which generated the web page.
235 - Store for user input data,
237 - Main area with control panel and ploting area.
239 If no HTML layout is provided, an error message is displayed instead.
241 :returns: The HTML div with the whole page.
250 dcc.Store(id="control-panel"),
251 dcc.Location(id="url", refresh=False),
262 id="offcanvas-metadata",
263 title="Detailed Information",
267 dbc.Row(id="row-metadata")
275 self._add_ctrl_col(),
276 self._add_plotting_col(),
294 def _add_navbar(self):
295 """Add nav element with navigation panel. It is placed on the top.
297 :returns: Navigation bar.
298 :rtype: dbc.NavbarSimple
300 return dbc.NavbarSimple(
301 id="navbarsimple-main",
314 brand_external_link=True,
319 def _add_ctrl_col(self) -> dbc.Col:
320 """Add column with controls. It is placed on the left side.
322 :returns: Column with the control panel.
327 children=self._add_ctrl_panel(),
328 className="sticky-top"
332 def _add_plotting_col(self) -> dbc.Col:
333 """Add column with plots and tables. It is placed on the right side.
335 :returns: Column with tables.
339 id="col-plotting-area",
341 dbc.Row( # Passed / failed tests
342 id="row-graph-passed",
343 class_name="g-0 p-2",
345 dcc.Loading(children=[
348 figure=self._default_fig_passed
354 id="row-graph-duration",
355 class_name="g-0 p-2",
357 dcc.Loading(children=[
360 figure=self._default_fig_duration
366 class_name="g-0 p-2",
373 dcc.Loading(children=[
375 id="btn-download-data",
376 children=show_tooltip(self._tooltips,
377 "help-download", "Download Data"),
381 dcc.Download(id="download-data")
393 children=show_tooltip(
416 def _add_ctrl_panel(self) -> dbc.Row:
417 """Add control panel.
419 :returns: Control panel.
424 class_name="g-0 p-1",
427 children=show_tooltip(self._tooltips,
428 "help-dut", "Device under Test")
433 value=self.default["dut"],
434 options=self.default["duts"]
439 class_name="g-0 p-1",
442 children=show_tooltip(self._tooltips,
443 "help-ttype", "Test Type"),
448 value=self.default["ttype"],
449 options=self.default["ttypes"]
454 class_name="g-0 p-1",
457 children=show_tooltip(self._tooltips,
458 "help-cadence", "Cadence"),
463 value=self.default["cadence"],
464 options=self.default["cadences"]
469 class_name="g-0 p-1",
472 children=show_tooltip(self._tooltips,
473 "help-tbed", "Test Bed"),
477 placeholder="Select a test bed...",
478 value=self.default["tbed"],
479 options=self.default["tbeds"]
484 class_name="g-0 p-1",
489 children=self.default["job"]
496 """A class representing the control panel.
499 def __init__(self, panel: dict, default: dict) -> None:
500 """Initialisation of the control pannel by default values. If
501 particular values are provided (parameter "panel") they are set
504 :param panel: Custom values to be set to the control panel.
505 :param default: Default values to be set to the control panel.
511 "ri-ttypes-options": default["ttypes"],
512 "ri-cadences-options": default["cadences"],
513 "dd-tbeds-options": default["tbeds"],
514 "ri-duts-value": default["dut"],
515 "ri-ttypes-value": default["ttype"],
516 "ri-cadences-value": default["cadence"],
517 "dd-tbeds-value": default["tbed"],
518 "al-job-children": default["job"]
520 self._panel = deepcopy(self._defaults)
522 for key in self._defaults:
523 self._panel[key] = panel[key]
525 def set(self, kwargs: dict) -> None:
526 """Set the values of the Control panel.
528 :param kwargs: key - value pairs to be set.
530 :raises KeyError: If the key in kwargs is not present in the Control
533 for key, val in kwargs.items():
534 if key in self._panel:
535 self._panel[key] = val
537 raise KeyError(f"The key {key} is not defined.")
540 def defaults(self) -> dict:
541 return self._defaults
544 def panel(self) -> dict:
547 def get(self, key: str) -> any:
548 """Returns the value of a key from the Control panel.
550 :param key: The key which value should be returned.
552 :returns: The value of the key.
554 :raises KeyError: If the key in kwargs is not present in the Control
557 return self._panel[key]
559 def values(self) -> list:
560 """Returns the values from the Control panel as a list.
562 :returns: The values from the Control panel.
565 return list(self._panel.values())
568 def callbacks(self, app):
569 """Callbacks for the whole application.
571 :param app: The application.
576 Output("control-panel", "data"), # Store
577 Output("graph-passed", "figure"),
578 Output("graph-duration", "figure"),
579 Output("input-url", "value"),
580 Output("ri-ttypes", "options"),
581 Output("ri-cadences", "options"),
582 Output("dd-tbeds", "options"),
583 Output("ri-duts", "value"),
584 Output("ri-ttypes", "value"),
585 Output("ri-cadences", "value"),
586 Output("dd-tbeds", "value"),
587 Output("al-job", "children"),
588 State("control-panel", "data"), # Store
589 Input("ri-duts", "value"),
590 Input("ri-ttypes", "value"),
591 Input("ri-cadences", "value"),
592 Input("dd-tbeds", "value"),
595 def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str, cadence:str,
596 tbed: str, href: str) -> tuple:
597 """Update the application when the event is detected.
599 :param cp_data: Current status of the control panel stored in
601 :param dut: Input - DUT name.
602 :param ttype: Input - Test type.
603 :param cadence: Input - The cadence of the job.
604 :param tbed: Input - The test bed.
605 :param href: Input - The URL provided by the browser.
612 :returns: New values for web page elements.
616 ctrl_panel = self.ControlPanel(cp_data, self.default)
619 parsed_url = url_decode(href)
621 url_params = parsed_url["params"]
625 trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
626 if trigger_id == "ri-duts":
627 ttype_opts = generate_options(get_ttypes(self.job_info, dut))
628 ttype_val = ttype_opts[0]["value"]
629 cad_opts = generate_options(get_cadences(
630 self.job_info, dut, ttype_val))
631 cad_val = cad_opts[0]["value"]
632 tbed_opts = generate_options(get_test_beds(
633 self.job_info, dut, ttype_val, cad_val))
634 tbed_val = tbed_opts[0]["value"]
636 "ri-duts-value": dut,
637 "ri-ttypes-options": ttype_opts,
638 "ri-ttypes-value": ttype_val,
639 "ri-cadences-options": cad_opts,
640 "ri-cadences-value": cad_val,
641 "dd-tbeds-options": tbed_opts,
642 "dd-tbeds-value": tbed_val
644 elif trigger_id == "ri-ttypes":
645 cad_opts = generate_options(get_cadences(
646 self.job_info, ctrl_panel.get("ri-duts-value"), ttype))
647 cad_val = cad_opts[0]["value"]
648 tbed_opts = generate_options(get_test_beds(
649 self.job_info, ctrl_panel.get("ri-duts-value"), ttype,
651 tbed_val = tbed_opts[0]["value"]
653 "ri-ttypes-value": ttype,
654 "ri-cadences-options": cad_opts,
655 "ri-cadences-value": cad_val,
656 "dd-tbeds-options": tbed_opts,
657 "dd-tbeds-value": tbed_val
659 elif trigger_id == "ri-cadences":
660 tbed_opts = generate_options(get_test_beds(
661 self.job_info, ctrl_panel.get("ri-duts-value"),
662 ctrl_panel.get("ri-ttypes-value"), cadence))
663 tbed_val = tbed_opts[0]["value"]
665 "ri-cadences-value": cadence,
666 "dd-tbeds-options": tbed_opts,
667 "dd-tbeds-value": tbed_val
669 elif trigger_id == "dd-tbeds":
671 "dd-tbeds-value": tbed
673 elif trigger_id == "url":
675 new_job = url_params.get("job", list())[0]
677 job_params = set_job_params(self.job_info, new_job)
678 ctrl_panel = self.ControlPanel(None, job_params)
680 ctrl_panel = self.ControlPanel(cp_data, self.default)
684 ctrl_panel.get("ri-duts-value"),
685 ctrl_panel.get("ri-ttypes-value"),
686 ctrl_panel.get("ri-cadences-value"),
687 ctrl_panel.get("dd-tbeds-value")
690 ctrl_panel.set({"al-job-children": job})
691 fig_passed, fig_duration = \
692 graph_statistics(self.data, job, self.layout)
698 gen_new_url(parsed_url, {"job": job})
700 ret_val.extend(ctrl_panel.values())
704 Output("download-data", "data"),
705 State("control-panel", "data"), # Store
706 Input("btn-download-data", "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 = self.ControlPanel(cp_data, self.default)
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