C-Dash: Add tooltips
[csit.git] / csit.infra.dash / app / cdash / coverage / layout.py
1 # Copyright (c) 2024 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """Plotly Dash HTML layout override.
15 """
16
17
18 import logging
19 import pandas as pd
20 import dash_bootstrap_components as dbc
21
22 from flask import Flask
23 from dash import dcc
24 from dash import html
25 from dash import callback_context, no_update, ALL
26 from dash import Input, Output, State
27 from dash.exceptions import PreventUpdate
28 from ast import literal_eval
29 from yaml import load, FullLoader, YAMLError
30
31 from ..utils.constants import Constants as C
32 from ..utils.control_panel import ControlPanel
33 from ..utils.trigger import Trigger
34 from ..utils.utils import label, gen_new_url, generate_options, navbar_report, \
35     show_tooltip
36 from ..utils.url_processing import url_decode
37 from .tables import coverage_tables, select_coverage_data
38
39
40 # Control panel partameters and their default values.
41 CP_PARAMS = {
42     "rls-val": str(),
43     "dut-opt": list(),
44     "dut-dis": True,
45     "dut-val": str(),
46     "dutver-opt": list(),
47     "dutver-dis": True,
48     "dutver-val": str(),
49     "phy-opt": list(),
50     "phy-dis": True,
51     "phy-val": str(),
52     "area-opt": list(),
53     "area-dis": True,
54     "area-val": str(),
55     "show-latency": ["show_latency", ]
56 }
57
58
59 class Layout:
60     """The layout of the dash app and the callbacks.
61     """
62
63     def __init__(
64             self,
65             app: Flask,
66             data_coverage: pd.DataFrame,
67             html_layout_file: str,
68             tooltip_file: str
69         ) -> None:
70         """Initialization:
71         - save the input parameters,
72         - prepare data for the control panel,
73         - read HTML layout file,
74
75         :param app: Flask application running the dash application.
76         :param html_layout_file: Path and name of the file specifying the HTML
77             layout of the dash application.
78         :param tooltip_file: Path and name of the yaml file specifying the
79             tooltips.
80         :type app: Flask
81         :type html_layout_file: str
82         :type tooltip_file: str
83         """
84
85         # Inputs
86         self._app = app
87         self._data = data_coverage
88         self._html_layout_file = html_layout_file
89         self._tooltip_file = tooltip_file
90
91         # Get structure of tests:
92         tbs = dict()
93         cols = ["job", "test_id", "dut_version", "release", ]
94         for _, row in self._data[cols].drop_duplicates().iterrows():
95             rls = row["release"]
96             lst_job = row["job"].split("-")
97             dut = lst_job[1]
98             d_ver = row["dut_version"]
99             tbed = "-".join(lst_job[-2:])
100             lst_test_id = row["test_id"].split(".")
101             if dut == "dpdk":
102                 area = "dpdk"
103             else:
104                 area = ".".join(lst_test_id[3:-2])
105             suite = lst_test_id[-2].replace("2n1l-", "").replace("1n1l-", "").\
106                 replace("2n-", "")
107             test = lst_test_id[-1]
108             nic = suite.split("-")[0]
109             for drv in C.DRIVERS:
110                 if drv in test:
111                     driver = drv.replace("-", "_")
112                     test = test.replace(f"{drv}-", "")
113                     break
114             else:
115                 driver = "dpdk"
116             infra = "-".join((tbed, nic, driver))
117
118             if tbs.get(rls, None) is None:
119                 tbs[rls] = dict()
120             if tbs[rls].get(dut, None) is None:
121                 tbs[rls][dut] = dict()
122             if tbs[rls][dut].get(d_ver, None) is None:
123                 tbs[rls][dut][d_ver] = dict()
124             if tbs[rls][dut][d_ver].get(area, None) is None:
125                 tbs[rls][dut][d_ver][area] = list()
126             if infra not in tbs[rls][dut][d_ver][area]:
127                 tbs[rls][dut][d_ver][area].append(infra)
128
129         self._spec_tbs = tbs
130
131         # Read from files:
132         self._html_layout = str()
133
134         try:
135             with open(self._html_layout_file, "r") as file_read:
136                 self._html_layout = file_read.read()
137         except IOError as err:
138             raise RuntimeError(
139                 f"Not possible to open the file {self._html_layout_file}\n{err}"
140             )
141
142         try:
143             with open(self._tooltip_file, "r") as file_read:
144                 self._tooltips = load(file_read, Loader=FullLoader)
145         except IOError as err:
146             logging.warning(
147                 f"Not possible to open the file {self._tooltip_file}\n{err}"
148             )
149         except YAMLError as err:
150             logging.warning(
151                 f"An error occurred while parsing the specification file "
152                 f"{self._tooltip_file}\n{err}"
153             )
154
155         # Callbacks:
156         if self._app is not None and hasattr(self, "callbacks"):
157             self.callbacks(self._app)
158
159     @property
160     def html_layout(self):
161         return self._html_layout
162
163     def add_content(self):
164         """Top level method which generated the web page.
165
166         It generates:
167         - Store for user input data,
168         - Navigation bar,
169         - Main area with control panel and ploting area.
170
171         If no HTML layout is provided, an error message is displayed instead.
172
173         :returns: The HTML div with the whole page.
174         :rtype: html.Div
175         """
176
177         if self.html_layout and self._spec_tbs:
178             return html.Div(
179                 id="div-main",
180                 className="small",
181                 children=[
182                     dbc.Row(
183                         id="row-navbar",
184                         class_name="g-0",
185                         children=[navbar_report((False, False, True, False)), ]
186                     ),
187                     dbc.Row(
188                         id="row-main",
189                         class_name="g-0",
190                         children=[
191                             dcc.Store(id="store-selected-tests"),
192                             dcc.Store(id="store-control-panel"),
193                             dcc.Location(id="url", refresh=False),
194                             self._add_ctrl_col(),
195                             self._add_plotting_col()
196                         ]
197                     ),
198                     dbc.Offcanvas(
199                         class_name="w-75",
200                         id="offcanvas-documentation",
201                         title="Documentation",
202                         placement="end",
203                         is_open=False,
204                         children=html.Iframe(
205                             src=C.URL_DOC_REL_NOTES,
206                             width="100%",
207                             height="100%"
208                         )
209                     )
210                 ]
211             )
212         else:
213             return html.Div(
214                 id="div-main-error",
215                 children=[
216                     dbc.Alert(
217                         [
218                             "An Error Occured"
219                         ],
220                         color="danger"
221                     )
222                 ]
223             )
224
225     def _add_ctrl_col(self) -> dbc.Col:
226         """Add column with controls. It is placed on the left side.
227
228         :returns: Column with the control panel.
229         :rtype: dbc.Col
230         """
231         return dbc.Col([
232             html.Div(
233                 children=self._add_ctrl_panel(),
234                 className="sticky-top"
235             )
236         ])
237
238     def _add_plotting_col(self) -> dbc.Col:
239         """Add column with plots. It is placed on the right side.
240
241         :returns: Column with plots.
242         :rtype: dbc.Col
243         """
244         return dbc.Col(
245             id="col-plotting-area",
246             children=[
247                 dbc.Spinner(
248                     children=[
249                         dbc.Row(
250                             id="plotting-area",
251                             class_name="g-0 p-0",
252                             children=[
253                                 C.PLACEHOLDER
254                             ]
255                         )
256                     ]
257                 )
258             ],
259             width=9
260         )
261
262     def _add_ctrl_panel(self) -> list:
263         """Add control panel.
264
265         :returns: Control panel.
266         :rtype: list
267         """
268         return [
269             dbc.Row(
270                 class_name="g-0 p-1",
271                 children=[
272                     dbc.InputGroup(
273                         [
274                             dbc.InputGroupText(show_tooltip(
275                                 self._tooltips,
276                                 "help-release",
277                                 "CSIT Release"
278                             )),
279                             dbc.Select(
280                                 id={"type": "ctrl-dd", "index": "rls"},
281                                 placeholder="Select a Release...",
282                                 options=sorted(
283                                     [
284                                         {"label": k, "value": k} \
285                                             for k in self._spec_tbs.keys()
286                                     ],
287                                     key=lambda d: d["label"]
288                                 )
289                             )
290                         ],
291                         size="sm"
292                     )
293                 ]
294             ),
295             dbc.Row(
296                 class_name="g-0 p-1",
297                 children=[
298                     dbc.InputGroup(
299                         [
300                             dbc.InputGroupText(show_tooltip(
301                                 self._tooltips,
302                                 "help-dut",
303                                 "DUT"
304                             )),
305                             dbc.Select(
306                                 id={"type": "ctrl-dd", "index": "dut"},
307                                 placeholder="Select a Device under Test..."
308                             )
309                         ],
310                         size="sm"
311                     )
312                 ]
313             ),
314             dbc.Row(
315                 class_name="g-0 p-1",
316                 children=[
317                     dbc.InputGroup(
318                         [
319                             dbc.InputGroupText(show_tooltip(
320                                 self._tooltips,
321                                 "help-dut-ver",
322                                 "DUT Version"
323                             )),
324                             dbc.Select(
325                                 id={"type": "ctrl-dd", "index": "dutver"},
326                                 placeholder=\
327                                     "Select a Version of Device under Test..."
328                             )
329                         ],
330                         size="sm"
331                     )
332                 ]
333             ),
334             dbc.Row(
335                 class_name="g-0 p-1",
336                 children=[
337                     dbc.InputGroup(
338                         [
339                             dbc.InputGroupText(show_tooltip(
340                                 self._tooltips,
341                                 "help-area",
342                                 "Area"
343                             )),
344                             dbc.Select(
345                                 id={"type": "ctrl-dd", "index": "area"},
346                                 placeholder="Select an Area..."
347                             )
348                         ],
349                         size="sm"
350                     )
351                 ]
352             ),
353             dbc.Row(
354                 class_name="g-0 p-1",
355                 children=[
356                     dbc.InputGroup(
357                         [
358                             dbc.InputGroupText(show_tooltip(
359                                 self._tooltips,
360                                 "help-infra",
361                                 "Infra"
362                             )),
363                             dbc.Select(
364                                 id={"type": "ctrl-dd", "index": "phy"},
365                                 placeholder=\
366                                     "Select a Physical Test Bed Topology..."
367                             )
368                         ],
369                         size="sm"
370                     )
371                 ]
372             ),
373             dbc.Row(
374                 class_name="g-0 p-1",
375                 children=[
376                     dbc.InputGroup(
377                         [
378                             dbc.InputGroupText(show_tooltip(
379                                 self._tooltips,
380                                 "help-show-latency",
381                                 "Latency"
382                             )),
383                             dbc.Checklist(
384                                 id="show-latency",
385                                 options=[{
386                                     "value": "show_latency",
387                                     "label": "Show Latency"
388                                 }],
389                                 value=["show_latency"],
390                                 inline=True,
391                                 class_name="ms-2"
392                             )
393                         ],
394                         style={"align-items": "center"},
395                         size="sm"
396                     )
397                 ]
398             )
399         ]
400
401     def _get_plotting_area(
402             self,
403             selected: dict,
404             url: str,
405             show_latency: bool
406         ) -> list:
407         """Generate the plotting area with all its content.
408
409         :param selected: Selected parameters of tests.
410         :param url: URL to be displayed in the modal window.
411         :param show_latency: If True, latency is displayed in the tables.
412         :type selected: dict
413         :type url: str
414         :type show_latency: bool
415         :returns: List of rows with elements to be displayed in the plotting
416             area.
417         :rtype: list
418         """
419         if not selected:
420             return C.PLACEHOLDER
421
422         return [
423             dbc.Row(
424                 children=coverage_tables(self._data, selected, show_latency),
425                 class_name="g-0 p-0",
426             ),
427             dbc.Row(
428                 children=C.PLACEHOLDER,
429                 class_name="g-0 p-1"
430             ),
431             dbc.Row(
432                 [
433                     dbc.Col([html.Div(
434                         [
435                             dbc.Button(
436                                 id="plot-btn-url",
437                                 children="Show URL",
438                                 class_name="me-1",
439                                 color="info",
440                                 style={
441                                     "text-transform": "none",
442                                     "padding": "0rem 1rem"
443                                 }
444                             ),
445                             dbc.Modal(
446                                 [
447                                     dbc.ModalHeader(dbc.ModalTitle("URL")),
448                                     dbc.ModalBody(url)
449                                 ],
450                                 id="plot-mod-url",
451                                 size="xl",
452                                 is_open=False,
453                                 scrollable=True
454                             ),
455                             dbc.Button(
456                                 id="plot-btn-download",
457                                 children="Download Data",
458                                 class_name="me-1",
459                                 color="info",
460                                 style={
461                                     "text-transform": "none",
462                                     "padding": "0rem 1rem"
463                                 }
464                             ),
465                             dcc.Download(id="download-iterative-data")
466                         ],
467                         className=\
468                             "d-grid gap-0 d-md-flex justify-content-md-end"
469                     )])
470                 ],
471                 class_name="g-0 p-0"
472             ),
473             dbc.Row(
474                 children=C.PLACEHOLDER,
475                 class_name="g-0 p-1"
476             )
477         ]
478
479     def callbacks(self, app):
480         """Callbacks for the whole application.
481
482         :param app: The application.
483         :type app: Flask
484         """
485
486         @app.callback(
487             [
488                 Output("store-control-panel", "data"),
489                 Output("store-selected-tests", "data"),
490                 Output("plotting-area", "children"),
491                 Output({"type": "ctrl-dd", "index": "rls"}, "value"),
492                 Output({"type": "ctrl-dd", "index": "dut"}, "options"),
493                 Output({"type": "ctrl-dd", "index": "dut"}, "disabled"),
494                 Output({"type": "ctrl-dd", "index": "dut"}, "value"),
495                 Output({"type": "ctrl-dd", "index": "dutver"}, "options"),
496                 Output({"type": "ctrl-dd", "index": "dutver"}, "disabled"),
497                 Output({"type": "ctrl-dd", "index": "dutver"}, "value"),
498                 Output({"type": "ctrl-dd", "index": "phy"}, "options"),
499                 Output({"type": "ctrl-dd", "index": "phy"}, "disabled"),
500                 Output({"type": "ctrl-dd", "index": "phy"}, "value"),
501                 Output({"type": "ctrl-dd", "index": "area"}, "options"),
502                 Output({"type": "ctrl-dd", "index": "area"}, "disabled"),
503                 Output({"type": "ctrl-dd", "index": "area"}, "value"),
504                 Output("show-latency", "value"),
505             ],
506             [
507                 State("store-control-panel", "data"),
508                 State("store-selected-tests", "data")
509             ],
510             [
511                 Input("url", "href"),
512                 Input("show-latency", "value"),
513                 Input({"type": "ctrl-dd", "index": ALL}, "value")
514             ]
515         )
516         def _update_application(
517                 control_panel: dict,
518                 selected: dict,
519                 href: str,
520                 show_latency: list,
521                 *_
522             ) -> tuple:
523             """Update the application when the event is detected.
524             """
525
526             ctrl_panel = ControlPanel(CP_PARAMS, control_panel)
527             plotting_area = no_update
528             on_draw = False
529             if selected is None:
530                 selected = dict()
531
532             # Parse the url:
533             parsed_url = url_decode(href)
534             if parsed_url:
535                 url_params = parsed_url["params"]
536             else:
537                 url_params = None
538
539             trigger = Trigger(callback_context.triggered)
540
541             if trigger.type == "url" and url_params:
542                 try:
543                     show_latency = literal_eval(url_params["show_latency"][0])
544                     selected = literal_eval(url_params["selection"][0])
545                 except (KeyError, IndexError, AttributeError):
546                     pass
547                 if selected:
548                     ctrl_panel.set({
549                         "rls-val": selected["rls"],
550                         "dut-val": selected["dut"],
551                         "dut-opt": generate_options(
552                             self._spec_tbs[selected["rls"]].keys()
553                         ),
554                         "dut-dis": False,
555                         "dutver-val": selected["dutver"],
556                         "dutver-opt": generate_options(
557                             self._spec_tbs[selected["rls"]]\
558                                 [selected["dut"]].keys()
559                         ),
560                         "dutver-dis": False,
561                         "area-val": selected["area"],
562                         "area-opt": [
563                             {"label": label(v), "value": v} \
564                                 for v in sorted(self._spec_tbs[selected["rls"]]\
565                                     [selected["dut"]]\
566                                         [selected["dutver"]].keys())
567                         ],
568                         "area-dis": False,
569                         "phy-val": selected["phy"],
570                         "phy-opt": generate_options(
571                             self._spec_tbs[selected["rls"]][selected["dut"]]\
572                                 [selected["dutver"]][selected["area"]]
573                         ),
574                         "phy-dis": False,
575                         "show-latency": show_latency
576                     })
577                     on_draw = True
578             elif trigger.type == "show-latency":
579                 ctrl_panel.set({"show-latency": show_latency})
580                 on_draw = True
581             elif trigger.type == "ctrl-dd":
582                 if trigger.idx == "rls":
583                     try:
584                         options = generate_options(
585                             self._spec_tbs[trigger.value].keys()
586                         )
587                         disabled = False
588                     except KeyError:
589                         options = list()
590                         disabled = True
591                     ctrl_panel.set({
592                         "rls-val": trigger.value,
593                         "dut-val": str(),
594                         "dut-opt": options,
595                         "dut-dis": disabled,
596                         "dutver-val": str(),
597                         "dutver-opt": list(),
598                         "dutver-dis": True,
599                         "phy-val": str(),
600                         "phy-opt": list(),
601                         "phy-dis": True,
602                         "area-val": str(),
603                         "area-opt": list(),
604                         "area-dis": True
605                     })
606                 elif trigger.idx == "dut":
607                     try:
608                         rls = ctrl_panel.get("rls-val")
609                         dut = self._spec_tbs[rls][trigger.value]
610                         options = generate_options(dut.keys())
611                         disabled = False
612                     except KeyError:
613                         options = list()
614                         disabled = True
615                     ctrl_panel.set({
616                         "dut-val": trigger.value,
617                         "dutver-val": str(),
618                         "dutver-opt": options,
619                         "dutver-dis": disabled,
620                         "phy-val": str(),
621                         "phy-opt": list(),
622                         "phy-dis": True,
623                         "area-val": str(),
624                         "area-opt": list(),
625                         "area-dis": True
626                     })
627                 elif trigger.idx == "dutver":
628                     try:
629                         rls = ctrl_panel.get("rls-val")
630                         dut = ctrl_panel.get("dut-val")
631                         ver = self._spec_tbs[rls][dut][trigger.value]
632                         options = [
633                             {"label": label(v), "value": v} for v in sorted(ver)
634                         ]
635                         disabled = False
636                     except KeyError:
637                         options = list()
638                         disabled = True
639                     ctrl_panel.set({
640                         "dutver-val": trigger.value,
641                         "area-val": str(),
642                         "area-opt": options,
643                         "area-dis": disabled,
644                         "phy-val": str(),
645                         "phy-opt": list(),
646                         "phy-dis": True
647                     })
648                 elif trigger.idx == "area":
649                     try:
650                         rls = ctrl_panel.get("rls-val")
651                         dut = ctrl_panel.get("dut-val")
652                         ver = ctrl_panel.get("dutver-val")
653                         options = generate_options(
654                             self._spec_tbs[rls][dut][ver][trigger.value])
655                         disabled = False
656                     except KeyError:
657                         options = list()
658                         disabled = True
659                     ctrl_panel.set({
660                         "area-val": trigger.value,
661                         "phy-val": str(),
662                         "phy-opt": options,
663                         "phy-dis": disabled
664                     })
665                 elif trigger.idx == "phy":
666                     ctrl_panel.set({"phy-val": trigger.value})
667                     selected = {
668                         "rls": ctrl_panel.get("rls-val"),
669                         "dut": ctrl_panel.get("dut-val"),
670                         "dutver": ctrl_panel.get("dutver-val"),
671                         "phy": ctrl_panel.get("phy-val"),
672                         "area": ctrl_panel.get("area-val"),
673                     }
674                     on_draw = True
675
676             if on_draw:
677                 if selected:
678                     plotting_area = self._get_plotting_area(
679                         selected,
680                         gen_new_url(
681                             parsed_url,
682                             {
683                                 "selection": selected,
684                                 "show_latency": show_latency
685                             }
686                         ),
687                         show_latency=bool(show_latency)
688                     )
689                 else:
690                     plotting_area = C.PLACEHOLDER
691                     selected = dict()
692
693             ret_val = [
694                 ctrl_panel.panel,
695                 selected,
696                 plotting_area,
697             ]
698             ret_val.extend(ctrl_panel.values)
699             return ret_val
700
701         @app.callback(
702             Output("plot-mod-url", "is_open"),
703             [Input("plot-btn-url", "n_clicks")],
704             [State("plot-mod-url", "is_open")],
705         )
706         def toggle_plot_mod_url(n, is_open):
707             """Toggle the modal window with url.
708             """
709             if n:
710                 return not is_open
711             return is_open
712
713         @app.callback(
714             Output("download-iterative-data", "data"),
715             State("store-selected-tests", "data"),
716             State("show-latency", "value"),
717             Input("plot-btn-download", "n_clicks"),
718             prevent_initial_call=True
719         )
720         def _download_coverage_data(selection, show_latency, _):
721             """Download the data
722
723             :param selection: List of tests selected by user stored in the
724                 browser.
725             :param show_latency: If True, latency is displayed in the tables.
726             :type selection: dict
727             :type show_latency: bool
728             :returns: dict of data frame content (base64 encoded) and meta data
729                 used by the Download component.
730             :rtype: dict
731             """
732
733             if not selection:
734                 raise PreventUpdate
735
736             df = select_coverage_data(
737                 self._data,
738                 selection,
739                 csv=True,
740                 show_latency=bool(show_latency)
741             )
742
743             return dcc.send_data_frame(df.to_csv, C.COVERAGE_DOWNLOAD_FILE_NAME)
744
745         @app.callback(
746             Output("offcanvas-documentation", "is_open"),
747             Input("btn-documentation", "n_clicks"),
748             State("offcanvas-documentation", "is_open")
749         )
750         def toggle_offcanvas_documentation(n_clicks, is_open):
751             if n_clicks:
752                 return not is_open
753             return is_open