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