From: Tibor Frank Date: Mon, 25 Apr 2022 14:55:01 +0000 (+0200) Subject: feat(uti): Add statistics X-Git-Url: https://gerrit.fd.io/r/gitweb?p=csit.git;a=commitdiff_plain;h=refs%2Fchanges%2F35%2F36035%2F4 feat(uti): Add statistics Change-Id: I14046fd1050f130d201bbe81a72e48ad4fd10057 Signed-off-by: Tibor Frank --- diff --git a/resources/tools/dash/app/pal/__init__.py b/resources/tools/dash/app/pal/__init__.py index ff56ab522e..ba68c017a1 100644 --- a/resources/tools/dash/app/pal/__init__.py +++ b/resources/tools/dash/app/pal/__init__.py @@ -43,13 +43,12 @@ def init_app(): assets.init_app(app) # Import Dash applications. + from .stats.stats import init_stats + app = init_stats(app) + from .trending.trending import init_trending app = init_trending(app) - # Temporarily switched off - # from .report.report import init_report - # app = init_report(app) - return app app = init_app() diff --git a/resources/tools/dash/app/pal/data/data.py b/resources/tools/dash/app/pal/data/data.py index a3b6c2a478..3d9b8b1664 100644 --- a/resources/tools/dash/app/pal/data/data.py +++ b/resources/tools/dash/app/pal/data/data.py @@ -151,13 +151,29 @@ class Data: def read_stats(self, days=None): """Read Suite Result Analysis data partition from parquet. """ - lambda_f = lambda part: True if part["stats_type"] == "sra" else False - - return self._create_dataframe_from_parquet( - path=self._get_path("statistics"), - partition_filter=lambda_f, - columns=None, # Get all columns. - days=days + l_stats = lambda part: True if part["stats_type"] == "sra" else False + l_mrr = lambda part: True if part["test_type"] == "mrr" else False + l_ndrpdr = lambda part: True if part["test_type"] == "ndrpdr" else False + + return ( + self._create_dataframe_from_parquet( + path=self._get_path("statistics"), + partition_filter=l_stats, + columns=self._get_columns("statistics"), + days=days + ), + self._create_dataframe_from_parquet( + path=self._get_path("statistics-trending"), + partition_filter=l_mrr, + columns=self._get_columns("statistics-trending"), + days=days + ), + self._create_dataframe_from_parquet( + path=self._get_path("statistics-trending"), + partition_filter=l_ndrpdr, + columns=self._get_columns("statistics-trending"), + days=days + ) ) def read_trending_mrr(self, days=None): diff --git a/resources/tools/dash/app/pal/data/data.yaml b/resources/tools/dash/app/pal/data/data.yaml index f639873fa8..7490b43b2a 100644 --- a/resources/tools/dash/app/pal/data/data.yaml +++ b/resources/tools/dash/app/pal/data/data.yaml @@ -1,5 +1,19 @@ statistics: path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/stats + columns: + - job + - build + - start_time + - duration +statistics-trending: + path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/trending + columns: + - job + - build + - dut_type + - dut_version + - hosts + - passed trending-mrr: path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/trending columns: diff --git a/resources/tools/dash/app/pal/stats/graphs.py b/resources/tools/dash/app/pal/stats/graphs.py new file mode 100644 index 0000000000..2fabf8e6ae --- /dev/null +++ b/resources/tools/dash/app/pal/stats/graphs.py @@ -0,0 +1,109 @@ +# Copyright (c) 2022 Cisco and/or its affiliates. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +""" + +import plotly.graph_objects as go +import pandas as pd + +from datetime import datetime, timedelta + +def select_data(data: pd.DataFrame, itm:str) -> pd.DataFrame: + """ + """ + + df = data.loc[(data["job"] == itm)].sort_values( + by="start_time", ignore_index=True) + + return df + + +def graph_statistics(df: pd.DataFrame, job:str, layout: dict, + start: datetime=datetime.utcnow()-timedelta(days=180), + end: datetime=datetime.utcnow()) -> tuple: + """ + """ + + data = select_data(df, job) + data = data.dropna(subset=["duration", ]) + if data.empty: + return None, None + + x_axis = [d for d in data["start_time"] if d >= start and d <= end] + if not x_axis: + return None, None + + hover = list() + for _, row in data.iterrows(): + hover_itm = ( + f"date: {row['start_time'].strftime('%d-%m-%Y %H:%M:%S')}
" + f"duration: " + f"{(int(row['duration']) // 3600):02d}:" + f"{((int(row['duration']) % 3600) // 60):02d}
" + f"passed: {row['passed']}
" + f"failed: {row['failed']}
" + f"{row['dut_type']}-ref: {row['dut_version']}
" + f"csit-ref: {row['job']}/{row['build']}
" + f"hosts: {', '.join(row['hosts'])}" + ) + hover.append(hover_itm) + + # Job durations: + fig_duration = go.Figure( + data=go.Scatter( + x=x_axis, + y=data["duration"], + name=u"Duration", + text=hover, + hoverinfo=u"text" + ) + ) + + tickvals = [0, ] + step = max(data["duration"]) / 5 + for i in range(5): + tickvals.append(int(step * (i + 1))) + layout_duration = layout.get("plot-stats-duration", dict()) + if layout_duration: + layout_duration["yaxis"]["tickvals"] = tickvals + layout_duration["yaxis"]["ticktext"] = [ + f"{(val // 3600):02d}:{((val % 3600) // 60):02d}" \ + for val in tickvals + ] + fig_duration.update_layout(layout_duration) + + # Passed / failed: + fig_passed = go.Figure( + data=[ + go.Bar( + x=x_axis, + y=data["passed"], + name=u"Passed", + hovertext=hover, + hoverinfo=u"text" + ), + go.Bar( + x=x_axis, + y=data["failed"], + name=u"Failed", + hovertext=hover, + hoverinfo=u"text" + ) + ] + ) + layout_pf = layout.get("plot-stats-passed", dict()) + if layout_pf: + fig_passed.update_layout(layout_pf) + + return fig_passed, fig_duration diff --git a/resources/tools/dash/app/pal/stats/layout.py b/resources/tools/dash/app/pal/stats/layout.py new file mode 100644 index 0000000000..18f7b69612 --- /dev/null +++ b/resources/tools/dash/app/pal/stats/layout.py @@ -0,0 +1,331 @@ +# Copyright (c) 2022 Cisco and/or its affiliates. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Plotly Dash HTML layout override. +""" + +import pandas as pd +import dash_bootstrap_components as dbc + +from dash import dcc +from dash import html +from dash import Input, Output +from dash.exceptions import PreventUpdate +from yaml import load, FullLoader, YAMLError +from datetime import datetime, timedelta + +from ..data.data import Data +from .graphs import graph_statistics + + +class Layout: + """ + """ + + def __init__(self, app, html_layout_file, spec_file, graph_layout_file, + data_spec_file): + """ + """ + + # Inputs + self._app = app + self._html_layout_file = html_layout_file + self._spec_file = spec_file + self._graph_layout_file = graph_layout_file + self._data_spec_file = data_spec_file + + # Read the data: + data_stats, data_mrr, data_ndrpdr = Data( + data_spec_file=self._data_spec_file, + debug=True + ).read_stats(days=180) + + df_tst_info = pd.concat([data_mrr, data_ndrpdr], ignore_index=True) + + # Pre-process the data: + data_stats = data_stats[~data_stats.job.str.contains("-verify-")] + data_stats = data_stats[~data_stats.job.str.contains("-coverage-")] + data_stats = data_stats[~data_stats.job.str.contains("-iterative-")] + data_stats = data_stats[["job", "build", "start_time", "duration"]] + + self._jobs = sorted(list(data_stats["job"].unique())) + + tst_info = { + "job": list(), + "build": list(), + "dut_type": list(), + "dut_version": list(), + "hosts": list(), + "passed": list(), + "failed": list() + } + for job in self._jobs: + df_job = df_tst_info.loc[(df_tst_info["job"] == job)] + builds = df_job["build"].unique() + for build in builds: + df_build = df_job.loc[(df_job["build"] == build)] + tst_info["job"].append(job) + tst_info["build"].append(build) + tst_info["dut_type"].append(df_build["dut_type"].iloc[-1]) + tst_info["dut_version"].append(df_build["dut_version"].iloc[-1]) + tst_info["hosts"].append(df_build["hosts"].iloc[-1]) + try: + passed = df_build.value_counts(subset='passed')[True] + except KeyError: + passed = 0 + try: + failed = df_build.value_counts(subset='passed')[False] + except KeyError: + failed = 0 + tst_info["passed"].append(passed) + tst_info["failed"].append(failed) + + self._data = data_stats.merge(pd.DataFrame.from_dict(tst_info)) + + # Read from files: + self._html_layout = "" + self._graph_layout = None + + try: + with open(self._html_layout_file, "r") as file_read: + self._html_layout = file_read.read() + except IOError as err: + raise RuntimeError( + f"Not possible to open the file {self._html_layout_file}\n{err}" + ) + + try: + with open(self._graph_layout_file, "r") as file_read: + self._graph_layout = load(file_read, Loader=FullLoader) + except IOError as err: + raise RuntimeError( + f"Not possible to open the file {self._graph_layout_file}\n" + f"{err}" + ) + except YAMLError as err: + raise RuntimeError( + f"An error occurred while parsing the specification file " + f"{self._graph_layout_file}\n" + f"{err}" + ) + + self._default_fig_passed, self._default_fig_duration = graph_statistics( + self.data, self.jobs[0], self.layout + ) + + # Callbacks: + if self._app is not None and hasattr(self, 'callbacks'): + self.callbacks(self._app) + + @property + def html_layout(self) -> dict: + return self._html_layout + + @property + def data(self) -> pd.DataFrame: + return self._data + + @property + def layout(self) -> dict: + return self._graph_layout + + @property + def jobs(self) -> list: + return self._jobs + + def add_content(self): + """ + """ + if self.html_layout: + return html.Div( + id="div-main", + children=[ + dbc.Row( + id="row-navbar", + class_name="g-0", + children=[ + self._add_navbar(), + ] + ), + dbc.Row( + id="row-main", + class_name="g-0", + children=[ + dcc.Store( + id="selected-tests" + ), + dcc.Store( + id="control-panel" + ), + self._add_ctrl_col(), + self._add_plotting_col(), + ] + ) + ] + ) + else: + return html.Div( + id="div-main-error", + children=[ + dbc.Alert( + [ + "An Error Occured", + ], + color="danger", + ), + ] + ) + + def _add_navbar(self): + """Add nav element with navigation panel. It is placed on the top. + """ + return dbc.NavbarSimple( + id="navbarsimple-main", + children=[ + dbc.NavItem( + dbc.NavLink( + "Continuous Performance Statistics", + disabled=True, + external_link=True, + href="#" + ) + ) + ], + brand="Dashboard", + brand_href="/", + brand_external_link=True, + class_name="p-2", + fluid=True, + ) + + def _add_ctrl_col(self) -> dbc.Col: + """Add column with controls. It is placed on the left side. + """ + return dbc.Col( + id="col-controls", + children=[ + self._add_ctrl_panel(), + ], + ) + + def _add_plotting_col(self) -> dbc.Col: + """Add column with plots and tables. It is placed on the right side. + """ + return dbc.Col( + id="col-plotting-area", + children=[ + dbc.Row( # Passed / failed tests + id="row-graph-passed", + class_name="g-0 p-2", + children=[ + dcc.Loading(children=[ + dcc.Graph( + id="graph-passed", + figure=self._default_fig_passed + ) + ]) + ] + ), + dbc.Row( # Duration + id="row-graph-duration", + class_name="g-0 p-2", + children=[ + dcc.Loading(children=[ + dcc.Graph( + id="graph-duration", + figure=self._default_fig_duration + ) + ]) + ] + ), + dbc.Row( # Download + id="row-btn-download", + class_name="g-0 p-2", + children=[ + dcc.Loading(children=[ + dbc.Button( + id="btn-download-data", + children=["Download Data"] + ), + dcc.Download(id="download-data") + ]) + ] + ) + ], + width=9, + ) + + def _add_ctrl_panel(self) -> dbc.Row: + """ + """ + return dbc.Row( + id="row-ctrl-panel", + class_name="g-0 p-2", + children=[ + dbc.Label("Choose the Trending Job"), + dbc.RadioItems( + id="ri_job", + value=self.jobs[0], + options=[{"label": i, "value": i} for i in self.jobs] + ), + dbc.Label("Choose the Time Period"), + dcc.DatePickerRange( + id="dpr-period", + className="d-flex justify-content-center", + min_date_allowed=\ + datetime.utcnow()-timedelta(days=180), + max_date_allowed=datetime.utcnow(), + initial_visible_month=datetime.utcnow(), + start_date=datetime.utcnow() - timedelta(days=180), + end_date=datetime.utcnow(), + display_format="D MMMM YY" + ) + ] + ) + + def callbacks(self, app): + + @app.callback( + Output("graph-passed", "figure"), + Output("graph-duration", "figure"), + Input("ri_job", "value"), + Input("dpr-period", "start_date"), + Input("dpr-period", "end_date"), + prevent_initial_call=True + ) + def _update_ctrl_panel(job:str, d_start: str, d_end: str) -> tuple: + """ + """ + + d_start = datetime(int(d_start[0:4]), int(d_start[5:7]), + int(d_start[8:10])) + d_end = datetime(int(d_end[0:4]), int(d_end[5:7]), int(d_end[8:10])) + + fig_passed, fig_duration = graph_statistics( + self.data, job, self.layout, d_start, d_end + ) + + return fig_passed, fig_duration + + @app.callback( + Output("download-data", "data"), + Input("btn-download-data", "n_clicks"), + prevent_initial_call=True + ) + def _download_data(n_clicks): + """ + """ + if not n_clicks: + raise PreventUpdate + + return dcc.send_data_frame(self.data.to_csv, "statistics.csv") diff --git a/resources/tools/dash/app/pal/stats/layout.yaml b/resources/tools/dash/app/pal/stats/layout.yaml new file mode 100644 index 0000000000..0a102e4d0a --- /dev/null +++ b/resources/tools/dash/app/pal/stats/layout.yaml @@ -0,0 +1,117 @@ +plot-stats-passed: + autosize: True + showlegend: False + yaxis: + showticklabels: True + title: "Number of Passed / Failed Tests" + gridcolor: "rgb(238, 238, 238)" + linecolor: "rgb(238, 238, 238)" + showline: True + zeroline: False + tickcolor: "rgb(238, 238, 238)" + linewidth: 1 + showgrid: True + rangemode: "tozero" + xaxis: + title: 'Date [MMDD]' + type: "date" + autorange: True + fixedrange: False + showgrid: True + gridcolor: "rgb(238, 238, 238)" + showline: True + linecolor: "rgb(238, 238, 238)" + zeroline: False + linewidth: 1 + showticklabels: True + tickcolor: "rgb(238, 238, 238)" + tickmode: "auto" + tickformat: "%m%d" + rangeselector: + buttons: + - count: 14 + label: "2w" + step: "day" + stepmode: "backward" + - count: 1 + label: "1m" + step: "month" + stepmode: "backward" + - count: 2 + label: "2m" + step: "month" + stepmode: "backward" + - count: 3 + label: "3m" + step: "month" + stepmode: "backward" + - step: "all" + margin: + r: 20 + b: 5 + t: 5 + l: 70 + paper_bgcolor: "#fff" + plot_bgcolor: "#fff" + barmode: "stack" + hoverlabel: + namelength: -1 + +plot-stats-duration: + autosize: True + showlegend: False + yaxis: + showticklabels: True + title: "Duration [hh:mm]" + gridcolor: "rgb(238, 238, 238)" + linecolor: "rgb(238, 238, 238)" + showline: True + zeroline: False + tickmode: "array" + tickcolor: "rgb(238, 238, 238)" + linewidth: 1 + showgrid: True + rangemode: "tozero" + xaxis: + title: 'Date [MMDD]' + type: "date" + autorange: True + fixedrange: False + showgrid: True + gridcolor: "rgb(238, 238, 238)" + showline: True + linecolor: "rgb(238, 238, 238)" + zeroline: False + linewidth: 1 + showticklabels: True + tickcolor: "rgb(238, 238, 238)" + tickmode: "auto" + tickformat: "%m%d" + rangeselector: + buttons: + - count: 14 + label: "2w" + step: "day" + stepmode: "backward" + - count: 1 + label: "1m" + step: "month" + stepmode: "backward" + - count: 2 + label: "2m" + step: "month" + stepmode: "backward" + - count: 3 + label: "3m" + step: "month" + stepmode: "backward" + - step: "all" + margin: + r: 20 + b: 5 + t: 5 + l: 70 + paper_bgcolor: "#fff" + plot_bgcolor: "#fff" + hoverlabel: + namelength: -1 diff --git a/resources/tools/dash/app/pal/stats/stats.py b/resources/tools/dash/app/pal/stats/stats.py new file mode 100644 index 0000000000..78bb6e6f88 --- /dev/null +++ b/resources/tools/dash/app/pal/stats/stats.py @@ -0,0 +1,48 @@ +# Copyright (c) 2022 Cisco and/or its affiliates. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Instantiate the Statistics Dash applocation. +""" +import dash +import dash_bootstrap_components as dbc + +from .layout import Layout + + +def init_stats(server): + """Create a Plotly Dash dashboard. + + :param server: Flask server. + :type server: Flask + :returns: Dash app server. + :rtype: Dash + """ + + dash_app = dash.Dash( + server=server, + routes_pathname_prefix=u"/stats/", + external_stylesheets=[dbc.themes.LUX], + ) + + # Custom HTML layout + layout = Layout( + app=dash_app, + html_layout_file="pal/templates/stats_layout.jinja2", + spec_file="pal/stats/spec_job_selection.yaml", + graph_layout_file="pal/stats/layout.yaml", + data_spec_file="pal/data/data.yaml" + ) + dash_app.index_string = layout.html_layout + dash_app.layout = layout.add_content() + + return dash_app.server diff --git a/resources/tools/dash/app/pal/templates/index_layout.jinja2 b/resources/tools/dash/app/pal/templates/index_layout.jinja2 index b9fba66206..5fd3d40b25 100644 --- a/resources/tools/dash/app/pal/templates/index_layout.jinja2 +++ b/resources/tools/dash/app/pal/templates/index_layout.jinja2 @@ -16,6 +16,9 @@

Trending

+

+ Statistics +