C-Dash: Add search in tests
[csit.git] / csit.infra.dash / app / cdash / utils / utils.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 """Functions used by Dash applications.
15 """
16
17 import pandas as pd
18 import plotly.graph_objects as go
19 import dash_bootstrap_components as dbc
20
21 import hdrh.histogram
22 import hdrh.codec
23
24 from math import sqrt
25 from dash import dcc, no_update, html
26 from datetime import datetime
27
28 from ..utils.constants import Constants as C
29 from ..utils.url_processing import url_encode
30 from ..utils.trigger import Trigger
31
32
33 def get_color(idx: int) -> str:
34     """Returns a color from the list defined in Constants.PLOT_COLORS defined by
35     its index.
36
37     :param idx: Index of the color.
38     :type idx: int
39     :returns: Color defined by hex code.
40     :trype: str
41     """
42     return C.PLOT_COLORS[idx % len(C.PLOT_COLORS)]
43
44
45 def show_tooltip(tooltips:dict, id: str, title: str,
46         clipboard_id: str=None) -> list:
47     """Generate list of elements to display a text (e.g. a title) with a
48     tooltip and optionaly with Copy&Paste icon and the clipboard
49     functionality enabled.
50
51     :param tooltips: Dictionary with tooltips.
52     :param id: Tooltip ID.
53     :param title: A text for which the tooltip will be displayed.
54     :param clipboard_id: If defined, a Copy&Paste icon is displayed and the
55         clipboard functionality is enabled.
56     :type tooltips: dict
57     :type id: str
58     :type title: str
59     :type clipboard_id: str
60     :returns: List of elements to display a text with a tooltip and
61         optionaly with Copy&Paste icon.
62     :rtype: list
63     """
64
65     return [
66         dcc.Clipboard(target_id=clipboard_id, title="Copy URL") \
67             if clipboard_id else str(),
68         f"{title} ",
69         dbc.Badge(
70             id=id,
71             children="?",
72             pill=True,
73             color="white",
74             text_color="info",
75             class_name="border ms-1",
76         ),
77         dbc.Tooltip(
78             children=tooltips.get(id, str()),
79             target=id,
80             placement="auto"
81         )
82     ]
83
84
85 def label(key: str) -> str:
86     """Returns a label for input elements (dropdowns, ...).
87
88     If the label is not defined, the function returns the provided key.
89
90     :param key: The key to the label defined in Constants.LABELS.
91     :type key: str
92     :returns: Label.
93     :rtype: str
94     """
95     return C.LABELS.get(key, key)
96
97
98 def sync_checklists(options: list, sel: list, all: list, id: str) -> tuple:
99     """Synchronize a checklist with defined "options" with its "All" checklist.
100
101     :param options: List of options for the cheklist.
102     :param sel: List of selected options.
103     :param all: List of selected option from "All" checklist.
104     :param id: ID of a checklist to be used for synchronization.
105     :returns: Tuple of lists with otions for both checklists.
106     :rtype: tuple of lists
107     """
108     opts = {v["value"] for v in options}
109     if id =="all":
110         sel = list(opts) if all else list()
111     else:
112         all = ["all", ] if set(sel) == opts else list()
113     return sel, all
114
115
116 def list_tests(selection: dict) -> list:
117     """Transform list of tests to a list of dictionaries usable by checkboxes.
118
119     :param selection: List of tests to be displayed in "Selected tests" window.
120     :type selection: list
121     :returns: List of dictionaries with "label", "value" pairs for a checkbox.
122     :rtype: list
123     """
124     if selection:
125         return [{"label": v["id"], "value": v["id"]} for v in selection]
126     else:
127         return list()
128
129
130 def get_date(s_date: str) -> datetime:
131     """Transform string reprezentation of date to datetime.datetime data type.
132
133     :param s_date: String reprezentation of date.
134     :type s_date: str
135     :returns: Date as datetime.datetime.
136     :rtype: datetime.datetime
137     """
138     return datetime(int(s_date[0:4]), int(s_date[5:7]), int(s_date[8:10]))
139
140
141 def gen_new_url(url_components: dict, params: dict) -> str:
142     """Generate a new URL with encoded parameters.
143
144     :param url_components: Dictionary with URL elements. It should contain
145         "scheme", "netloc" and "path".
146     :param url_components: URL parameters to be encoded to the URL.
147     :type parsed_url: dict
148     :type params: dict
149     :returns Encoded URL with parameters.
150     :rtype: str
151     """
152
153     if url_components:
154         return url_encode(
155             {
156                 "scheme": url_components.get("scheme", ""),
157                 "netloc": url_components.get("netloc", ""),
158                 "path": url_components.get("path", ""),
159                 "params": params
160             }
161         )
162     else:
163         return str()
164
165
166 def get_duts(df: pd.DataFrame) -> list:
167     """Get the list of DUTs from the pre-processed information about jobs.
168
169     :param df: DataFrame with information about jobs.
170     :type df: pandas.DataFrame
171     :returns: Alphabeticaly sorted list of DUTs.
172     :rtype: list
173     """
174     return sorted(list(df["dut"].unique()))
175
176
177 def get_ttypes(df: pd.DataFrame, dut: str) -> list:
178     """Get the list of test types from the pre-processed information about
179     jobs.
180
181     :param df: DataFrame with information about jobs.
182     :param dut: The DUT for which the list of test types will be populated.
183     :type df: pandas.DataFrame
184     :type dut: str
185     :returns: Alphabeticaly sorted list of test types.
186     :rtype: list
187     """
188     return sorted(list(df.loc[(df["dut"] == dut)]["ttype"].unique()))
189
190
191 def get_cadences(df: pd.DataFrame, dut: str, ttype: str) -> list:
192     """Get the list of cadences from the pre-processed information about
193     jobs.
194
195     :param df: DataFrame with information about jobs.
196     :param dut: The DUT for which the list of cadences will be populated.
197     :param ttype: The test type for which the list of cadences will be
198         populated.
199     :type df: pandas.DataFrame
200     :type dut: str
201     :type ttype: str
202     :returns: Alphabeticaly sorted list of cadences.
203     :rtype: list
204     """
205     return sorted(list(df.loc[(
206         (df["dut"] == dut) &
207         (df["ttype"] == ttype)
208     )]["cadence"].unique()))
209
210
211 def get_test_beds(df: pd.DataFrame, dut: str, ttype: str, cadence: str) -> list:
212     """Get the list of test beds from the pre-processed information about
213     jobs.
214
215     :param df: DataFrame with information about jobs.
216     :param dut: The DUT for which the list of test beds will be populated.
217     :param ttype: The test type for which the list of test beds will be
218         populated.
219     :param cadence: The cadence for which the list of test beds will be
220         populated.
221     :type df: pandas.DataFrame
222     :type dut: str
223     :type ttype: str
224     :type cadence: str
225     :returns: Alphabeticaly sorted list of test beds.
226     :rtype: list
227     """
228     return sorted(list(df.loc[(
229         (df["dut"] == dut) &
230         (df["ttype"] == ttype) &
231         (df["cadence"] == cadence)
232     )]["tbed"].unique()))
233
234
235 def get_job(df: pd.DataFrame, dut, ttype, cadence, testbed):
236     """Get the name of a job defined by dut, ttype, cadence, test bed.
237     Input information comes from the control panel.
238
239     :param df: DataFrame with information about jobs.
240     :param dut: The DUT for which the job name will be created.
241     :param ttype: The test type for which the job name will be created.
242     :param cadence: The cadence for which the job name will be created.
243     :param testbed: The test bed for which the job name will be created.
244     :type df: pandas.DataFrame
245     :type dut: str
246     :type ttype: str
247     :type cadence: str
248     :type testbed: str
249     :returns: Job name.
250     :rtype: str
251     """
252     return df.loc[(
253         (df["dut"] == dut) &
254         (df["ttype"] == ttype) &
255         (df["cadence"] == cadence) &
256         (df["tbed"] == testbed)
257     )]["job"].item()
258
259
260 def generate_options(opts: list, sort: bool=True) -> list:
261     """Return list of options for radio items in control panel. The items in
262     the list are dictionaries with keys "label" and "value".
263
264     :params opts: List of options (str) to be used for the generated list.
265     :type opts: list
266     :returns: List of options (dict).
267     :rtype: list
268     """
269     if sort:
270         opts = sorted(opts)
271     return [{"label": i, "value": i} for i in opts]
272
273
274 def set_job_params(df: pd.DataFrame, job: str) -> dict:
275     """Create a dictionary with all options and values for (and from) the
276     given job.
277
278     :param df: DataFrame with information about jobs.
279     :params job: The name of job for and from which the dictionary will be
280         created.
281     :type df: pandas.DataFrame
282     :type job: str
283     :returns: Dictionary with all options and values for (and from) the
284         given job.
285     :rtype: dict
286     """
287
288     l_job = job.split("-")
289     return {
290         "job": job,
291         "dut": l_job[1],
292         "ttype": l_job[3],
293         "cadence": l_job[4],
294         "tbed": "-".join(l_job[-2:]),
295         "duts": generate_options(get_duts(df)),
296         "ttypes": generate_options(get_ttypes(df, l_job[1])),
297         "cadences": generate_options(get_cadences(df, l_job[1], l_job[3])),
298         "tbeds": generate_options(
299             get_test_beds(df, l_job[1], l_job[3], l_job[4]))
300     }
301
302
303 def get_list_group_items(
304         items: list,
305         type: str,
306         colorize: bool=True,
307         add_index: bool=False
308     ) -> list:
309     """Generate list of ListGroupItems with checkboxes with selected items.
310
311     :param items: List of items to be displayed in the ListGroup.
312     :param type: The type part of an element ID.
313     :param colorize: If True, the color of labels is set, otherwise the default
314         color is used.
315     :param add_index: Add index to the list items.
316     :type items: list
317     :type type: str
318     :type colorize: bool
319     :type add_index: bool
320     :returns: List of ListGroupItems with checkboxes with selected items.
321     :rtype: list
322     """
323
324     children = list()
325     for i, l in enumerate(items):
326         idx = f"{i + 1}. " if add_index else str()
327         label = f"{idx}{l['id']}" if isinstance(l, dict) else f"{idx}{l}"
328         children.append(
329             dbc.ListGroupItem(
330                 children=[
331                     dbc.Checkbox(
332                         id={"type": type, "index": i},
333                         label=label,
334                         value=False,
335                         label_class_name="m-0 p-0",
336                         label_style={
337                             "font-size": ".875em",
338                             "color": get_color(i) if colorize else "#55595c"
339                         },
340                         class_name="info"
341                     )
342                 ],
343                 class_name="p-0"
344             )
345         )
346
347     return children
348
349
350 def relative_change_stdev(mean1, mean2, std1, std2):
351     """Compute relative standard deviation of change of two values.
352
353     The "1" values are the base for comparison.
354     Results are returned as percentage (and percentual points for stdev).
355     Linearized theory is used, so results are wrong for relatively large stdev.
356
357     :param mean1: Mean of the first number.
358     :param mean2: Mean of the second number.
359     :param std1: Standard deviation estimate of the first number.
360     :param std2: Standard deviation estimate of the second number.
361     :type mean1: float
362     :type mean2: float
363     :type std1: float
364     :type std2: float
365     :returns: Relative change and its stdev.
366     :rtype: float
367     """
368     mean1, mean2 = float(mean1), float(mean2)
369     quotient = mean2 / mean1
370     first = std1 / mean1
371     second = std2 / mean2
372     std = quotient * sqrt(first * first + second * second)
373     return (quotient - 1) * 100, std * 100
374
375
376 def get_hdrh_latencies(row: pd.Series, name: str) -> dict:
377     """Get the HDRH latencies from the test data.
378
379     :param row: A row fron the data frame with test data.
380     :param name: The test name to be displayed as the graph title.
381     :type row: pandas.Series
382     :type name: str
383     :returns: Dictionary with HDRH latencies.
384     :rtype: dict
385     """
386
387     latencies = {"name": name}
388     for key in C.LAT_HDRH:
389         try:
390             latencies[key] = row[key]
391         except KeyError:
392             return None
393
394     return latencies
395
396
397 def graph_hdrh_latency(data: dict, layout: dict) -> go.Figure:
398     """Generate HDR Latency histogram graphs.
399
400     :param data: HDRH data.
401     :param layout: Layout of plot.ly graph.
402     :type data: dict
403     :type layout: dict
404     :returns: HDR latency Histogram.
405     :rtype: plotly.graph_objects.Figure
406     """
407
408     fig = None
409
410     traces = list()
411     for idx, (lat_name, lat_hdrh) in enumerate(data.items()):
412         try:
413             decoded = hdrh.histogram.HdrHistogram.decode(lat_hdrh)
414         except (hdrh.codec.HdrLengthException, TypeError):
415             continue
416         previous_x = 0.0
417         prev_perc = 0.0
418         xaxis = list()
419         yaxis = list()
420         hovertext = list()
421         for item in decoded.get_recorded_iterator():
422             # The real value is "percentile".
423             # For 100%, we cut that down to "x_perc" to avoid
424             # infinity.
425             percentile = item.percentile_level_iterated_to
426             x_perc = min(percentile, C.PERCENTILE_MAX)
427             xaxis.append(previous_x)
428             yaxis.append(item.value_iterated_to)
429             hovertext.append(
430                 f"<b>{C.GRAPH_LAT_HDRH_DESC[lat_name]}</b><br>"
431                 f"Direction: {('W-E', 'E-W')[idx % 2]}<br>"
432                 f"Percentile: {prev_perc:.5f}-{percentile:.5f}%<br>"
433                 f"Latency: {item.value_iterated_to}uSec"
434             )
435             next_x = 100.0 / (100.0 - x_perc)
436             xaxis.append(next_x)
437             yaxis.append(item.value_iterated_to)
438             hovertext.append(
439                 f"<b>{C.GRAPH_LAT_HDRH_DESC[lat_name]}</b><br>"
440                 f"Direction: {('W-E', 'E-W')[idx % 2]}<br>"
441                 f"Percentile: {prev_perc:.5f}-{percentile:.5f}%<br>"
442                 f"Latency: {item.value_iterated_to}uSec"
443             )
444             previous_x = next_x
445             prev_perc = percentile
446
447         traces.append(
448             go.Scatter(
449                 x=xaxis,
450                 y=yaxis,
451                 name=C.GRAPH_LAT_HDRH_DESC[lat_name],
452                 mode="lines",
453                 legendgroup=C.GRAPH_LAT_HDRH_DESC[lat_name],
454                 showlegend=bool(idx % 2),
455                 line=dict(
456                     color=get_color(int(idx/2)),
457                     dash="solid",
458                     width=1 if idx % 2 else 2
459                 ),
460                 hovertext=hovertext,
461                 hoverinfo="text"
462             )
463         )
464     if traces:
465         fig = go.Figure()
466         fig.add_traces(traces)
467         layout_hdrh = layout.get("plot-hdrh-latency", None)
468         if lat_hdrh:
469             fig.update_layout(layout_hdrh)
470
471     return fig
472
473
474 def navbar_trending(active: tuple):
475     """Add nav element with navigation panel. It is placed on the top.
476
477     :param active: Tuple of boolean values defining the active items in the
478         navbar. True == active
479     :type active: tuple
480     :returns: Navigation bar.
481     :rtype: dbc.NavbarSimple
482     """
483     return dbc.NavbarSimple(
484         children=[
485             dbc.NavItem(dbc.NavLink(
486                 C.TREND_TITLE,
487                 active=active[0],
488                 external_link=True,
489                 href="/trending"
490             )),
491             dbc.NavItem(dbc.NavLink(
492                 C.NEWS_TITLE,
493                 active=active[1],
494                 external_link=True,
495                 href="/news"
496             )),
497             dbc.NavItem(dbc.NavLink(
498                 C.STATS_TITLE,
499                 active=active[2],
500                 external_link=True,
501                 href="/stats"
502             )),
503             dbc.NavItem(dbc.NavLink(
504                 C.SEARCH_TITLE,
505                 active=active[3],
506                 external_link=True,
507                 href="/search"
508             )),
509             dbc.NavItem(dbc.NavLink(
510                 "Documentation",
511                 id="btn-documentation",
512             ))
513         ],
514         id="navbarsimple-main",
515         brand=C.BRAND,
516         brand_href="/",
517         brand_external_link=True,
518         class_name="p-2",
519         fluid=True
520     )
521
522
523 def navbar_report(active: tuple):
524     """Add nav element with navigation panel. It is placed on the top.
525
526     :param active: Tuple of boolean values defining the active items in the
527         navbar. True == active
528     :type active: tuple
529     :returns: Navigation bar.
530     :rtype: dbc.NavbarSimple
531     """
532     return dbc.NavbarSimple(
533         id="navbarsimple-main",
534         children=[
535             dbc.NavItem(dbc.NavLink(
536                 C.REPORT_TITLE,
537                 active=active[0],
538                 external_link=True,
539                 href="/report"
540             )),
541             dbc.NavItem(dbc.NavLink(
542                 "Comparisons",
543                 active=active[1],
544                 external_link=True,
545                 href="/comparisons"
546             )),
547             dbc.NavItem(dbc.NavLink(
548                 "Coverage Data",
549                 active=active[2],
550                 external_link=True,
551                 href="/coverage"
552             )),
553             dbc.NavItem(dbc.NavLink(
554                 C.SEARCH_TITLE,
555                 active=active[3],
556                 external_link=True,
557                 href="/search"
558             )),
559             dbc.NavItem(dbc.NavLink(
560                 "Documentation",
561                 id="btn-documentation",
562             ))
563         ],
564         brand=C.BRAND,
565         brand_href="/",
566         brand_external_link=True,
567         class_name="p-2",
568         fluid=True
569     )
570
571
572 def filter_table_data(
573         store_table_data: list,
574         table_filter: str
575     ) -> list:
576     """Filter table data using user specified filter.
577
578     :param store_table_data: Table data represented as a list of records.
579     :param table_filter: User specified filter.
580     :type store_table_data: list
581     :type table_filter: str
582     :returns: A new table created by filtering of table data represented as
583         a list of records.
584     :rtype: list
585     """
586
587     # Checks:
588     if not any((table_filter, store_table_data, )):
589         return store_table_data
590
591     def _split_filter_part(filter_part: str) -> tuple:
592         """Split a part of filter into column name, operator and value.
593         A "part of filter" is a sting berween "&&" operator.
594
595         :param filter_part: A part of filter.
596         :type filter_part: str
597         :returns: Column name, operator, value
598         :rtype: tuple[str, str, str|float]
599         """
600         for operator_type in C.OPERATORS:
601             for operator in operator_type:
602                 if operator in filter_part:
603                     name_p, val_p = filter_part.split(operator, 1)
604                     name = name_p[name_p.find("{") + 1 : name_p.rfind("}")]
605                     val_p = val_p.strip()
606                     if (val_p[0] == val_p[-1] and val_p[0] in ("'", '"', '`')):
607                         value = val_p[1:-1].replace("\\" + val_p[0], val_p[0])
608                     else:
609                         try:
610                             value = float(val_p)
611                         except ValueError:
612                             value = val_p
613
614                     return name, operator_type[0].strip(), value
615         return (None, None, None)
616
617     df = pd.DataFrame.from_records(store_table_data)
618     for filter_part in table_filter.split(" && "):
619         col_name, operator, filter_value = _split_filter_part(filter_part)
620         if operator == "contains":
621             df = df.loc[df[col_name].str.contains(filter_value, regex=True)]
622         elif operator in ("eq", "ne", "lt", "le", "gt", "ge"):
623             # These operators match pandas series operator method names.
624             df = df.loc[getattr(df[col_name], operator)(filter_value)]
625         elif operator == "datestartswith":
626             # This is a simplification of the front-end filtering logic,
627             # only works with complete fields in standard format.
628             # Currently not used in comparison tables.
629             df = df.loc[df[col_name].str.startswith(filter_value)]
630
631     return df.to_dict("records")
632
633
634 def show_trending_graph_data(
635         trigger: Trigger,
636         data: dict,
637         graph_layout: dict
638     ) -> tuple:
639     """Generates the data for the offcanvas displayed when a particular point in
640     a trending graph (daily data) is clicked on.
641
642     :param trigger: The information from trigger when the data point is clicked
643         on.
644     :param graph: The data from the clicked point in the graph.
645     :param graph_layout: The layout of the HDRH latency graph.
646     :type trigger: Trigger
647     :type graph: dict
648     :type graph_layout: dict
649     :returns: The data to be displayed on the offcanvas and the information to
650         show the offcanvas.
651     :rtype: tuple(list, list, bool)
652     """
653
654     if trigger.idx == "tput":
655         idx = 0
656     elif trigger.idx == "bandwidth":
657         idx = 1
658     elif trigger.idx == "lat":
659         idx = len(data) - 1
660     else:
661         return list(), list(), False
662     try:
663         data = data[idx]["points"][0]
664     except (IndexError, KeyError, ValueError, TypeError):
665         return list(), list(), False
666
667     metadata = no_update
668     graph = list()
669
670     list_group_items = list()
671     for itm in data.get("text", None).split("<br>"):
672         if not itm:
673             continue
674         lst_itm = itm.split(": ")
675         if lst_itm[0] == "csit-ref":
676             list_group_item = dbc.ListGroupItem([
677                 dbc.Badge(lst_itm[0]),
678                 html.A(
679                     lst_itm[1],
680                     href=f"{C.URL_JENKINS}{lst_itm[1]}",
681                     target="_blank"
682                 )
683             ])
684         else:
685             list_group_item = dbc.ListGroupItem([
686                 dbc.Badge(lst_itm[0]),
687                 lst_itm[1]
688             ])
689         list_group_items.append(list_group_item)
690
691     if trigger.idx == "tput":
692         title = "Throughput"
693     elif trigger.idx == "bandwidth":
694         title = "Bandwidth"
695     elif trigger.idx == "lat":
696         title = "Latency"
697         hdrh_data = data.get("customdata", None)
698         if hdrh_data:
699             graph = [dbc.Card(
700                 class_name="gy-2 p-0",
701                 children=[
702                     dbc.CardHeader(hdrh_data.pop("name")),
703                     dbc.CardBody(
704                         dcc.Graph(
705                             id="hdrh-latency-graph",
706                             figure=graph_hdrh_latency(hdrh_data, graph_layout)
707                         )
708                     )
709                 ])
710             ]
711
712     metadata = [
713         dbc.Card(
714             class_name="gy-2 p-0",
715             children=[
716                 dbc.CardHeader(children=[
717                     dcc.Clipboard(
718                         target_id="tput-lat-metadata",
719                         title="Copy",
720                         style={"display": "inline-block"}
721                     ),
722                     title
723                 ]),
724                 dbc.CardBody(
725                     dbc.ListGroup(list_group_items, flush=True),
726                     id="tput-lat-metadata",
727                     class_name="p-0",
728                 )
729             ]
730         )
731     ]
732
733     return metadata, graph, True
734
735
736 def show_iterative_graph_data(
737         trigger: Trigger,
738         data: dict,
739         graph_layout: dict
740     ) -> tuple:
741     """Generates the data for the offcanvas displayed when a particular point in
742     a box graph (iterative data) is clicked on.
743
744     :param trigger: The information from trigger when the data point is clicked
745         on.
746     :param graph: The data from the clicked point in the graph.
747     :param graph_layout: The layout of the HDRH latency graph.
748     :type trigger: Trigger
749     :type graph: dict
750     :type graph_layout: dict
751     :returns: The data to be displayed on the offcanvas and the information to
752         show the offcanvas.
753     :rtype: tuple(list, list, bool)
754     """
755
756     if trigger.idx == "tput":
757         idx = 0
758     elif trigger.idx == "bandwidth":
759         idx = 1
760     elif trigger.idx == "lat":
761         idx = len(data) - 1
762     else:
763         return list(), list(), False
764
765     try:
766         data = data[idx]["points"]
767     except (IndexError, KeyError, ValueError, TypeError):
768         return list(), list(), False
769
770     def _process_stats(data: list, param: str) -> list:
771         """Process statistical data provided by plot.ly box graph.
772
773         :param data: Statistical data provided by plot.ly box graph.
774         :param param: Parameter saying if the data come from "tput" or
775             "lat" graph.
776         :type data: list
777         :type param: str
778         :returns: Listo of tuples where the first value is the
779             statistic's name and the secont one it's value.
780         :rtype: list
781         """
782         if len(data) == 7:
783             stats = ("max", "upper fence", "q3", "median", "q1",
784                     "lower fence", "min")
785         elif len(data) == 9:
786             stats = ("outlier", "max", "upper fence", "q3", "median",
787                     "q1", "lower fence", "min", "outlier")
788         elif len(data) == 1:
789             if param == "lat":
790                 stats = ("average latency at 50% PDR", )
791             elif param == "bandwidth":
792                 stats = ("bandwidth", )
793             else:
794                 stats = ("throughput", )
795         else:
796             return list()
797         unit = " [us]" if param == "lat" else str()
798         return [(f"{stat}{unit}", f"{value['y']:,.0f}")
799                 for stat, value in zip(stats, data)]
800
801     customdata = data[0].get("customdata", dict())
802     datapoint = customdata.get("metadata", dict())
803     hdrh_data = customdata.get("hdrh", dict())
804
805     list_group_items = list()
806     for k, v in datapoint.items():
807         if k == "csit-ref":
808             if len(data) > 1:
809                 continue
810             list_group_item = dbc.ListGroupItem([
811                 dbc.Badge(k),
812                 html.A(v, href=f"{C.URL_JENKINS}{v}", target="_blank")
813             ])
814         else:
815             list_group_item = dbc.ListGroupItem([dbc.Badge(k), v])
816         list_group_items.append(list_group_item)
817
818     graph = list()
819     if trigger.idx == "tput":
820         title = "Throughput"
821     elif trigger.idx == "bandwidth":
822         title = "Bandwidth"
823     elif trigger.idx == "lat":
824         title = "Latency"
825         if len(data) == 1:
826             if hdrh_data:
827                 graph = [dbc.Card(
828                     class_name="gy-2 p-0",
829                     children=[
830                         dbc.CardHeader(hdrh_data.pop("name")),
831                         dbc.CardBody(dcc.Graph(
832                             id="hdrh-latency-graph",
833                             figure=graph_hdrh_latency(hdrh_data, graph_layout)
834                         ))
835                     ])
836                 ]
837
838     for k, v in _process_stats(data, trigger.idx):
839         list_group_items.append(dbc.ListGroupItem([dbc.Badge(k), v]))
840
841     metadata = [
842         dbc.Card(
843             class_name="gy-2 p-0",
844             children=[
845                 dbc.CardHeader(children=[
846                     dcc.Clipboard(
847                         target_id="tput-lat-metadata",
848                         title="Copy",
849                         style={"display": "inline-block"}
850                     ),
851                     title
852                 ]),
853                 dbc.CardBody(
854                     dbc.ListGroup(list_group_items, flush=True),
855                     id="tput-lat-metadata",
856                     class_name="p-0"
857                 )
858             ]
859         )
860     ]
861
862     return metadata, graph, True