| # Copyright (c) 2017 The WebRTC project authors. All Rights Reserved. |
| # |
| # Use of this source code is governed by a BSD-style license |
| # that can be found in the LICENSE file in the root of the source |
| # tree. An additional intellectual property rights grant can be found |
| # in the file PATENTS. All contributing project authors may |
| # be found in the AUTHORS file in the root of the source tree. |
| |
| import functools |
| import hashlib |
| import logging |
| import os |
| import re |
| import sys |
| |
| try: |
| import csscompressor |
| except ImportError: |
| logging.critical( |
| 'Cannot import the third-party Python package csscompressor') |
| sys.exit(1) |
| |
| try: |
| import jsmin |
| except ImportError: |
| logging.critical('Cannot import the third-party Python package jsmin') |
| sys.exit(1) |
| |
| |
| class HtmlExport(object): |
| """HTML exporter class for APM quality scores.""" |
| |
| _NEW_LINE = '\n' |
| |
| # CSS and JS file paths. |
| _PATH = os.path.dirname(os.path.realpath(__file__)) |
| _CSS_FILEPATH = os.path.join(_PATH, 'results.css') |
| _CSS_MINIFIED = True |
| _JS_FILEPATH = os.path.join(_PATH, 'results.js') |
| _JS_MINIFIED = True |
| |
| def __init__(self, output_filepath): |
| self._scores_data_frame = None |
| self._output_filepath = output_filepath |
| |
| def Export(self, scores_data_frame): |
| """Exports scores into an HTML file. |
| |
| Args: |
| scores_data_frame: DataFrame instance. |
| """ |
| self._scores_data_frame = scores_data_frame |
| html = [ |
| '<html>', |
| self._BuildHeader(), |
| ('<script type="text/javascript">' |
| '(function () {' |
| 'window.addEventListener(\'load\', function () {' |
| 'var inspector = new AudioInspector();' |
| '});' |
| '})();' |
| '</script>'), '<body>', |
| self._BuildBody(), '</body>', '</html>' |
| ] |
| self._Save(self._output_filepath, self._NEW_LINE.join(html)) |
| |
| def _BuildHeader(self): |
| """Builds the <head> section of the HTML file. |
| |
| The header contains the page title and either embedded or linked CSS and JS |
| files. |
| |
| Returns: |
| A string with <head>...</head> HTML. |
| """ |
| html = ['<head>', '<title>Results</title>'] |
| |
| # Add Material Design hosted libs. |
| html.append('<link rel="stylesheet" href="http://fonts.googleapis.com/' |
| 'css?family=Roboto:300,400,500,700" type="text/css">') |
| html.append( |
| '<link rel="stylesheet" href="https://fonts.googleapis.com/' |
| 'icon?family=Material+Icons">') |
| html.append( |
| '<link rel="stylesheet" href="https://code.getmdl.io/1.3.0/' |
| 'material.indigo-pink.min.css">') |
| html.append('<script defer src="https://code.getmdl.io/1.3.0/' |
| 'material.min.js"></script>') |
| |
| # Embed custom JavaScript and CSS files. |
| html.append('<script>') |
| with open(self._JS_FILEPATH) as f: |
| html.append( |
| jsmin.jsmin(f.read()) if self._JS_MINIFIED else ( |
| f.read().rstrip())) |
| html.append('</script>') |
| html.append('<style>') |
| with open(self._CSS_FILEPATH) as f: |
| html.append( |
| csscompressor.compress(f.read()) if self._CSS_MINIFIED else ( |
| f.read().rstrip())) |
| html.append('</style>') |
| |
| html.append('</head>') |
| |
| return self._NEW_LINE.join(html) |
| |
| def _BuildBody(self): |
| """Builds the content of the <body> section.""" |
| score_names = self._scores_data_frame[ |
| 'eval_score_name'].drop_duplicates().values.tolist() |
| |
| html = [ |
| ('<div class="mdl-layout mdl-js-layout mdl-layout--fixed-header ' |
| 'mdl-layout--fixed-tabs">'), |
| '<header class="mdl-layout__header">', |
| '<div class="mdl-layout__header-row">', |
| '<span class="mdl-layout-title">APM QA results ({})</span>'.format( |
| self._output_filepath), |
| '</div>', |
| ] |
| |
| # Tab selectors. |
| html.append('<div class="mdl-layout__tab-bar mdl-js-ripple-effect">') |
| for tab_index, score_name in enumerate(score_names): |
| is_active = tab_index == 0 |
| html.append('<a href="#score-tab-{}" class="mdl-layout__tab{}">' |
| '{}</a>'.format(tab_index, |
| ' is-active' if is_active else '', |
| self._FormatName(score_name))) |
| html.append('</div>') |
| |
| html.append('</header>') |
| html.append( |
| '<main class="mdl-layout__content" style="overflow-x: auto;">') |
| |
| # Tabs content. |
| for tab_index, score_name in enumerate(score_names): |
| html.append('<section class="mdl-layout__tab-panel{}" ' |
| 'id="score-tab-{}">'.format( |
| ' is-active' if is_active else '', tab_index)) |
| html.append('<div class="page-content">') |
| html.append( |
| self._BuildScoreTab(score_name, ('s{}'.format(tab_index), ))) |
| html.append('</div>') |
| html.append('</section>') |
| |
| html.append('</main>') |
| html.append('</div>') |
| |
| # Add snackbar for notifications. |
| html.append( |
| '<div id="snackbar" aria-live="assertive" aria-atomic="true"' |
| ' aria-relevant="text" class="mdl-snackbar mdl-js-snackbar">' |
| '<div class="mdl-snackbar__text"></div>' |
| '<button type="button" class="mdl-snackbar__action"></button>' |
| '</div>') |
| |
| return self._NEW_LINE.join(html) |
| |
| def _BuildScoreTab(self, score_name, anchor_data): |
| """Builds the content of a tab.""" |
| # Find unique values. |
| scores = self._scores_data_frame[ |
| self._scores_data_frame.eval_score_name == score_name] |
| apm_configs = sorted(self._FindUniqueTuples(scores, ['apm_config'])) |
| test_data_gen_configs = sorted( |
| self._FindUniqueTuples(scores, |
| ['test_data_gen', 'test_data_gen_params'])) |
| |
| html = [ |
| '<div class="mdl-grid">', |
| '<div class="mdl-layout-spacer"></div>', |
| '<div class="mdl-cell mdl-cell--10-col">', |
| ('<table class="mdl-data-table mdl-js-data-table mdl-shadow--2dp" ' |
| 'style="width: 100%;">'), |
| ] |
| |
| # Header. |
| html.append('<thead><tr><th>APM config / Test data generator</th>') |
| for test_data_gen_info in test_data_gen_configs: |
| html.append('<th>{} {}</th>'.format( |
| self._FormatName(test_data_gen_info[0]), |
| test_data_gen_info[1])) |
| html.append('</tr></thead>') |
| |
| # Body. |
| html.append('<tbody>') |
| for apm_config in apm_configs: |
| html.append('<tr><td>' + self._FormatName(apm_config[0]) + '</td>') |
| for test_data_gen_info in test_data_gen_configs: |
| dialog_id = self._ScoreStatsInspectorDialogId( |
| score_name, apm_config[0], test_data_gen_info[0], |
| test_data_gen_info[1]) |
| html.append( |
| '<td onclick="openScoreStatsInspector(\'{}\')">{}</td>'. |
| format( |
| dialog_id, |
| self._BuildScoreTableCell(score_name, |
| test_data_gen_info[0], |
| test_data_gen_info[1], |
| apm_config[0]))) |
| html.append('</tr>') |
| html.append('</tbody>') |
| |
| html.append( |
| '</table></div><div class="mdl-layout-spacer"></div></div>') |
| |
| html.append( |
| self._BuildScoreStatsInspectorDialogs(score_name, apm_configs, |
| test_data_gen_configs, |
| anchor_data)) |
| |
| return self._NEW_LINE.join(html) |
| |
| def _BuildScoreTableCell(self, score_name, test_data_gen, |
| test_data_gen_params, apm_config): |
| """Builds the content of a table cell for a score table.""" |
| scores = self._SliceDataForScoreTableCell(score_name, apm_config, |
| test_data_gen, |
| test_data_gen_params) |
| stats = self._ComputeScoreStats(scores) |
| |
| html = [] |
| items_id_prefix = (score_name + test_data_gen + test_data_gen_params + |
| apm_config) |
| if stats['count'] == 1: |
| # Show the only available score. |
| item_id = hashlib.md5(items_id_prefix.encode('utf-8')).hexdigest() |
| html.append('<div id="single-value-{0}">{1:f}</div>'.format( |
| item_id, scores['score'].mean())) |
| html.append( |
| '<div class="mdl-tooltip" data-mdl-for="single-value-{}">{}' |
| '</div>'.format(item_id, 'single value')) |
| else: |
| # Show stats. |
| for stat_name in ['min', 'max', 'mean', 'std dev']: |
| item_id = hashlib.md5( |
| (items_id_prefix + stat_name).encode('utf-8')).hexdigest() |
| html.append('<div id="stats-{0}">{1:f}</div>'.format( |
| item_id, stats[stat_name])) |
| html.append( |
| '<div class="mdl-tooltip" data-mdl-for="stats-{}">{}' |
| '</div>'.format(item_id, stat_name)) |
| |
| return self._NEW_LINE.join(html) |
| |
| def _BuildScoreStatsInspectorDialogs(self, score_name, apm_configs, |
| test_data_gen_configs, anchor_data): |
| """Builds a set of score stats inspector dialogs.""" |
| html = [] |
| for apm_config in apm_configs: |
| for test_data_gen_info in test_data_gen_configs: |
| dialog_id = self._ScoreStatsInspectorDialogId( |
| score_name, apm_config[0], test_data_gen_info[0], |
| test_data_gen_info[1]) |
| |
| html.append('<dialog class="mdl-dialog" id="{}" ' |
| 'style="width: 40%;">'.format(dialog_id)) |
| |
| # Content. |
| html.append('<div class="mdl-dialog__content">') |
| html.append( |
| '<h6><strong>APM config preset</strong>: {}<br/>' |
| '<strong>Test data generator</strong>: {} ({})</h6>'. |
| format(self._FormatName(apm_config[0]), |
| self._FormatName(test_data_gen_info[0]), |
| test_data_gen_info[1])) |
| html.append( |
| self._BuildScoreStatsInspectorDialog( |
| score_name, apm_config[0], test_data_gen_info[0], |
| test_data_gen_info[1], anchor_data + (dialog_id, ))) |
| html.append('</div>') |
| |
| # Actions. |
| html.append('<div class="mdl-dialog__actions">') |
| html.append('<button type="button" class="mdl-button" ' |
| 'onclick="closeScoreStatsInspector()">' |
| 'Close</button>') |
| html.append('</div>') |
| |
| html.append('</dialog>') |
| |
| return self._NEW_LINE.join(html) |
| |
| def _BuildScoreStatsInspectorDialog(self, score_name, apm_config, |
| test_data_gen, test_data_gen_params, |
| anchor_data): |
| """Builds one score stats inspector dialog.""" |
| scores = self._SliceDataForScoreTableCell(score_name, apm_config, |
| test_data_gen, |
| test_data_gen_params) |
| |
| capture_render_pairs = sorted( |
| self._FindUniqueTuples(scores, ['capture', 'render'])) |
| echo_simulators = sorted( |
| self._FindUniqueTuples(scores, ['echo_simulator'])) |
| |
| html = [ |
| '<table class="mdl-data-table mdl-js-data-table mdl-shadow--2dp">' |
| ] |
| |
| # Header. |
| html.append('<thead><tr><th>Capture-Render / Echo simulator</th>') |
| for echo_simulator in echo_simulators: |
| html.append('<th>' + self._FormatName(echo_simulator[0]) + '</th>') |
| html.append('</tr></thead>') |
| |
| # Body. |
| html.append('<tbody>') |
| for row, (capture, render) in enumerate(capture_render_pairs): |
| html.append('<tr><td><div>{}</div><div>{}</div></td>'.format( |
| capture, render)) |
| for col, echo_simulator in enumerate(echo_simulators): |
| score_tuple = self._SliceDataForScoreStatsTableCell( |
| scores, capture, render, echo_simulator[0]) |
| cell_class = 'r{}c{}'.format(row, col) |
| html.append('<td class="single-score-cell {}">{}</td>'.format( |
| cell_class, |
| self._BuildScoreStatsInspectorTableCell( |
| score_tuple, anchor_data + (cell_class, )))) |
| html.append('</tr>') |
| html.append('</tbody>') |
| |
| html.append('</table>') |
| |
| # Placeholder for the audio inspector. |
| html.append('<div class="audio-inspector-placeholder"></div>') |
| |
| return self._NEW_LINE.join(html) |
| |
| def _BuildScoreStatsInspectorTableCell(self, score_tuple, anchor_data): |
| """Builds the content of a cell of a score stats inspector.""" |
| anchor = '&'.join(anchor_data) |
| html = [('<div class="v">{}</div>' |
| '<button class="mdl-button mdl-js-button mdl-button--icon"' |
| ' data-anchor="{}">' |
| '<i class="material-icons mdl-color-text--blue-grey">link</i>' |
| '</button>').format(score_tuple.score, anchor)] |
| |
| # Add all the available file paths as hidden data. |
| for field_name in score_tuple.keys(): |
| if field_name.endswith('_filepath'): |
| html.append( |
| '<input type="hidden" name="{}" value="{}">'.format( |
| field_name, score_tuple[field_name])) |
| |
| return self._NEW_LINE.join(html) |
| |
| def _SliceDataForScoreTableCell(self, score_name, apm_config, |
| test_data_gen, test_data_gen_params): |
| """Slices |self._scores_data_frame| to extract the data for a tab.""" |
| masks = [] |
| masks.append(self._scores_data_frame.eval_score_name == score_name) |
| masks.append(self._scores_data_frame.apm_config == apm_config) |
| masks.append(self._scores_data_frame.test_data_gen == test_data_gen) |
| masks.append(self._scores_data_frame.test_data_gen_params == |
| test_data_gen_params) |
| mask = functools.reduce((lambda i1, i2: i1 & i2), masks) |
| del masks |
| return self._scores_data_frame[mask] |
| |
| @classmethod |
| def _SliceDataForScoreStatsTableCell(cls, scores, capture, render, |
| echo_simulator): |
| """Slices |scores| to extract the data for a tab.""" |
| masks = [] |
| |
| masks.append(scores.capture == capture) |
| masks.append(scores.render == render) |
| masks.append(scores.echo_simulator == echo_simulator) |
| mask = functools.reduce((lambda i1, i2: i1 & i2), masks) |
| del masks |
| |
| sliced_data = scores[mask] |
| assert len(sliced_data) == 1, 'single score is expected' |
| return sliced_data.iloc[0] |
| |
| @classmethod |
| def _FindUniqueTuples(cls, data_frame, fields): |
| """Slices |data_frame| to a list of fields and finds unique tuples.""" |
| return data_frame[fields].drop_duplicates().values.tolist() |
| |
| @classmethod |
| def _ComputeScoreStats(cls, data_frame): |
| """Computes score stats.""" |
| scores = data_frame['score'] |
| return { |
| 'count': scores.count(), |
| 'min': scores.min(), |
| 'max': scores.max(), |
| 'mean': scores.mean(), |
| 'std dev': scores.std(), |
| } |
| |
| @classmethod |
| def _ScoreStatsInspectorDialogId(cls, score_name, apm_config, |
| test_data_gen, test_data_gen_params): |
| """Assigns a unique name to a dialog.""" |
| return 'score-stats-dialog-' + hashlib.md5( |
| 'score-stats-inspector-{}-{}-{}-{}'.format( |
| score_name, apm_config, test_data_gen, |
| test_data_gen_params).encode('utf-8')).hexdigest() |
| |
| @classmethod |
| def _Save(cls, output_filepath, html): |
| """Writes the HTML file. |
| |
| Args: |
| output_filepath: output file path. |
| html: string with the HTML content. |
| """ |
| with open(output_filepath, 'w') as f: |
| f.write(html) |
| |
| @classmethod |
| def _FormatName(cls, name): |
| """Formats a name. |
| |
| Args: |
| name: a string. |
| |
| Returns: |
| A copy of name in which underscores and dashes are replaced with a space. |
| """ |
| return re.sub(r'[_\-]', ' ', name) |