C-Dash: Add detailed views to comparison tables 56/40556/6
authorTibor Frank <tifrank@cisco.com>
Wed, 20 Mar 2024 05:43:03 +0000 (05:43 +0000)
committerTibor Frank <tifrank@cisco.com>
Fri, 22 Mar 2024 08:38:36 +0000 (08:38 +0000)
Change-Id: I0936f736497299f8b9fc1254012b2a0b20c41bfb
Signed-off-by: Tibor Frank <tifrank@cisco.com>
csit.infra.dash/app/cdash/comparisons/comparisons.py
csit.infra.dash/app/cdash/comparisons/layout.py
csit.infra.dash/app/cdash/comparisons/tables.py
csit.infra.dash/app/cdash/report/graphs.py
csit.infra.dash/app/cdash/search/layout.py
csit.infra.dash/app/cdash/utils/utils.py

index 5700552..f2cda81 100644 (file)
@@ -44,6 +44,7 @@ def init_comparisons(
         app=dash_app,
         data_iterative=data_iterative,
         html_layout_file=C.HTML_LAYOUT_FILE,
         app=dash_app,
         data_iterative=data_iterative,
         html_layout_file=C.HTML_LAYOUT_FILE,
+        graph_layout_file=C.REPORT_GRAPH_LAYOUT_FILE,
         tooltip_file=C.TOOLTIP_FILE
     )
     dash_app.index_string = layout.html_layout
         tooltip_file=C.TOOLTIP_FILE
     )
     dash_app.index_string = layout.html_layout
index d325426..3aa3239 100644 (file)
@@ -26,14 +26,16 @@ from dash.exceptions import PreventUpdate
 from dash.dash_table.Format import Format, Scheme
 from ast import literal_eval
 from yaml import load, FullLoader, YAMLError
 from dash.dash_table.Format import Format, Scheme
 from ast import literal_eval
 from yaml import load, FullLoader, YAMLError
+from copy import deepcopy
 
 from ..utils.constants import Constants as C
 from ..utils.control_panel import ControlPanel
 from ..utils.trigger import Trigger
 from ..utils.url_processing import url_decode
 from ..utils.utils import generate_options, gen_new_url, navbar_report, \
 
 from ..utils.constants import Constants as C
 from ..utils.control_panel import ControlPanel
 from ..utils.trigger import Trigger
 from ..utils.url_processing import url_decode
 from ..utils.utils import generate_options, gen_new_url, navbar_report, \
-    filter_table_data, show_tooltip
+    filter_table_data, sort_table_data, show_iterative_graph_data, show_tooltip
 from .tables import comparison_table
 from .tables import comparison_table
+from ..report.graphs import graph_iterative
 
 
 # Control panel partameters and their default values.
 
 
 # Control panel partameters and their default values.
@@ -80,12 +82,15 @@ class Layout:
             app: Flask,
             data_iterative: pd.DataFrame,
             html_layout_file: str,
             app: Flask,
             data_iterative: pd.DataFrame,
             html_layout_file: str,
+            graph_layout_file: str,
             tooltip_file: str
         ) -> None:
         """Initialization:
         - save the input parameters,
         - prepare data for the control panel,
         - read HTML layout file,
             tooltip_file: str
         ) -> None:
         """Initialization:
         - save the input parameters,
         - prepare data for the control panel,
         - read HTML layout file,
+        - read graph layout file,
+        - read tooltips from the tooltip file.
 
         :param app: Flask application running the dash application.
         :param data_iterative: Iterative data to be used in comparison tables.
 
         :param app: Flask application running the dash application.
         :param data_iterative: Iterative data to be used in comparison tables.
@@ -93,9 +98,12 @@ class Layout:
             layout of the dash application.
         :param tooltip_file: Path and name of the yaml file specifying the
             tooltips.
             layout of the dash application.
         :param tooltip_file: Path and name of the yaml file specifying the
             tooltips.
