| # 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 os |
| import re |
| |
| |
| 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') |
| _JS_FILEPATH = os.path.join(_PATH, 'results.js') |
| |
| 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(), |
| '<body onload="initialize()">', |
| 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. |
| def EmbedFile(filepath): |
| with open(filepath) as f: |
| for l in f: |
| html.append(l.rstrip()) |
| html.append('<script>') |
| EmbedFile(self._JS_FILEPATH) |
| html.append('</script>') |
| html.append('<style>') |
| EmbedFile(self._CSS_FILEPATH) |
| 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">') |
| |
| # 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)) |
| html.append('</div>') |
| html.append('</section>') |
| |
| html.append('</main>') |
| html.append('</div>') |
| |
| return self._NEW_LINE.join(html) |
| |
| def _BuildScoreTab(self, score_name): |
| """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: |
| onclick_handler = 'openScoreStatsInspector(\'{}\')'.format( |
| self._ScoreStatsInspectorDialogId(score_name, apm_config[0], |
| test_data_gen_info[0], |
| test_data_gen_info[1])) |
| html.append('<td onclick="{}">{}</td>'.format( |
| onclick_handler, 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)) |
| |
| 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): |
| """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])) |
| html.append('</div>') |
| |
| # Actions. |
| html.append('<div class="mdl-dialog__actions">') |
| html.append('<button type="button" class="mdl-button" ' |
| 'onclick="closeScoreStatsInspector(\'' + dialog_id + '\')">' |
| '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): |
| """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 capture, render in capture_render_pairs: |
| html.append('<tr><td><div>{}</div><div>{}</div></td>'.format( |
| capture, render)) |
| for echo_simulator in echo_simulators: |
| score_tuple = self._SliceDataForScoreStatsTableCell( |
| scores, capture, render, echo_simulator[0]) |
| html.append('<td class="single-score-cell">{}</td>'.format( |
| self._BuildScoreStatsInspectorTableCell(score_tuple))) |
| 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): |
| """Builds the content of a cell of a score stats inspector.""" |
| html = ['<div>{}</div>'.format(score_tuple.score)] |
| |
| # 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) |