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