+        :param graph_layout_file: Path and name of the file with layout of
+            plot.ly graphs.
         :type app: Flask
         :type data_iterative: pandas.DataFrame
         :type html_layout_file: str
         :type app: Flask
         :type data_iterative: pandas.DataFrame
         :type html_layout_file: str
+        :type graph_layout_file: str
         :type tooltip_file: str
         """
 
         :type tooltip_file: str
         """
 
@@ -103,6 +111,7 @@ class Layout:
         self._app = app
         self._data = data_iterative
         self._html_layout_file = html_layout_file
         self._app = app
         self._data = data_iterative
         self._html_layout_file = html_layout_file
+        self._graph_layout_file = graph_layout_file
         self._tooltip_file = tooltip_file
 
         # Get structure of tests:
         self._tooltip_file = tooltip_file
 
         # Get structure of tests:
@@ -174,6 +183,20 @@ class Layout:
                 f"Not possible to open the file {self._html_layout_file}\n{err}"
             )
 
                 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{err}"
+            )
+
         try:
             with open(self._tooltip_file, "r") as file_read:
                 self._tooltips = load(file_read, Loader=FullLoader)
         try:
             with open(self._tooltip_file, "r") as file_read:
                 self._tooltips = load(file_read, Loader=FullLoader)
@@ -232,6 +255,31 @@ class Layout:
                             self._add_plotting_col()
                         ]
                     ),
                             self._add_plotting_col()
                         ]
                     ),
+                    dbc.Spinner(
+                        dbc.Offcanvas(
+                            class_name="w-75",
+                            id="offcanvas-details",
+                            title="Test Details",
+                            placement="end",
+                            is_open=False,
+                            children=[]
+                        ),
+                        delay_show=C.SPINNER_DELAY
+                    ),
+                    dbc.Spinner(
+                        dbc.Offcanvas(
+                            class_name="w-50",
+                            id="offcanvas-metadata",
+                            title="Detailed Information",
+                            placement="end",
+                            is_open=False,
+                            children=[
+                                dbc.Row(id="metadata-tput-lat"),
+                                dbc.Row(id="metadata-hdrh-graph")
+                            ]
+                        ),
+                        delay_show=C.SPINNER_DELAY
+                    ),
                     dbc.Offcanvas(
                         class_name="w-75",
                         id="offcanvas-documentation",
                     dbc.Offcanvas(
                         class_name="w-75",
                         id="offcanvas-documentation",
@@ -625,7 +673,7 @@ class Layout:
                             editable=False,
                             filter_action="custom",
                             filter_query="",
                             editable=False,
                             filter_action="custom",
                             filter_query="",
-                            sort_action="native",
+                            sort_action="custom",
                             sort_mode="multi",
                             selected_columns=[],
                             selected_rows=[],
                             sort_mode="multi",
                             selected_columns=[],
                             selected_rows=[],
@@ -749,6 +797,7 @@ class Layout:
                 Input("normalize", "value"),
                 Input("outliers", "value"),
                 Input({"type": "table", "index": ALL}, "filter_query"),
                 Input("normalize", "value"),
                 Input("outliers", "value"),
                 Input({"type": "table", "index": ALL}, "filter_query"),
+                Input({"type": "table", "index": ALL}, "sort_by"),
                 Input({"type": "ctrl-dd", "index": ALL}, "value"),
                 Input({"type": "ctrl-cl", "index": ALL}, "value"),
                 Input({"type": "ctrl-btn", "index": ALL}, "n_clicks")
                 Input({"type": "ctrl-dd", "index": ALL}, "value"),
                 Input({"type": "ctrl-cl", "index": ALL}, "value"),
                 Input({"type": "ctrl-btn", "index": ALL}, "n_clicks")
@@ -763,7 +812,6 @@ class Layout:
                 href: str,
                 normalize: list,
                 outliers: bool,
                 href: str,
                 normalize: list,
                 outliers: bool,
-                table_filter: str,
                 *_
             ) -> tuple:
             """Update the application when the event is detected.
                 *_
             ) -> tuple:
             """Update the application when the event is detected.
@@ -1020,10 +1068,16 @@ class Layout:
                         "cmp-val-val": str()
                     })
             elif trigger.type == "table" and trigger.idx == "comparison":
                         "cmp-val-val": str()
                     })
             elif trigger.type == "table" and trigger.idx == "comparison":
