C-Dash: Add search in tests
[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 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, navbar_report
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=[navbar_report((False, False, True, False)), ]
165                     ),
166                     dbc.Row(
167                         id="row-main",
168                         class_name="g-0",
169                         children=[
170                             dcc.Store(id="store-selected-tests"),
171                             dcc.Store(id="store-control-panel"),
172                             dcc.Location(id="url", refresh=False),
173                             self._add_ctrl_col(),
174                             self._add_plotting_col()
175                         ]
176                     ),
177                     dbc.Offcanvas(
178                         class_name="w-75",
179                         id="offcanvas-documentation",
180                         title="Documentation",
181                         placement="end",
182                         is_open=False,
183                         children=html.Iframe(
184                             src=C.URL_DOC_REL_NOTES,
185                             width="100%",
186                             height="100%"
187                         )
188                     )
189                 ]
190             )
191         else:
192             return html.Div(
193                 id="div-main-error",
194                 children=[
195                     dbc.Alert(
196                         [
197                             "An Error Occured"
198                         ],
199                         color="danger"
200                     )
201                 ]
202             )
203
204     def _add_ctrl_col(self) -> dbc.Col:
205         """Add column with controls. It is placed on the left side.
206
207         :returns: Column with the control panel.
208         :rtype: dbc.Col
209         """
210         return dbc.Col([
211             html.Div(
212                 children=self._add_ctrl_panel(),
213                 className="sticky-top"
214             )
215         ])
216
217     def _add_plotting_col(self) -> dbc.Col:
218         """Add column with plots. It is placed on the right side.
219
220         :returns: Column with plots.
221         :rtype: dbc.Col
222         """
223         return dbc.Col(
224             id="col-plotting-area",
225             children=[
226                 dbc.Spinner(
227                     children=[
228                         dbc.Row(
229                             id="plotting-area",
230                             class_name="g-0 p-0",
231                             children=[
232                                 C.PLACEHOLDER
233                             ]
234                         )
235                     ]
236                 )
237             ],
238             width=9
239         )
240
241     def _add_ctrl_panel(self) -> list:
242         """Add control panel.
243
244         :returns: Control panel.
245         :rtype: list
246         """
247         return [
248             dbc.Row(
249                 class_name="g-0 p-1",
250                 children=[
251                     dbc.InputGroup(
252                         [
253                             dbc.InputGroupText("CSIT Release"),
254                             dbc.Select(
255                                 id={"type": "ctrl-dd", "index": "rls"},
256                                 placeholder="Select a Release...",
257                                 options=sorted(
258                                     [
259                                         {"label": k, "value": k} \
260                                             for k in self._spec_tbs.keys()
261                                     ],
262                                     key=lambda d: d["label"]
263                                 )
264                             )
265                         ],
266                         size="sm"
267                     )
268                 ]
269             ),
270             dbc.Row(
271                 class_name="g-0 p-1",
272                 children=[
273                     dbc.InputGroup(
274                         [
275                             dbc.InputGroupText("DUT"),
276                             dbc.Select(
277                                 id={"type": "ctrl-dd", "index": "dut"},
278                                 placeholder="Select a Device under Test..."
279                             )
280                         ],
281                         size="sm"
282                     )
283                 ]
284             ),
285             dbc.Row(
286                 class_name="g-0 p-1",
287                 children=[
288                     dbc.InputGroup(
289                         [
290                             dbc.InputGroupText("DUT Version"),
291                             dbc.Select(
292                                 id={"type": "ctrl-dd", "index": "dutver"},
293                                 placeholder=\
294                                     "Select a Version of Device under Test..."
295                             )
296                         ],
297                         size="sm"
298                     )
299                 ]
300             ),
301             dbc.Row(
302                 class_name="g-0 p-1",
303                 children=[
304                     dbc.InputGroup(
305                         [
306                             dbc.InputGroupText("Area"),
307                             dbc.Select(
308                                 id={"type": "ctrl-dd", "index": "area"},
309                                 placeholder="Select an Area..."
310                             )
311                         ],
312                         size="sm"
313                     )
314                 ]
315             ),
316             dbc.Row(
317                 class_name="g-0 p-1",
318                 children=[
319                     dbc.InputGroup(
320                         [
321                             dbc.InputGroupText("Infra"),
322                             dbc.Select(
323                                 id={"type": "ctrl-dd", "index": "phy"},
324                                 placeholder=\
325                                     "Select a Physical Test Bed Topology..."
326                             )
327                         ],
328                         size="sm"
329                     )
330                 ]
331             ),
332             dbc.Row(
333                 class_name="g-0 p-1",
334                 children=[
335                     dbc.InputGroup(
336                         [
337                             dbc.InputGroupText("Latency"),
338                             dbc.Checklist(
339                                 id="show-latency",
340                                 options=[{
341                                     "value": "show_latency",
342                                     "label": "Show Latency"
343                                 }],
344                                 value=["show_latency"],
345                                 inline=True,
346                                 class_name="ms-2"
347                             )
348                         ],
349                         style={"align-items": "center"},
350                         size="sm"
351                     )
352                 ]
353             )
354         ]
355
356     def _get_plotting_area(
357             self,
358             selected: dict,
359             url: str,
360             show_latency: bool
361         ) -> list:
362         """Generate the plotting area with all its content.
363
364         :param selected: Selected parameters of tests.
365         :param url: URL to be displayed in the modal window.
366         :param show_latency: If True, latency is displayed in the tables.
367         :type selected: dict
368         :type url: str
369         :type show_latency: bool
370         :returns: List of rows with elements to be displayed in the plotting
371             area.
372         :rtype: list
373         """
374         if not selected:
375             return C.PLACEHOLDER
376
377         return [
378             dbc.Row(
379                 children=coverage_tables(self._data, selected, show_latency),
380                 class_name="g-0 p-0",
381             ),
382             dbc.Row(
383                 children=C.PLACEHOLDER,
384                 class_name="g-0 p-1"
385             ),
386             dbc.Row(
387                 [
388                     dbc.Col([html.Div(
389                         [
390                             dbc.Button(
391                                 id="plot-btn-url",
392                                 children="Show URL",
393                                 class_name="me-1",
394                                 color="info",
395                                 style={
396                                     "text-transform": "none",
397                                     "padding": "0rem 1rem"
398                                 }
399                             ),
400                             dbc.Modal(
401                                 [
402                                     dbc.ModalHeader(dbc.ModalTitle("URL")),
403                                     dbc.ModalBody(url)
404                                 ],
405                                 id="plot-mod-url",
406                                 size="xl",
407                                 is_open=False,
408                                 scrollable=True
409                             ),
410                             dbc.Button(
411                                 id="plot-btn-download",
412                                 children="Download Data",
413                                 class_name="me-1",
414                                 color="info",
415                                 style={
416                                     "text-transform": "none",
417                                     "padding": "0rem 1rem"
418                                 }
419                             ),
420                             dcc.Download(id="download-iterative-data")
421                         ],
422                         className=\
423                             "d-grid gap-0 d-md-flex justify-content-md-end"
424                     )])
425                 ],
426                 class_name="g-0 p-0"
427             ),
428             dbc.Row(
429                 children=C.PLACEHOLDER,
430                 class_name="g-0 p-1"
431             )
432         ]
433
434     def callbacks(self, app):
435         """Callbacks for the whole application.
436
437         :param app: The application.
438         :type app: Flask
439         """
440
441         @app.callback(
442             [
443                 Output("store-control-panel", "data"),
444                 Output("store-selected-tests", "data"),
445                 Output("plotting-area", "children"),
446                 Output({"type": "ctrl-dd", "index": "rls"}, "value"),
447                 Output({"type": "ctrl-dd", "index": "dut"}, "options"),
448                 Output({"type": "ctrl-dd", "index": "dut"}, "disabled"),
449                 Output({"type": "ctrl-dd", "index": "dut"}, "value"),
450                 Output({"type": "ctrl-dd", "index": "dutver"}, "options"),
451                 Output({"type": "ctrl-dd", "index": "dutver"}, "disabled"),
452                 Output({"type": "ctrl-dd", "index": "dutver"}, "value"),
453                 Output({"type": "ctrl-dd", "index": "phy"}, "options"),
454                 Output({"type": "ctrl-dd", "index": "phy"}, "disabled"),
455                 Output({"type": "ctrl-dd", "index": "phy"}, "value"),
456                 Output({"type": "ctrl-dd", "index": "area"}, "options"),
457                 Output({"type": "ctrl-dd", "index": "area"}, "disabled"),
458                 Output({"type": "ctrl-dd", "index": "area"}, "value"),
459                 Output("show-latency", "value"),
460             ],
461             [
462                 State("store-control-panel", "data"),
463                 State("store-selected-tests", "data")
464             ],
465             [
466                 Input("url", "href"),
467                 Input("show-latency", "value"),
468                 Input({"type": "ctrl-dd", "index": ALL}, "value")
469             ]
470         )
471         def _update_application(
472                 control_panel: dict,
473                 selected: dict,
474                 href: str,
475                 show_latency: list,
476                 *_
477             ) -> tuple:
478             """Update the application when the event is detected.
479             """
480
481             ctrl_panel = ControlPanel(CP_PARAMS, control_panel)
482             plotting_area = no_update
483             on_draw = False
484             if selected is None:
485                 selected = dict()
486
487             # Parse the url:
488             parsed_url = url_decode(href)
489             if parsed_url:
490                 url_params = parsed_url["params"]
491             else:
492                 url_params = None
493
494             trigger = Trigger(callback_context.triggered)
495
496             if trigger.type == "url" and url_params:
497                 try:
498                     show_latency = literal_eval(url_params["show_latency"][0])
499                     selected = literal_eval(url_params["selection"][0])
500                 except (KeyError, IndexError, AttributeError):
501                     pass
502                 if selected:
503                     ctrl_panel.set({
504                         "rls-val": selected["rls"],
505                         "dut-val": selected["dut"],
506                         "dut-opt": generate_options(
507                             self._spec_tbs[selected["rls"]].keys()
508                         ),
509                         "dut-dis": False,
510                         "dutver-val": selected["dutver"],
511                         "dutver-opt": generate_options(
512                             self._spec_tbs[selected["rls"]]\
513                                 [selected["dut"]].keys()
514                         ),
515                         "dutver-dis": False,
516                         "area-val": selected["area"],
517                         "area-opt": [
518                             {"label": label(v), "value": v} \
519                                 for v in sorted(self._spec_tbs[selected["rls"]]\
520                                     [selected["dut"]]\
521                                         [selected["dutver"]].keys())
522                         ],
523                         "area-dis": False,
524                         "phy-val": selected["phy"],
525                         "phy-opt": generate_options(
526                             self._spec_tbs[selected["rls"]][selected["dut"]]\
527                                 [selected["dutver"]][selected["area"]]
528                         ),
529                         "phy-dis": False,
530                         "show-latency": show_latency
531                     })
532                     on_draw = True
533             elif trigger.type == "show-latency":
534                 ctrl_panel.set({"show-latency": show_latency})
535                 on_draw = True
536             elif trigger.type == "ctrl-dd":
537                 if trigger.idx == "rls":
538                     try:
539                         options = generate_options(
540                             self._spec_tbs[trigger.value].keys()
541                         )
542                         disabled = False
543                     except KeyError:
544                         options = list()
545                         disabled = True
546                     ctrl_panel.set({
547                         "rls-val": trigger.value,
548                         "dut-val": str(),
549                         "dut-opt": options,
550                         "dut-dis": disabled,
551                         "dutver-val": str(),
552                         "dutver-opt": list(),
553                         "dutver-dis": True,
554                         "phy-val": str(),
555                         "phy-opt": list(),
556                         "phy-dis": True,
557                         "area-val": str(),
558                         "area-opt": list(),
559                         "area-dis": True
560                     })
561                 elif trigger.idx == "dut":
562                     try:
563                         rls = ctrl_panel.get("rls-val")
564                         dut = self._spec_tbs[rls][trigger.value]
565                         options = generate_options(dut.keys())
566                         disabled = False
567                     except KeyError:
568                         options = list()
569                         disabled = True
570                     ctrl_panel.set({
571                         "dut-val": trigger.value,
572                         "dutver-val": str(),
573                         "dutver-opt": options,
574                         "dutver-dis": disabled,
575                         "phy-val": str(),
576                         "phy-opt": list(),
577                         "phy-dis": True,
578                         "area-val": str(),
579                         "area-opt": list(),
580                         "area-dis": True
581                     })
582                 elif trigger.idx == "dutver":
583                     try:
584                         rls = ctrl_panel.get("rls-val")
585                         dut = ctrl_panel.get("dut-val")
586                         ver = self._spec_tbs[rls][dut][trigger.value]
587                         options = [
588                             {"label": label(v), "value": v} for v in sorted(ver)
589                         ]
590                         disabled = False
591                     except KeyError:
592                         options = list()
593                         disabled = True
594                     ctrl_panel.set({
595                         "dutver-val": trigger.value,
596                         "area-val": str(),
597                         "area-opt": options,
598                         "area-dis": disabled,
599                         "phy-val": str(),
600                         "phy-opt": list(),
601                         "phy-dis": True
602                     })
603                 elif trigger.idx == "area":
604                     try:
605                         rls = ctrl_panel.get("rls-val")
606                         dut = ctrl_panel.get("dut-val")
607                         ver = ctrl_panel.get("dutver-val")
608                         options = generate_options(
609                             self._spec_tbs[rls][dut][ver][trigger.value])
610                         disabled = False
611                     except KeyError:
612                         options = list()
613                         disabled = True
614                     ctrl_panel.set({
615                         "area-val": trigger.value,
616                         "phy-val": str(),
617                         "phy-opt": options,
618                         "phy-dis": disabled
619                     })
620                 elif trigger.idx == "phy":
621                     ctrl_panel.set({"phy-val": trigger.value})
622                     selected = {
623                         "rls": ctrl_panel.get("rls-val"),
624                         "dut": ctrl_panel.get("dut-val"),
625                         "dutver": ctrl_panel.get("dutver-val"),
626                         "phy": ctrl_panel.get("phy-val"),
627                         "area": ctrl_panel.get("area-val"),
628                     }
629                     on_draw = True
630
631             if on_draw:
632                 if selected:
633                     plotting_area = self._get_plotting_area(
634                         selected,
635                         gen_new_url(
636                             parsed_url,
637                             {
638                                 "selection": selected,
639                                 "show_latency": show_latency
640                             }
641                         ),
642                         show_latency=bool(show_latency)
643                     )
644                 else:
645                     plotting_area = C.PLACEHOLDER
646                     selected = dict()
647
648             ret_val = [
649                 ctrl_panel.panel,
650                 selected,
651                 plotting_area,
652             ]
653             ret_val.extend(ctrl_panel.values)
654             return ret_val
655
656         @app.callback(
657             Output("plot-mod-url", "is_open"),
658             [Input("plot-btn-url", "n_clicks")],
659             [State("plot-mod-url", "is_open")],
660         )
661         def toggle_plot_mod_url(n, is_open):
662             """Toggle the modal window with url.
663             """
664             if n:
665                 return not is_open
666             return is_open
667
668         @app.callback(
669             Output("download-iterative-data", "data"),
670             State("store-selected-tests", "data"),
671             State("show-latency", "value"),
672             Input("plot-btn-download", "n_clicks"),
673             prevent_initial_call=True
674         )
675         def _download_coverage_data(selection, show_latency, _):
676             """Download the data
677
678             :param selection: List of tests selected by user stored in the
679                 browser.
680             :param show_latency: If True, latency is displayed in the tables.
681             :type selection: dict
682             :type show_latency: bool
683             :returns: dict of data frame content (base64 encoded) and meta data
684                 used by the Download component.
685             :rtype: dict
686             """
687
688             if not selection:
689                 raise PreventUpdate
690
691             df = select_coverage_data(
692                 self._data,
693                 selection,
694                 csv=True,
695                 show_latency=bool(show_latency)
696             )
697
698             return dcc.send_data_frame(df.to_csv, C.COVERAGE_DOWNLOAD_FILE_NAME)
699
700         @app.callback(
701             Output("offcanvas-documentation", "is_open"),
702             Input("btn-documentation", "n_clicks"),
703             State("offcanvas-documentation", "is_open")
704         )
705         def toggle_offcanvas_documentation(n_clicks, is_open):
706             if n_clicks:
707                 return not is_open
708             return is_open