blob: 7bd226bd3957ce0f8702582b77d3b29ed4f28fb9 [file] [log] [blame]
# 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)