1# Copyright (c) 2017 The WebRTC project authors. All Rights Reserved.
2#
3# Use of this source code is governed by a BSD-style license
4# that can be found in the LICENSE file in the root of the source
5# tree. An additional intellectual property rights grant can be found
6# in the file PATENTS.  All contributing project authors may
7# be found in the AUTHORS file in the root of the source tree.
8
9import functools
10import hashlib
11import logging
12import os
13import re
14import sys
15
16try:
17  import csscompressor
18except ImportError:
19  logging.critical('Cannot import the third-party Python package csscompressor')
20  sys.exit(1)
21
22try:
23  import jsmin
24except ImportError:
25  logging.critical('Cannot import the third-party Python package jsmin')
26  sys.exit(1)
27
28
29class HtmlExport(object):
30  """HTML exporter class for APM quality scores."""
31
32  _NEW_LINE = '\n'
33
34  # CSS and JS file paths.
35  _PATH = os.path.dirname(os.path.realpath(__file__))
36  _CSS_FILEPATH = os.path.join(_PATH, 'results.css')
37  _CSS_MINIFIED = True
38  _JS_FILEPATH = os.path.join(_PATH, 'results.js')
39  _JS_MINIFIED = True
40
41  def __init__(self, output_filepath):
42    self._scores_data_frame = None
43    self._output_filepath = output_filepath
44
45  def Export(self, scores_data_frame):
46    """Exports scores into an HTML file.
47
48    Args:
49      scores_data_frame: DataFrame instance.
50    """
51    self._scores_data_frame = scores_data_frame
52    html = ['<html>',
53            self._BuildHeader(),
54            ('<script type="text/javascript">'
55              '(function () {'
56                'window.addEventListener(\'load\', function () {'
57                  'var inspector = new AudioInspector();'
58                '});'
59             '})();'
60             '</script>'),
61            '<body>',
62            self._BuildBody(),
63            '</body>',
64            '</html>']
65    self._Save(self._output_filepath, self._NEW_LINE.join(html))
66
67  def _BuildHeader(self):
68    """Builds the <head> section of the HTML file.
69
70    The header contains the page title and either embedded or linked CSS and JS
71    files.
72
73    Returns:
74      A string with <head>...</head> HTML.
75    """
76    html = ['<head>', '<title>Results</title>']
77
78    # Add Material Design hosted libs.
79    html.append('<link rel="stylesheet" href="http://fonts.googleapis.com/'
80                'css?family=Roboto:300,400,500,700" type="text/css">')
81    html.append('<link rel="stylesheet" href="https://fonts.googleapis.com/'
82                'icon?family=Material+Icons">')
83    html.append('<link rel="stylesheet" href="https://code.getmdl.io/1.3.0/'
84                'material.indigo-pink.min.css">')
85    html.append('<script defer src="https://code.getmdl.io/1.3.0/'
86                'material.min.js"></script>')
87
88    # Embed custom JavaScript and CSS files.
89    html.append('<script>')
90    with open(self._JS_FILEPATH) as f:
91      html.append(jsmin.jsmin(f.read()) if self._JS_MINIFIED else (
92          f.read().rstrip()))
93    html.append('</script>')
94    html.append('<style>')
95    with open(self._CSS_FILEPATH) as f:
96      html.append(csscompressor.compress(f.read()) if self._CSS_MINIFIED else (
97          f.read().rstrip()))
98    html.append('</style>')
99
100    html.append('</head>')
101
102    return self._NEW_LINE.join(html)
103
104  def _BuildBody(self):
105    """Builds the content of the <body> section."""
106    score_names = self._scores_data_frame['eval_score_name'].drop_duplicates(
107    ).values.tolist()
108
109    html = [
110        ('<div class="mdl-layout mdl-js-layout mdl-layout--fixed-header '
111         'mdl-layout--fixed-tabs">'),
112        '<header class="mdl-layout__header">',
113        '<div class="mdl-layout__header-row">',
114        '<span class="mdl-layout-title">APM QA results ({})</span>'.format(
115            self._output_filepath),
116        '</div>',
117    ]
118
119    # Tab selectors.
120    html.append('<div class="mdl-layout__tab-bar mdl-js-ripple-effect">')
121    for tab_index, score_name in enumerate(score_names):
122      is_active = tab_index == 0
123      html.append('<a href="#score-tab-{}" class="mdl-layout__tab{}">'
124                  '{}</a>'.format(tab_index,
125                                  ' is-active' if is_active else '',
126                                  self._FormatName(score_name)))
127    html.append('</div>')
128
129    html.append('</header>')
130    html.append('<main class="mdl-layout__content" style="overflow-x: auto;">')
131
132    # Tabs content.
133    for tab_index, score_name in enumerate(score_names):
134      html.append('<section class="mdl-layout__tab-panel{}" '
135                  'id="score-tab-{}">'.format(
136                      ' is-active' if is_active else '', tab_index))
137      html.append('<div class="page-content">')
138      html.append(self._BuildScoreTab(score_name, ('s{}'.format(tab_index),)))
139      html.append('</div>')
140      html.append('</section>')
141
142    html.append('</main>')
143    html.append('</div>')
144
145    # Add snackbar for notifications.
146    html.append(
147        '<div id="snackbar" aria-live="assertive" aria-atomic="true"'
148            ' aria-relevant="text" class="mdl-snackbar mdl-js-snackbar">'
149          '<div class="mdl-snackbar__text"></div>'
150          '<button type="button" class="mdl-snackbar__action"></button>'
151        '</div>')
152
153    return self._NEW_LINE.join(html)
154
155  def _BuildScoreTab(self, score_name, anchor_data):
156    """Builds the content of a tab."""
157    # Find unique values.
158    scores = self._scores_data_frame[
159        self._scores_data_frame.eval_score_name == score_name]
160    apm_configs = sorted(self._FindUniqueTuples(scores, ['apm_config']))
161    test_data_gen_configs = sorted(self._FindUniqueTuples(
162        scores, ['test_data_gen', 'test_data_gen_params']))
163
164    html = [
165        '<div class="mdl-grid">',
166        '<div class="mdl-layout-spacer"></div>',
167        '<div class="mdl-cell mdl-cell--10-col">',
168        ('<table class="mdl-data-table mdl-js-data-table mdl-shadow--2dp" '
169         'style="width: 100%;">'),
170    ]
171
172    # Header.
173    html.append('<thead><tr><th>APM config / Test data generator</th>')
174    for test_data_gen_info in test_data_gen_configs:
175      html.append('<th>{} {}</th>'.format(
176          self._FormatName(test_data_gen_info[0]), test_data_gen_info[1]))
177    html.append('</tr></thead>')
178
179    # Body.
180    html.append('<tbody>')
181    for apm_config in apm_configs:
182      html.append('<tr><td>' + self._FormatName(apm_config[0]) + '</td>')
183      for test_data_gen_info in test_data_gen_configs:
184        dialog_id = self._ScoreStatsInspectorDialogId(
185            score_name, apm_config[0], test_data_gen_info[0],
186            test_data_gen_info[1])
187        html.append(
188            '<td onclick="openScoreStatsInspector(\'{}\')">{}</td>'.format(
189                dialog_id, self._BuildScoreTableCell(
190                    score_name, test_data_gen_info[0], test_data_gen_info[1],
191                    apm_config[0])))
192      html.append('</tr>')
193    html.append('</tbody>')
194
195    html.append('</table></div><div class="mdl-layout-spacer"></div></div>')
196
197    html.append(self._BuildScoreStatsInspectorDialogs(
198        score_name, apm_configs, test_data_gen_configs,
199        anchor_data))
200
201    return self._NEW_LINE.join(html)
202
203  def _BuildScoreTableCell(self, score_name, test_data_gen,
204                           test_data_gen_params, apm_config):
205    """Builds the content of a table cell for a score table."""
206    scores = self._SliceDataForScoreTableCell(
207        score_name, apm_config, test_data_gen, test_data_gen_params)
208    stats = self._ComputeScoreStats(scores)
209
210    html = []
211    items_id_prefix = (
212        score_name + test_data_gen + test_data_gen_params + apm_config)
213    if stats['count'] == 1:
214      # Show the only available score.
215      item_id = hashlib.md5(items_id_prefix.encode('utf-8')).hexdigest()
216      html.append('<div id="single-value-{0}">{1:f}</div>'.format(
217          item_id, scores['score'].mean()))
218      html.append('<div class="mdl-tooltip" data-mdl-for="single-value-{}">{}'
219          '</div>'.format(item_id, 'single value'))
220    else:
221      # Show stats.
222      for stat_name in ['min', 'max', 'mean', 'std dev']:
223        item_id = hashlib.md5(
224            (items_id_prefix + stat_name).encode('utf-8')).hexdigest()
225        html.append('<div id="stats-{0}">{1:f}</div>'.format(
226            item_id, stats[stat_name]))
227        html.append('<div class="mdl-tooltip" data-mdl-for="stats-{}">{}'
228            '</div>'.format(item_id, stat_name))
229
230    return self._NEW_LINE.join(html)
231
232  def _BuildScoreStatsInspectorDialogs(
233      self, score_name, apm_configs, test_data_gen_configs, anchor_data):
234    """Builds a set of score stats inspector dialogs."""
235    html = []
236    for apm_config in apm_configs:
237      for test_data_gen_info in test_data_gen_configs:
238        dialog_id = self._ScoreStatsInspectorDialogId(
239            score_name, apm_config[0],
240            test_data_gen_info[0], test_data_gen_info[1])
241
242        html.append('<dialog class="mdl-dialog" id="{}" '
243                    'style="width: 40%;">'.format(dialog_id))
244
245        # Content.
246        html.append('<div class="mdl-dialog__content">')
247        html.append('<h6><strong>APM config preset</strong>: {}<br/>'
248                    '<strong>Test data generator</strong>: {} ({})</h6>'.format(
249                        self._FormatName(apm_config[0]),
250                        self._FormatName(test_data_gen_info[0]),
251                        test_data_gen_info[1]))
252        html.append(self._BuildScoreStatsInspectorDialog(
253            score_name, apm_config[0], test_data_gen_info[0],
254            test_data_gen_info[1], anchor_data + (dialog_id,)))
255        html.append('</div>')
256
257        # Actions.
258        html.append('<div class="mdl-dialog__actions">')
259        html.append('<button type="button" class="mdl-button" '
260                    'onclick="closeScoreStatsInspector()">'
261                    'Close</button>')
262        html.append('</div>')
263
264        html.append('</dialog>')
265
266    return self._NEW_LINE.join(html)
267
268  def _BuildScoreStatsInspectorDialog(
269      self, score_name, apm_config, test_data_gen, test_data_gen_params,
270      anchor_data):
271    """Builds one score stats inspector dialog."""
272    scores = self._SliceDataForScoreTableCell(
273        score_name, apm_config, test_data_gen, test_data_gen_params)
274
275    capture_render_pairs = sorted(self._FindUniqueTuples(
276        scores, ['capture', 'render']))
277    echo_simulators = sorted(self._FindUniqueTuples(scores, ['echo_simulator']))
278
279    html = ['<table class="mdl-data-table mdl-js-data-table mdl-shadow--2dp">']
280
281    # Header.
282    html.append('<thead><tr><th>Capture-Render / Echo simulator</th>')
283    for echo_simulator in echo_simulators:
284      html.append('<th>' + self._FormatName(echo_simulator[0]) +'</th>')
285    html.append('</tr></thead>')
286
287    # Body.
288    html.append('<tbody>')
289    for row, (capture, render) in enumerate(capture_render_pairs):
290      html.append('<tr><td><div>{}</div><div>{}</div></td>'.format(
291          capture, render))
292      for col, echo_simulator in enumerate(echo_simulators):
293        score_tuple = self._SliceDataForScoreStatsTableCell(
294            scores, capture, render, echo_simulator[0])
295        cell_class = 'r{}c{}'.format(row, col)
296        html.append('<td class="single-score-cell {}">{}</td>'.format(
297            cell_class, self._BuildScoreStatsInspectorTableCell(
298                score_tuple, anchor_data + (cell_class,))))
299      html.append('</tr>')
300    html.append('</tbody>')
301
302    html.append('</table>')
303
304    # Placeholder for the audio inspector.
305    html.append('<div class="audio-inspector-placeholder"></div>')
306
307    return self._NEW_LINE.join(html)
308
309  def _BuildScoreStatsInspectorTableCell(self, score_tuple, anchor_data):
310    """Builds the content of a cell of a score stats inspector."""
311    anchor = '&'.join(anchor_data)
312    html = [('<div class="v">{}</div>'
313             '<button class="mdl-button mdl-js-button mdl-button--icon"'
314                    ' data-anchor="{}">'
315               '<i class="material-icons mdl-color-text--blue-grey">link</i>'
316             '</button>').format(score_tuple.score, anchor)]
317
318    # Add all the available file paths as hidden data.
319    for field_name in score_tuple.keys():
320      if field_name.endswith('_filepath'):
321        html.append('<input type="hidden" name="{}" value="{}">'.format(
322            field_name, score_tuple[field_name]))
323
324    return self._NEW_LINE.join(html)
325
326  def _SliceDataForScoreTableCell(
327      self, score_name, apm_config, test_data_gen, test_data_gen_params):
328    """Slices |self._scores_data_frame| to extract the data for a tab."""
329    masks = []
330    masks.append(self._scores_data_frame.eval_score_name == score_name)
331    masks.append(self._scores_data_frame.apm_config == apm_config)
332    masks.append(self._scores_data_frame.test_data_gen == test_data_gen)
333    masks.append(
334        self._scores_data_frame.test_data_gen_params == test_data_gen_params)
335    mask = functools.reduce((lambda i1, i2: i1 & i2), masks)
336    del masks
337    return self._scores_data_frame[mask]
338
339  @classmethod
340  def _SliceDataForScoreStatsTableCell(
341      cls, scores, capture, render, echo_simulator):
342    """Slices |scores| to extract the data for a tab."""
343    masks = []
344
345    masks.append(scores.capture == capture)
346    masks.append(scores.render == render)
347    masks.append(scores.echo_simulator == echo_simulator)
348    mask = functools.reduce((lambda i1, i2: i1 & i2), masks)
349    del masks
350
351    sliced_data = scores[mask]
352    assert len(sliced_data) == 1, 'single score is expected'
353    return sliced_data.iloc[0]
354
355  @classmethod
356  def _FindUniqueTuples(cls, data_frame, fields):
357    """Slices |data_frame| to a list of fields and finds unique tuples."""
358    return data_frame[fields].drop_duplicates().values.tolist()
359
360  @classmethod
361  def _ComputeScoreStats(cls, data_frame):
362    """Computes score stats."""
363    scores = data_frame['score']
364    return {
365        'count': scores.count(),
366        'min': scores.min(),
367        'max': scores.max(),
368        'mean': scores.mean(),
369        'std dev': scores.std(),
370    }
371
372  @classmethod
373  def _ScoreStatsInspectorDialogId(cls, score_name, apm_config, test_data_gen,
374                                   test_data_gen_params):
375    """Assigns a unique name to a dialog."""
376    return 'score-stats-dialog-' + hashlib.md5(
377        'score-stats-inspector-{}-{}-{}-{}'.format(
378            score_name, apm_config, test_data_gen,
379            test_data_gen_params).encode('utf-8')).hexdigest()
380
381  @classmethod
382  def _Save(cls, output_filepath, html):
383    """Writes the HTML file.
384
385    Args:
386      output_filepath: output file path.
387      html: string with the HTML content.
388    """
389    with open(output_filepath, 'w') as f:
390      f.write(html)
391
392  @classmethod
393  def _FormatName(cls, name):
394    """Formats a name.
395
396    Args:
397      name: a string.
398
399    Returns:
400      A copy of name in which underscores and dashes are replaced with a space.
401    """
402    return re.sub(r'[_\-]', ' ', name)
403