C-Dash: Add coverage tables
[csit.git] / csit.infra.dash / app / cdash / coverage / layout.py
diff --git a/csit.infra.dash/app/cdash/coverage/layout.py b/csit.infra.dash/app/cdash/coverage/layout.py
new file mode 100644 (file)
index 0000000..03d2da7
--- /dev/null
@@ -0,0 +1,661 @@
+# Copyright (c) 2023 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 flask import Flask
+from dash import dcc
+from dash import html
+from dash import callback_context, no_update, ALL
+from dash import Input, Output, State
+from dash.exceptions import PreventUpdate
+from ast import literal_eval
+
+from ..utils.constants import Constants as C
+from ..utils.control_panel import ControlPanel
+from ..utils.trigger import Trigger
+from ..utils.utils import label, gen_new_url, generate_options
+from ..utils.url_processing import url_decode
+from .tables import coverage_tables, select_coverage_data
+
+
+# Control panel partameters and their default values.
+CP_PARAMS = {
+    "rls-val": str(),
+    "dut-opt": list(),
+    "dut-dis": True,
+    "dut-val": str(),
+    "dutver-opt": list(),
+    "dutver-dis": True,
+    "dutver-val": str(),
+    "phy-opt": list(),
+    "phy-dis": True,
+    "phy-val": str(),
+    "area-opt": list(),
+    "area-dis": True,
+    "area-val": str()
+}
+
+
+class Layout:
+    """The layout of the dash app and the callbacks.
+    """
+
+    def __init__(
+            self,
+            app: Flask,
+            data_coverage: pd.DataFrame,
+            html_layout_file: str
+        ) -> None:
+        """Initialization:
+        - save the input parameters,
+        - prepare data for the control panel,
+        - read HTML layout file,
+
+        :param app: Flask application running the dash application.
+        :param html_layout_file: Path and name of the file specifying the HTML
+            layout of the dash application.
+        :type app: Flask
+        :type html_layout_file: str
+        """
+
+        # Inputs
+        self._app = app
+        self._html_layout_file = html_layout_file
+        self._data = data_coverage
+
+        # Get structure of tests:
+        tbs = dict()
+        cols = ["job", "test_id", "dut_version", "release", ]
+        for _, row in self._data[cols].drop_duplicates().iterrows():
+            rls = row["release"]
+            lst_job = row["job"].split("-")
+            dut = lst_job[1]
+            d_ver = row["dut_version"]
+            tbed = "-".join(lst_job[-2:])
+            lst_test_id = row["test_id"].split(".")
+            if dut == "dpdk":
+                area = "dpdk"
+            else:
+                area = "-".join(lst_test_id[3:-2])
+            suite = lst_test_id[-2].replace("2n1l-", "").replace("1n1l-", "").\
+                replace("2n-", "")
+            test = lst_test_id[-1]
+            nic = suite.split("-")[0]
+            for drv in C.DRIVERS:
+                if drv in test:
+                    driver = drv.replace("-", "_")
+                    test = test.replace(f"{drv}-", "")
+                    break
+            else:
+                driver = "dpdk"
+            infra = "-".join((tbed, nic, driver))
+
+            if tbs.get(rls, None) is None:
+                tbs[rls] = dict()
+            if tbs[rls].get(dut, None) is None:
+                tbs[rls][dut] = dict()
+            if tbs[rls][dut].get(d_ver, None) is None:
+                tbs[rls][dut][d_ver] = dict()
+            if tbs[rls][dut][d_ver].get(infra, None) is None:
+                tbs[rls][dut][d_ver][infra] = list()
+            if area not in tbs[rls][dut][d_ver][infra]:
+                tbs[rls][dut][d_ver][infra].append(area)
+
+        self._spec_tbs = tbs
+
+        # Read from files:
+        self._html_layout = str()
+
+        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}"
+            )
+
+        # Callbacks:
+        if self._app is not None and hasattr(self, "callbacks"):
+            self.callbacks(self._app)
+
+    @property
+    def html_layout(self):
+        return self._html_layout
+
+    def add_content(self):
+        """Top level method which generated the web page.
+
+        It generates:
+        - Store for user input data,
+        - Navigation bar,
+        - Main area with control panel and ploting area.
+
+        If no HTML layout is provided, an error message is displayed instead.
+
+        :returns: The HTML div with the whole page.
+        :rtype: html.Div
+        """
+
+        if self.html_layout and self._spec_tbs:
+            return html.Div(
+                id="div-main",
+                className="small",
+                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="store-selected-tests"),
+                            dcc.Store(id="store-control-panel"),
+                            dcc.Location(id="url", refresh=False),
+                            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.
+
+        :returns: Navigation bar.
+        :rtype: dbc.NavbarSimple
+        """
+        return dbc.NavbarSimple(
+            id="navbarsimple-main",
+            children=[
+                dbc.NavItem(
+                    dbc.NavLink(
+                        C.COVERAGE_TITLE,
+                        disabled=True,
+                        external_link=True,
+                        href="#"
+                    )
+                )
+            ],
+            brand=C.BRAND,
+            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.
+
+        :returns: Column with the control panel.
+        :rtype: dbc.Col
+        """
+        return dbc.Col([
+            html.Div(
+                children=self._add_ctrl_panel(),
+                className="sticky-top"
+            )
+        ])
+
+    def _add_plotting_col(self) -> dbc.Col:
+        """Add column with plots. It is placed on the right side.
+
+        :returns: Column with plots.
+        :rtype: dbc.Col
+        """
+        return dbc.Col(
+            id="col-plotting-area",
+            children=[
+                dbc.Spinner(
+                    children=[
+                        dbc.Row(
+                            id="plotting-area",
+                            class_name="g-0 p-0",
+                            children=[
+                                C.PLACEHOLDER
+                            ]
+                        )
+                    ]
+                )
+            ],
+            width=9
+        )
+
+    def _add_ctrl_panel(self) -> list:
+        """Add control panel.
+
+        :returns: Control panel.
+        :rtype: list
+        """
+        return [
+            dbc.Row(
+                class_name="g-0 p-1",
+                children=[
+                    dbc.InputGroup(
+                        [
+                            dbc.InputGroupText("CSIT Release"),
+                            dbc.Select(
+                                id={"type": "ctrl-dd", "index": "rls"},
+                                placeholder="Select a Release...",
+                                options=sorted(
+                                    [
+                                        {"label": k, "value": k} \
+                                            for k in self._spec_tbs.keys()
+                                    ],
+                                    key=lambda d: d["label"]
+                                )
+                            )
+                        ],
+                        size="sm"
+                    )
+                ]
+            ),
+            dbc.Row(
+                class_name="g-0 p-1",
+                children=[
+                    dbc.InputGroup(
+                        [
+                            dbc.InputGroupText("DUT"),
+                            dbc.Select(
+                                id={"type": "ctrl-dd", "index": "dut"},
+                                placeholder="Select a Device under Test..."
+                            )
+                        ],
+                        size="sm"
+                    )
+                ]
+            ),
+            dbc.Row(
+                class_name="g-0 p-1",
+                children=[
+                    dbc.InputGroup(
+                        [
+                            dbc.InputGroupText("DUT Version"),
+                            dbc.Select(
+                                id={"type": "ctrl-dd", "index": "dutver"},
+                                placeholder=\
+                                    "Select a Version of Device under Test..."
+                            )
+                        ],
+                        size="sm"
+                    )
+                ]
+            ),
+            dbc.Row(
+                class_name="g-0 p-1",
+                children=[
+                    dbc.InputGroup(
+                        [
+                            dbc.InputGroupText("Infra"),
+                            dbc.Select(
+                                id={"type": "ctrl-dd", "index": "phy"},
+                                placeholder=\
+                                    "Select a Physical Test Bed Topology..."
+                            )
+                        ],
+                        size="sm"
+                    )
+                ]
+            ),
+            dbc.Row(
+                class_name="g-0 p-1",
+                children=[
+                    dbc.InputGroup(
+                        [
+                            dbc.InputGroupText("Area"),
+                            dbc.Select(
+                                id={"type": "ctrl-dd", "index": "area"},
+                                placeholder="Select an Area..."
+                            )
+                        ],
+                        size="sm"
+                    )
+                ]
+            )
+        ]
+
+    def _get_plotting_area(self, selected: dict, url: str) -> list:
+        """Generate the plotting area with all its content.
+
+        :param selected: Selected parameters of tests.
+        :param url: URL to be displayed in the modal window.
+        :type selected: dict
+        :type url: str
+        :returns: List of rows with elements to be displayed in the plotting
+            area.
+        :rtype: list
+        """
+        if not selected:
+            return C.PLACEHOLDER
+
+        return [
+            dbc.Row(
+                children=coverage_tables(self._data, selected),
+                class_name="g-0 p-0",
+            ),
+            dbc.Row(
+                children=C.PLACEHOLDER,
+                class_name="g-0 p-1"
+            ),
+            dbc.Row(
+                [
+                    dbc.Col([html.Div(
+                        [
+                            dbc.Button(
+                                id="plot-btn-url",
+                                children="Show URL",
+                                class_name="me-1",
+                                color="info",
+                                style={
+                                    "text-transform": "none",
+                                    "padding": "0rem 1rem"
+                                }
+                            ),
+                            dbc.Modal(
+                                [
+                                    dbc.ModalHeader(dbc.ModalTitle("URL")),
+                                    dbc.ModalBody(url)
+                                ],
+                                id="plot-mod-url",
+                                size="xl",
+                                is_open=False,
+                                scrollable=True
+                            ),
+                            dbc.Button(
+                                id="plot-btn-download",
+                                children="Download Data",
+                                class_name="me-1",
+                                color="info",
+                                style={
+                                    "text-transform": "none",
+                                    "padding": "0rem 1rem"
+                                }
+                            ),
+                            dcc.Download(id="download-iterative-data")
+                        ],
+                        className=\
+                            "d-grid gap-0 d-md-flex justify-content-md-end"
+                    )])
+                ],
+                class_name="g-0 p-0"
+            ),
+            dbc.Row(
+                children=C.PLACEHOLDER,
+                class_name="g-0 p-1"
+            )
+        ]
+
+    def callbacks(self, app):
+        """Callbacks for the whole application.
+
+        :param app: The application.
+        :type app: Flask
+        """
+
+        @app.callback(
+            [
+                Output("store-control-panel", "data"),
+                Output("store-selected-tests", "data"),
+                Output("plotting-area", "children"),
+                Output({"type": "ctrl-dd", "index": "rls"}, "value"),
+                Output({"type": "ctrl-dd", "index": "dut"}, "options"),
+                Output({"type": "ctrl-dd", "index": "dut"}, "disabled"),
+                Output({"type": "ctrl-dd", "index": "dut"}, "value"),
+                Output({"type": "ctrl-dd", "index": "dutver"}, "options"),
+                Output({"type": "ctrl-dd", "index": "dutver"}, "disabled"),
+                Output({"type": "ctrl-dd", "index": "dutver"}, "value"),
+                Output({"type": "ctrl-dd", "index": "phy"}, "options"),
+                Output({"type": "ctrl-dd", "index": "phy"}, "disabled"),
+                Output({"type": "ctrl-dd", "index": "phy"}, "value"),
+                Output({"type": "ctrl-dd", "index": "area"}, "options"),
+                Output({"type": "ctrl-dd", "index": "area"}, "disabled"),
+                Output({"type": "ctrl-dd", "index": "area"}, "value"),
+            ],
+            [
+                State("store-control-panel", "data"),
+                State("store-selected-tests", "data")
+            ],
+            [
+                Input("url", "href"),
+                Input({"type": "ctrl-dd", "index": ALL}, "value")
+            ]
+        )
+        def _update_application(
+                control_panel: dict,
+                selected: dict,
+                href: str,
+                *_
+            ) -> tuple:
+            """Update the application when the event is detected.
+            """
+
+            ctrl_panel = ControlPanel(CP_PARAMS, control_panel)
+            plotting_area = no_update
+            on_draw = False
+            if selected is None:
+                selected = dict()
+
+            # Parse the url:
+            parsed_url = url_decode(href)
+            if parsed_url:
+                url_params = parsed_url["params"]
+            else:
+                url_params = None
+
+            trigger = Trigger(callback_context.triggered)
+
+            if trigger.type == "url" and url_params:
+                try:
+                    selected = literal_eval(url_params["selection"][0])
+                except (KeyError, IndexError):
+                    pass
+                if selected:
+                    ctrl_panel.set({
+                        "rls-val": selected["rls"],
+                        "dut-val": selected["dut"],
+                        "dut-opt": generate_options(
+                            self._spec_tbs[selected["rls"]].keys()
+                        ),
+                        "dut-dis": False,
+                        "dutver-val": selected["dutver"],
+                        "dutver-opt": generate_options(
+                            self._spec_tbs[selected["rls"]]\
+                                [selected["dut"]].keys()
+                        ),
+                        "dutver-dis": False,
+                        "phy-val": selected["phy"],
+                        "phy-opt": generate_options(
+                            self._spec_tbs[selected["rls"]][selected["dut"]]\
+                                [selected["dutver"]].keys()
+                        ),
+                        "phy-dis": False,
+                        "area-val": selected["area"],
+                        "area-opt": [
+                            {"label": label(v), "value": v} for v in sorted(
+                                self._spec_tbs[selected["rls"]]\
+                                    [selected["dut"]][selected["dutver"]]\
+                                        [selected["phy"]]
+                            )
+                        ],
+                        "area-dis": False
+                    })
+                    on_draw = True
+            elif trigger.type == "ctrl-dd":
+                if trigger.idx == "rls":
+                    try:
+                        options = generate_options(
+                            self._spec_tbs[trigger.value].keys()
+                        )
+                        disabled = False
+                    except KeyError:
+                        options = list()
+                        disabled = True
+                    ctrl_panel.set({
+                        "rls-val": trigger.value,
+                        "dut-val": str(),
+                        "dut-opt": options,
+                        "dut-dis": disabled,
+                        "dutver-val": str(),
+                        "dutver-opt": list(),
+                        "dutver-dis": True,
+                        "phy-val": str(),
+                        "phy-opt": list(),
+                        "phy-dis": True,
+                        "area-val": str(),
+                        "area-opt": list(),
+                        "area-dis": True
+                    })
+                elif trigger.idx == "dut":
+                    try:
+                        rls = ctrl_panel.get("rls-val")
+                        dut = self._spec_tbs[rls][trigger.value]
+                        options = generate_options(dut.keys())
+                        disabled = False
+                    except KeyError:
+                        options = list()
+                        disabled = True
+                    ctrl_panel.set({
+                        "dut-val": trigger.value,
+                        "dutver-val": str(),
+                        "dutver-opt": options,
+                        "dutver-dis": disabled,
+                        "phy-val": str(),
+                        "phy-opt": list(),
+                        "phy-dis": True,
+                        "area-val": str(),
+                        "area-opt": list(),
+                        "area-dis": True
+                    })
+                elif trigger.idx == "dutver":
+                    try:
+                        rls = ctrl_panel.get("rls-val")
+                        dut = ctrl_panel.get("dut-val")
+                        dutver = self._spec_tbs[rls][dut][trigger.value]
+                        options = generate_options(dutver.keys())
+                        disabled = False
+                    except KeyError:
+                        options = list()
+                        disabled = True
+                    ctrl_panel.set({
+                        "dutver-val": trigger.value,
+                        "phy-val": str(),
+                        "phy-opt": options,
+                        "phy-dis": disabled,
+                        "area-val": str(),
+                        "area-opt": list(),
+                        "area-dis": True
+                    })
+                elif trigger.idx == "phy":
+                    try:
+                        rls = ctrl_panel.get("rls-val")
+                        dut = ctrl_panel.get("dut-val")
+                        dutver = ctrl_panel.get("dutver-val")
+                        phy = self._spec_tbs[rls][dut][dutver][trigger.value]
+                        options = [
+                            {"label": label(v), "value": v} for v in sorted(phy)
+                        ]
+                        disabled = False
+                    except KeyError:
+                        options = list()
+                        disabled = True
+                    ctrl_panel.set({
+                        "phy-val": trigger.value,
+                        "area-val": str(),
+                        "area-opt": options,
+                        "area-dis": disabled
+                    })
+                elif trigger.idx == "area":
+                    ctrl_panel.set({"area-val": trigger.value})
+                    selected = {
+                        "rls": ctrl_panel.get("rls-val"),
+                        "dut": ctrl_panel.get("dut-val"),
+                        "dutver": ctrl_panel.get("dutver-val"),
+                        "phy": ctrl_panel.get("phy-val"),
+                        "area": ctrl_panel.get("area-val"),
+                    }
+                    on_draw = True
+
+            if on_draw:
+                if selected:
+                    plotting_area = self._get_plotting_area(
+                        selected,
+                        gen_new_url(parsed_url, {"selection": selected})
+                    )
+                else:
+                    plotting_area = C.PLACEHOLDER
+                    selected = dict()
+
+            ret_val = [
+                ctrl_panel.panel,
+                selected,
+                plotting_area,
+            ]
+            ret_val.extend(ctrl_panel.values)
+            return ret_val
+
+        @app.callback(
+            Output("plot-mod-url", "is_open"),
+            [Input("plot-btn-url", "n_clicks")],
+            [State("plot-mod-url", "is_open")],
+        )
+        def toggle_plot_mod_url(n, is_open):
+            """Toggle the modal window with url.
+            """
+            if n:
+                return not is_open
+            return is_open
+
+        @app.callback(
+            Output("download-iterative-data", "data"),
+            State("store-selected-tests", "data"),
+            Input("plot-btn-download", "n_clicks"),
+            prevent_initial_call=True
+        )
+        def _download_coverage_data(selection, _):
+            """Download the data
+
+            :param selection: List of tests selected by user stored in the
+                browser.
+            :type selection: dict
+            :returns: dict of data frame content (base64 encoded) and meta data
+                used by the Download component.
+            :rtype: dict
+            """
+
+            if not selection:
+                raise PreventUpdate
+
+            df = select_coverage_data(self._data, selection, csv=True)
+
+            return dcc.send_data_frame(df.to_csv, C.COVERAGE_DOWNLOAD_FILE_NAME)