a2d51d46a3aade989df3de39c64b8debd0172053
[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(area, None) is None:
117                 tbs[rls][dut][d_ver][area] = list()
118             if infra not in tbs[rls][dut][d_ver][area]:
119                 tbs[rls][dut][d_ver][area].append(infra)
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("Area"),
346                             dbc.Select(
347                                 id={"type": "ctrl-dd", "index": "area"},
348                                 placeholder="Select an Area..."
349                             )
350                         ],
351                         size="sm"
352                     )
353                 ]
354             ),
355             dbc.Row(
356                 class_name="g-0 p-1",
357                 children=[
358                     dbc.InputGroup(
359                         [
360                             dbc.InputGroupText("Infra"),
361                             dbc.Select(
362                                 id={"type": "ctrl-dd", "index": "phy"},
363                                 placeholder=\
364                                     "Select a Physical Test Bed Topology..."
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                         "area-val": selected["area"],
556                         "area-opt": [
557                             {"label": label(v), "value": v} \
558                                 for v in sorted(self._spec_tbs[selected["rls"]]\
559                                     [selected["dut"]]\
560                                         [selected["dutver"]].keys())
561                         ],
562                         "area-dis": False,
563                         "phy-val": selected["phy"],
564                         "phy-opt": generate_options(
565                             self._spec_tbs[selected["rls"]][selected["dut"]]\
566                                 [selected["dutver"]][selected["area"]]
567                         ),
568                         "phy-dis": False,
569                         "show-latency": show_latency
570                     })
571                     on_draw = True
572             elif trigger.type == "show-latency":
573                 ctrl_panel.set({"show-latency": show_latency})
574                 on_draw = True
575             elif trigger.type == "ctrl-dd":
576                 if trigger.idx == "rls":
577                     try:
578                         options = generate_options(
579                             self._spec_tbs[trigger.value].keys()
580                         )
581                         disabled = False
582                     except KeyError:
583                         options = list()
584                         disabled = True
585                     ctrl_panel.set({
586                         "rls-val": trigger.value,
587                         "dut-val": str(),
588                         "dut-opt": options,
589                         "dut-dis": disabled,
590                         "dutver-val": str(),
591                         "dutver-opt": list(),
592                         "dutver-dis": True,
593                         "phy-val": str(),
594                         "phy-opt": list(),
595                         "phy-dis": True,
596                         "area-val": str(),
597                         "area-opt": list(),
598                         "area-dis": True
599                     })
600                 elif trigger.idx == "dut":
601                     try:
602                         rls = ctrl_panel.get("rls-val")
603                         dut = self._spec_tbs[rls][trigger.value]
604                         options = generate_options(dut.keys())
605                         disabled = False
606                     except KeyError:
607                         options = list()
608                         disabled = True
609                     ctrl_panel.set({
610                         "dut-val": trigger.value,
611                         "dutver-val": str(),
612                         "dutver-opt": options,
613                         "dutver-dis": disabled,
614                         "phy-val": str(),
615                         "phy-opt": list(),
616                         "phy-dis": True,
617                         "area-val": str(),
618                         "area-opt": list(),
619                         "area-dis": True
620                     })
621                 elif trigger.idx == "dutver":
622                     try:
623                         rls = ctrl_panel.get("rls-val")
624                         dut = ctrl_panel.get("dut-val")
625                         ver = self._spec_tbs[rls][dut][trigger.value]
626                         options = [
627                             {"label": label(v), "value": v} for v in sorted(ver)
628                         ]
629                         disabled = False
630                     except KeyError:
631                         options = list()
632                         disabled = True
633                     ctrl_panel.set({
634                         "dutver-val": trigger.value,
635                         "area-val": str(),
636                         "area-opt": options,
637                         "area-dis": disabled,
638                         "phy-val": str(),
639                         "phy-opt": list(),
640                         "phy-dis": True
641                     })
642                 elif trigger.idx == "area":
643                     try:
644                         rls = ctrl_panel.get("rls-val")
645                         dut = ctrl_panel.get("dut-val")
646                         ver = ctrl_panel.get("dutver-val")
647                         options = generate_options(
648                             self._spec_tbs[rls][dut][ver][trigger.value])
649                         disabled = False
650                     except KeyError:
651                         options = list()
652                         disabled = True
653                     ctrl_panel.set({
654                         "area-val": trigger.value,
655                         "phy-val": str(),
656                         "phy-opt": options,
657                         "phy-dis": disabled
658                     })
659                 elif trigger.idx == "phy":
660                     ctrl_panel.set({"phy-val": trigger.value})
661                     selected = {
662                         "rls": ctrl_panel.get("rls-val"),
663                         "dut": ctrl_panel.get("dut-val"),
664                         "dutver": ctrl_panel.get("dutver-val"),
665                         "phy": ctrl_panel.get("phy-val"),
666                         "area": ctrl_panel.get("area-val"),
667                     }
668                     on_draw = True
669
670             if on_draw:
671                 if selected:
672                     plotting_area = self._get_plotting_area(
673                         selected,
674                         gen_new_url(
675                             parsed_url,
676                             {
677                                 "selection": selected,
678                                 "show_latency": show_latency
679                             }
680                         ),
681                         show_latency=bool(show_latency)
682                     )
683                 else:
684                     plotting_area = C.PLACEHOLDER
685                     selected = dict()
686
687             ret_val = [
688                 ctrl_panel.panel,
689                 selected,
690                 plotting_area,
691             ]
692             ret_val.extend(ctrl_panel.values)
693             return ret_val
694
695         @app.callback(
696             Output("plot-mod-url", "is_open"),
697             [Input("plot-btn-url", "n_clicks")],
698             [State("plot-mod-url", "is_open")],
699         )
700         def toggle_plot_mod_url(n, is_open):
701             """Toggle the modal window with url.
702             """
703             if n:
704                 return not is_open
705             return is_open
706
707         @app.callback(
708             Output("download-iterative-data", "data"),
709             State("store-selected-tests", "data"),
710             State("show-latency", "value"),
711             Input("plot-btn-download", "n_clicks"),
712             prevent_initial_call=True
713         )
714         def _download_coverage_data(selection, show_latency, _):
715             """Download the data
716
717             :param selection: List of tests selected by user stored in the
718                 browser.
719             :param show_latency: If True, latency is displayed in the tables.
720             :type selection: dict
721             :type show_latency: bool
722             :returns: dict of data frame content (base64 encoded) and meta data
723                 used by the Download component.
724             :rtype: dict
725             """
726
727             if not selection:
728                 raise PreventUpdate
729
730             df = select_coverage_data(
731                 self._data,
732                 selection,
733                 csv=True,
734                 show_latency=bool(show_latency)
735             )
736
737             return dcc.send_data_frame(df.to_csv, C.COVERAGE_DOWNLOAD_FILE_NAME)
738
739         @app.callback(
740             Output("offcanvas-documentation", "is_open"),
741             Input("btn-documentation", "n_clicks"),
742             State("offcanvas-documentation", "is_open")
743         )
744         def toggle_offcanvas_documentation(n_clicks, is_open):
745             if n_clicks:
746                 return not is_open
747             return is_open