feat(uti): Add statistics 35/36035/4
authorTibor Frank <tifrank@cisco.com>
Mon, 25 Apr 2022 14:55:01 +0000 (16:55 +0200)
committerTibor Frank <tifrank@cisco.com>
Tue, 26 Apr 2022 08:49:11 +0000 (08:49 +0000)
Change-Id: I14046fd1050f130d201bbe81a72e48ad4fd10057
Signed-off-by: Tibor Frank <tifrank@cisco.com>
resources/tools/dash/app/pal/__init__.py
resources/tools/dash/app/pal/data/data.py
resources/tools/dash/app/pal/data/data.yaml
resources/tools/dash/app/pal/stats/graphs.py [new file with mode: 0644]
resources/tools/dash/app/pal/stats/layout.py [new file with mode: 0644]
resources/tools/dash/app/pal/stats/layout.yaml [new file with mode: 0644]
resources/tools/dash/app/pal/stats/stats.py [new file with mode: 0644]
resources/tools/dash/app/pal/templates/index_layout.jinja2
resources/tools/dash/app/pal/templates/stats_layout.jinja2 [new file with mode: 0644]
resources/tools/dash/app/pal/trending/graphs.py
resources/tools/dash/app/pal/trending/spec_test_selection.yaml

index ff56ab5..ba68c01 100644 (file)
@@ -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()
index a3b6c2a..3d9b8b1 100644 (file)
@@ -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):
index f639873..7490b43 100644 (file)
@@ -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 (file)
index 0000000..2fabf8e
--- /dev/null
@@ -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')}<br>"
+            f"duration: "
+            f"{(int(row['duration']) // 3600):02d}:"
+            f"{((int(row['duration']) % 3600) // 60):02d}<br>"
+            f"passed: {row['passed']}<br>"
+            f"failed: {row['failed']}<br>"
+            f"{row['dut_type']}-ref: {row['dut_version']}<br>"
+            f"csit-ref: {row['job']}/{row['build']}<br>"
+            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 (file)
index 0000000..18f7b69
--- /dev/null
@@ -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 (file)
index 0000000..0a102e4
--- /dev/null
@@ -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 (file)
index 0000000..78bb6e6
--- /dev/null
@@ -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
index b9fba66..5fd3d40 100644 (file)
@@ -16,6 +16,9 @@
     <p class="lead">
       <a href="/trending/" class="btn btn-primary fw-bold">Trending</a>
     </p>
+    <p class="lead">
+      <a href="/stats/" class="btn btn-primary fw-bold">Statistics</a>
+    </p>
   </main>
 
   <footer class="mt-auto text-white-50">
diff --git a/resources/tools/dash/app/pal/templates/stats_layout.jinja2 b/resources/tools/dash/app/pal/templates/stats_layout.jinja2
new file mode 100644 (file)
index 0000000..dae6f00
--- /dev/null
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <title>Continuous Performance Statistics</title>
+  {%metas%}
+  {%favicon%}
+  {%css%}
+</head>
+<body>
+  {%app_entry%}
+  <footer>
+    {%config%}
+    {%scripts%}
+    {%renderer%}
+  </footer>
+</body>
+</html>
\ No newline at end of file
index 7089e2c..16cb5a2 100644 (file)
@@ -165,7 +165,7 @@ def select_trending_data(data: pd.DataFrame, itm:dict) -> pd.DataFrame:
     phy = itm["phy"].split("-")
     if len(phy) == 4:
         topo, arch, nic, drv = phy
-        if drv in ("dpdk", "ixgbe"):
+        if drv == "dpdk":
             drv = ""
         else:
             drv += "-"
index 4d177c0..ad4589f 100644 (file)
     core: [1C, 2C, 4C]
     frame-size: [64B, ]
     test-type: [MRR, NDR, PDR]
-2n-dnv-10ge2p1x553-ixgbe:
+2n-dnv-10ge2p1x553-dpdk:
   l2-base:
     label: L2 Ethernet Switching Base
     test:
     core: [1C, 2C]
     frame-size: [IMIX, 1518B]
     test-type: [MRR, ]
-3n-dnv-10ge2p1x553-ixgbe:
+3n-dnv-10ge2p1x553-dpdk:
   l2-base:
     label: L2 Ethernet Switching Base
     test:
     core: [2C, 3C, 4C]
     frame-size: [1518B, IMIX]
     test-type: [MRR, NDR, PDR]
-3n-tsh-10ge2p1x520-ixgbe:
+3n-tsh-10ge2p1x520-dpdk:
   l2-base:
     label: L2 Ethernet Switching Base
     test: