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