-                filtered_data = filter_table_data(
-                    store_table_data,
-                    table_filter[0]
-                )
+                if trigger.parameter == "filter_query":
+                    filtered_data = filter_table_data(
+                        store_table_data,
+                        trigger.value
+                    )
+                elif trigger.parameter == "sort_by":
+                    filtered_data = sort_table_data(
+                        store_table_data,
+                        trigger.value
+                    )
                 table_data = [filtered_data, ]
 
             if all((on_draw, selected["reference"]["set"],
                 table_data = [filtered_data, ]
 
             if all((on_draw, selected["reference"]["set"],
@@ -1149,3 +1203,137 @@ class Layout:
             if n_clicks:
                 return not is_open
             return is_open
             if n_clicks:
                 return not is_open
             return is_open
+
+        @app.callback(
+            Output("offcanvas-details", "is_open"),
+            Output("offcanvas-details", "children"),
+            State("store-selected", "data"),
+            State("store-filtered-table-data", "data"),
+            State("normalize", "value"),
+            State("outliers", "value"),
+            Input({"type": "table", "index": ALL}, "active_cell"),
+            prevent_initial_call=True
+        )
+        def show_test_data(cp_sel, table, normalize, outliers, *_):
+            """Show offcanvas with graphs and tables based on selected test(s).
+            """
+
+            trigger = Trigger(callback_context.triggered)
+            if not all((trigger.value, cp_sel["reference"]["set"], \
+                        cp_sel["compare"]["set"])):
+                raise PreventUpdate
+
+            try:
+                test_name = pd.DataFrame.from_records(table).\
+                    iloc[[trigger.value["row"]]]["Test Name"].iloc[0]
+                dut = cp_sel["reference"]["selection"]["dut"]
+                rls, dutver = cp_sel["reference"]["selection"]["dutver"].\
+                    split("-", 1)
+                phy = cp_sel["reference"]["selection"]["infra"]
+                framesize, core, test_id = test_name.split("-", 2)
+                test, ttype = test_id.rsplit("-", 1)
+                ttype = "pdr" if ttype == "latency" else ttype
+                l_phy = phy.split("-")
+                tb = "-".join(l_phy[:2])
+                nic = l_phy[2]
+                stype = "ndrpdr" if ttype in ("ndr", "pdr") else ttype
+            except(KeyError, IndexError, AttributeError, ValueError):
+                raise PreventUpdate
+
+            df = pd.DataFrame(self._data.loc[(
+                    (self._data["dut_type"] == dut) &
+                    (self._data["dut_version"] == dutver) &
+                    (self._data["release"] == rls)
+                )])
+            df = df[df.job.str.endswith(tb)]
+            df = df[df.test_id.str.contains(
+                f"{nic}.*{test}-{stype}", regex=True
+            )]
+            if df.empty:
+                raise PreventUpdate
+
+            l_test_id = df["test_id"].iloc[0].split(".")
+            area = ".".join(l_test_id[3:-2])
+
+            r_sel = {
+                "id": f"{test}-{ttype}",
+                "rls": rls,
+                "dut": dut,
+                "dutver": dutver,
+                "phy": phy,
+                "area": area,
+                "test": test,
+                "framesize": framesize,
+                "core": core,
+                "testtype": ttype
+            }
+
+            c_sel = deepcopy(r_sel)
+            param = cp_sel["compare"]["parameter"]
+            val = cp_sel["compare"]["value"].lower()
+            if param == "dutver":
+                c_sel["rls"], c_sel["dutver"] = val.split("-", 1)
+            elif param == "ttype":
+                c_sel["id"] = f"{test}-{val}"
+                c_sel["testtype"] = val
+            elif param == "infra":
+                c_sel["phy"] = val
+            else:
+                c_sel[param] = val
+
+            r_sel["id"] = "-".join(
+                (r_sel["phy"], r_sel["framesize"], r_sel["core"], r_sel["id"])
+            )
+            c_sel["id"] = "-".join(
+                (c_sel["phy"], c_sel["framesize"], c_sel["core"], c_sel["id"])
+            )
+            selected = [r_sel, c_sel]
+
+            indexes = ("tput", "bandwidth", "lat")
+            graphs = graph_iterative(
+                self._data,
+                selected,
+                self._graph_layout,
+                bool(normalize),
+                bool(outliers)
+            )
+            cols = list()
+            for graph, idx in zip(graphs, indexes):
+                if graph:
+                    cols.append(dbc.Col(dcc.Graph(
+                        figure=graph,
+                        id={"type": "graph-iter", "index": idx},
+                    )))
+            if not cols:
+                cols="No data."
+            ret_val = [
+                dbc.Row(
+                    class_name="g-0 p-0",
+                    children=dbc.Alert(test, color="info"),
+                ),
+                dbc.Row(class_name="g-0 p-0", children=cols)
+            ]
+
+            return True, ret_val
+
+        @app.callback(
+            Output("metadata-tput-lat", "children"),
+            Output("metadata-hdrh-graph", "children"),
+            Output("offcanvas-metadata", "is_open"),
+            Input({"type": "graph-iter", "index": ALL}, "clickData"),
+            prevent_initial_call=True
+        )
+        def _show_metadata_from_graph(iter_data: dict) -> tuple:
+            """Generates the data for the offcanvas displayed when a particular
+            point in a graph is clicked on.
+            """
+
+            trigger = Trigger(callback_context.triggered)
+            if not trigger.value:
+                raise PreventUpdate
+
+            if trigger.type == "graph-iter":
+                return show_iterative_graph_data(
+                    trigger, iter_data, self._graph_layout)
+            else:
+                raise PreventUpdate
index 18f9404..0e32f38 100644 (file)
@@ -95,15 +95,14 @@ def select_comp_data(
                     tmp_df.extend(l_itm)
                 l_df = tmp_df
 
                     tmp_df.extend(l_itm)
                 l_df = tmp_df
 
-            if remove_outliers:
-                q1 = percentile(l_df, 25, method=C.COMP_PERCENTILE_METHOD)
-                q3 = percentile(l_df, 75, method=C.COMP_PERCENTILE_METHOD)
-                irq = q3 - q1
-                lif = q1 - C.COMP_OUTLIER_TYPE * irq
-                uif = q3 + C.COMP_OUTLIER_TYPE * irq
-                l_df = [i for i in l_df if i >= lif and i <= uif]
-
             try:
             try:
+                if remove_outliers:
+                    q1 = percentile(l_df, 25, method=C.COMP_PERCENTILE_METHOD)
+                    q3 = percentile(l_df, 75, method=C.COMP_PERCENTILE_METHOD)
+                    irq = q3 - q1
+                    lif = q1 - C.COMP_OUTLIER_TYPE * irq
+                    uif = q3 + C.COMP_OUTLIER_TYPE * irq
+                    l_df = [i for i in l_df if i >= lif and i <= uif]
                 mean_val = mean(l_df)
                 std_val = std(l_df)
             except (TypeError, ValueError):
                 mean_val = mean(l_df)
                 std_val = std(l_df)
             except (TypeError, ValueError):
index 44c57d4..0627411 100644 (file)
 """Implementation of graphs for iterative data.
 """
 
 """Implementation of graphs for iterative data.
 """
 
-
 import plotly.graph_objects as go
 import pandas as pd
 
 from copy import deepcopy
 import plotly.graph_objects as go
 import pandas as pd
 
 from copy import deepcopy
+from numpy import percentile
 
 from ..utils.constants import Constants as C
 from ..utils.utils import get_color, get_hdrh_latencies
 
 from ..utils.constants import Constants as C
 from ..utils.utils import get_color, get_hdrh_latencies
@@ -74,7 +74,7 @@ def select_iterative_data(data: pd.DataFrame, itm:dict) -> pd.DataFrame:
 
 
 def graph_iterative(data: pd.DataFrame, sel: list, layout: dict,
 
 
 def graph_iterative(data: pd.DataFrame, sel: list, layout: dict,
-        normalize: bool=False) -> tuple:
+        normalize: bool=False, remove_outliers: bool=False) -> tuple:
     """Generate the statistical box graph with iterative data (MRR, NDR and PDR,
     for PDR also Latencies).
 
     """Generate the statistical box graph with iterative data (MRR, NDR and PDR,
     for PDR also Latencies).
 
@@ -83,15 +83,19 @@ def graph_iterative(data: pd.DataFrame, sel: list, layout: dict,
     :param layout: Layout of plot.ly graph.
     :param normalize: If True, the data is normalized to CPU frequency
         Constants.NORM_FREQUENCY.
     :param layout: Layout of plot.ly graph.
     :param normalize: If True, the data is normalized to CPU frequency
         Constants.NORM_FREQUENCY.
-    :param data: pandas.DataFrame
-    :param sel: list
-    :param layout: dict
-    :param normalize: bool
+    :param remove_outliers: If True the outliers are removed before
+        generating the table.
+    :type data: pandas.DataFrame
+    :type sel: list
+    :type layout: dict
+    :type normalize: bool
+    :type remove_outliers: bool
     :returns: Tuple of graphs - throughput and latency.
     :rtype: tuple(plotly.graph_objects.Figure, plotly.graph_objects.Figure)
     """
 
     :returns: Tuple of graphs - throughput and latency.
     :rtype: tuple(plotly.graph_objects.Figure, plotly.graph_objects.Figure)
     """
 
-    def get_y_values(data, y_data_max, param, norm_factor, release=str()):
+    def get_y_values(data, y_data_max, param, norm_factor, release=str(),
+                     remove_outliers=False):
         if param == "result_receive_rate_rate_values":
             if release == "rls2402":
                 y_vals_raw = data["result_receive_rate_rate_avg"].to_list()
         if param == "result_receive_rate_rate_values":
             if release == "rls2402":
                 y_vals_raw = data["result_receive_rate_rate_avg"].to_list()
@@ -100,6 +104,17 @@ def graph_iterative(data: pd.DataFrame, sel: list, layout: dict,
         else:
             y_vals_raw = data[param].to_list()
         y_data = [(y * norm_factor) for y in y_vals_raw]
         else:
             y_vals_raw = data[param].to_list()
         y_data = [(y * norm_factor) for y in y_vals_raw]
+
+        if remove_outliers:
+            try:
+                q1 = percentile(y_data, 25, method=C.COMP_PERCENTILE_METHOD)
+                q3 = percentile(y_data, 75, method=C.COMP_PERCENTILE_METHOD)
+                irq = q3 - q1
+                lif = q1 - C.COMP_OUTLIER_TYPE * irq
+                uif = q3 + C.COMP_OUTLIER_TYPE * irq
+                y_data = [i for i in y_data if i >= lif and i <= uif]
+            except TypeError:
+                pass
         try:
             y_data_max = max(max(y_data), y_data_max)
         except TypeError:
         try:
             y_data_max = max(max(y_data), y_data_max)
         except TypeError:
@@ -142,7 +157,12 @@ def graph_iterative(data: pd.DataFrame, sel: list, layout: dict,
         y_units.update(itm_data[C.UNIT[ttype]].unique().tolist())
 
         y_data, y_tput_max = get_y_values(
         y_units.update(itm_data[C.UNIT[ttype]].unique().tolist())
 
         y_data, y_tput_max = get_y_values(
-            itm_data, y_tput_max, C.VALUE_ITER[ttype], norm_factor, itm["rls"]
+            itm_data,
+            y_tput_max,
+            C.VALUE_ITER[ttype],
+            norm_factor,
+            itm["rls"],
+            remove_outliers
         )
 
         nr_of_samples = len(y_data)
         )
 
         nr_of_samples = len(y_data)
@@ -192,7 +212,8 @@ def graph_iterative(data: pd.DataFrame, sel: list, layout: dict,
                 itm_data,
                 y_band_max,
                 C.VALUE_ITER[f"{ttype}-bandwidth"],
                 itm_data,
                 y_band_max,
                 C.VALUE_ITER[f"{ttype}-bandwidth"],
-                norm_factor
+                norm_factor,
+                remove_outliers=remove_outliers
             )
             if not all(pd.isna(y_band)):
                 y_band_units.update(
             )
             if not all(pd.isna(y_band)):
                 y_band_units.update(
@@ -221,7 +242,8 @@ def graph_iterative(data: pd.DataFrame, sel: list, layout: dict,
                 itm_data,
                 y_lat_max,
                 C.VALUE_ITER["latency"],
                 itm_data,
                 y_lat_max,
                 C.VALUE_ITER["latency"],
-                1 / norm_factor
+                1 / norm_factor,
+                remove_outliers=remove_outliers
             )
             if not all(pd.isna(y_lat)):
                 customdata = list()
             )
             if not all(pd.isna(y_lat)):
                 customdata = list()
index c803505..aa4dd53 100644 (file)
@@ -32,8 +32,8 @@ from ..utils.constants import Constants as C
 from ..utils.control_panel import ControlPanel
 from ..utils.trigger import Trigger
 from ..utils.utils import gen_new_url, generate_options, navbar_trending, \
 from ..utils.control_panel import ControlPanel
 from ..utils.trigger import Trigger
 from ..utils.utils import gen_new_url, generate_options, navbar_trending, \
-    filter_table_data, show_trending_graph_data, show_iterative_graph_data, \
-    show_tooltip
+    filter_table_data, sort_table_data, show_trending_graph_data, \
+    show_iterative_graph_data, show_tooltip
 from ..utils.url_processing import url_decode
 from .tables import search_table
 from ..coverage.tables import coverage_tables
 from ..utils.url_processing import url_decode
 from .tables import search_table
 from ..coverage.tables import coverage_tables
@@ -448,7 +448,7 @@ class Layout:
                             columns=columns,
                             data=table.to_dict("records"),
                             filter_action="custom",
                             columns=columns,
                             data=table.to_dict("records"),
                             filter_action="custom",
-                            sort_action="native",
+                            sort_action="custom",
                             sort_mode="multi",
                             selected_columns=[],
                             selected_rows=[],
                             sort_mode="multi",
                             selected_columns=[],
                             selected_rows=[],
@@ -538,6 +538,7 @@ class Layout:
             State({"type": "table", "index": ALL}, "data"),
             Input("url", "href"),
             Input({"type": "table", "index": ALL}, "filter_query"),
             State({"type": "table", "index": ALL}, "data"),
             Input("url", "href"),
             Input({"type": "table", "index": ALL}, "filter_query"),
+            Input({"type": "table", "index": ALL}, "sort_by"),
             Input({"type": "ctrl-dd", "index": ALL}, "value"),
             prevent_initial_call=True
         )
             Input({"type": "ctrl-dd", "index": ALL}, "value"),
             prevent_initial_call=True
         )
@@ -679,10 +680,16 @@ class Layout:
                     }
                     on_draw = True
             elif trigger.type == "table" and trigger.idx == "search":
                     }
                     on_draw = True
             elif trigger.type == "table" and trigger.idx == "search":
-                filtered_data = filter_table_data(
-                    store_table_data,
-                    trigger.value
-                )
+                if trigger.parameter == "filter_query":
+                    filtered_data = filter_table_data(
+                        store_table_data,
+                        trigger.value
+                    )
+                elif trigger.parameter == "sort_by":
+                    filtered_data = sort_table_data(
+                        store_table_data,
+                        trigger.value
+                    )
                 table_data = [filtered_data, ]
 
             if on_draw:
                 table_data = [filtered_data, ]
 
             if on_draw:
@@ -735,8 +742,8 @@ class Layout:
                 rls = store["selection"]["release"]
                 tb = row["Test Bed"].iloc[0]
                 nic = row["NIC"].iloc[0]
                 rls = store["selection"]["release"]
                 tb = row["Test Bed"].iloc[0]
                 nic = row["NIC"].iloc[0]
-                driver = row['Driver'].iloc[0]
-                test_name = row['Test'].iloc[0]
+                driver = row["Driver"].iloc[0]
+                test_name = row["Test"].iloc[0]
                 dutver = str()
             except(KeyError, IndexError, AttributeError, ValueError):
                 raise PreventUpdate
                 dutver = str()
             except(KeyError, IndexError, AttributeError, ValueError):
                 raise PreventUpdate
@@ -777,7 +784,7 @@ class Layout:
                     testtype = [testtype, ]
                 core = l_test[1] if l_test[1] else "8c"
                 test = "-".join(l_test[2: -1])
                     testtype = [testtype, ]
                 core = l_test[1] if l_test[1] else "8c"
                 test = "-".join(l_test[2: -1])
-                test_id = f"{tb}-{nic}-{driver}-{core}-{l_test[0]}-{test}"
+                test_id = f"{tb}-{nic}-{driver}-{l_test[0]}-{core}-{test}"
                 title = dbc.Row(
                     class_name="g-0 p-0",
                     children=dbc.Alert(test_id, color="info"),
                 title = dbc.Row(
                     class_name="g-0 p-0",
                     children=dbc.Alert(test_id, color="info"),
@@ -873,7 +880,7 @@ class Layout:
             Input({"type": "graph-iter", "index": ALL}, "clickData"),
             prevent_initial_call=True
         )
             Input({"type": "graph-iter", "index": ALL}, "clickData"),
             prevent_initial_call=True
         )
-        def _show_metadata_from_trend_graph(
+        def _show_metadata_from_graph(
                 trend_data: dict,
                 iter_data: dict
             ) -> tuple:
                 trend_data: dict,
                 iter_data: dict
             ) -> tuple:
index 3d2866f..692e45e 100644 (file)
@@ -631,6 +631,39 @@ def filter_table_data(
     return df.to_dict("records")
 
 
     return df.to_dict("records")
 
 
+def sort_table_data(
+        store_table_data: list,
+        sort_by: list
+    ) -> list:
+    """Sort table data using user specified order.
+
+    :param store_table_data: Table data represented as a list of records.
+    :param sort_by: User specified sorting order (multicolumn).
+    :type store_table_data: list
+    :type sort_by: list
+    :returns: A new table created by sorting the table data represented as
+        a list of records.
+    :rtype: list
+    """
+
+    # Checks:
+    if not any((sort_by, store_table_data, )):
+        return store_table_data
+
+    df = pd.DataFrame.from_records(store_table_data)
+    if len(sort_by):
+        dff = df.sort_values(
+            [col["column_id"] for col in sort_by],
+            ascending=[col["direction"] == "asc" for col in sort_by],
+            inplace=False
+        )
+    else:
+        # No sort is applied
+        dff = df
+
+    return dff.to_dict("records")
+
+
 def show_trending_graph_data(
         trigger: Trigger,
         data: dict,
 def show_trending_graph_data(
         trigger: Trigger,
         data: dict,