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