1// Copyright (C) 2018 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import * as m from 'mithril'; 16 17import {assertTrue} from '../base/logging'; 18import {Actions} from '../common/actions'; 19import {QueryResponse} from '../common/queries'; 20import {EngineMode} from '../common/state'; 21 22import {Animation} from './animation'; 23import {globals} from './globals'; 24import {toggleHelp} from './help_modal'; 25import { 26 isLegacyTrace, 27 openFileWithLegacyTraceViewer, 28} from './legacy_trace_viewer'; 29import {showModal} from './modal'; 30 31const ALL_PROCESSES_QUERY = 'select name, pid from process order by name;'; 32 33const CPU_TIME_FOR_PROCESSES = ` 34select 35 process.name, 36 tot_proc/1e9 as cpu_sec 37from 38 (select 39 upid, 40 sum(tot_thd) as tot_proc 41 from 42 (select 43 utid, 44 sum(dur) as tot_thd 45 from sched group by utid) 46 join thread using(utid) group by upid) 47join process using(upid) 48order by cpu_sec desc limit 100;`; 49 50const CYCLES_PER_P_STATE_PER_CPU = ` 51select 52 cpu, 53 freq, 54 dur, 55 sum(dur * freq)/1e6 as mcycles 56from ( 57 select 58 cpu, 59 value as freq, 60 lead(ts) over (partition by cpu order by ts) - ts as dur 61 from counter 62 inner join cpu_counter_track on counter.track_id = cpu_counter_track.id 63 where name = 'cpufreq' 64) group by cpu, freq 65order by mcycles desc limit 32;`; 66 67const CPU_TIME_BY_CLUSTER_BY_PROCESS = ` 68select process.name as process, thread, core, cpu_sec from ( 69 select thread.name as thread, upid, 70 case when cpug = 0 then 'little' else 'big' end as core, 71 cpu_sec from (select cpu/4 as cpug, utid, sum(dur)/1e9 as cpu_sec 72 from sched group by utid, cpug order by cpu_sec desc 73 ) inner join thread using(utid) 74) inner join process using(upid) limit 30;`; 75 76const HEAP_GRAPH_BYTES_PER_TYPE = ` 77select 78 upid, 79 graph_sample_ts, 80 type_name, 81 sum(self_size) as total_self_size 82from heap_graph_object 83group by 84 upid, 85 graph_sample_ts, 86 type_name 87order by total_self_size desc 88limit 100;`; 89 90const SQL_STATS = ` 91with first as (select started as ts from sqlstats limit 1) 92select query, 93 round((max(ended - started, 0))/1e6) as runtime_ms, 94 round((max(started - queued, 0))/1e6) as latency_ms, 95 round((started - first.ts)/1e6) as t_start_ms 96from sqlstats, first 97order by started desc`; 98 99const TRACE_STATS = 'select * from stats order by severity, source, name, idx'; 100 101let lastTabTitle = ''; 102 103function createCannedQuery(query: string): (_: Event) => void { 104 return (e: Event) => { 105 e.preventDefault(); 106 globals.dispatch(Actions.executeQuery({ 107 engineId: '0', 108 queryId: 'command', 109 query, 110 })); 111 }; 112} 113 114const EXAMPLE_ANDROID_TRACE_URL = 115 'https://storage.googleapis.com/perfetto-misc/example_android_trace_15s'; 116 117const EXAMPLE_CHROME_TRACE_URL = 118 'https://storage.googleapis.com/perfetto-misc/example_chrome_trace_4s_1.json'; 119 120const SECTIONS = [ 121 { 122 title: 'Navigation', 123 summary: 'Open or record a new trace', 124 expanded: true, 125 items: [ 126 {t: 'Open trace file', a: popupFileSelectionDialog, i: 'folder_open'}, 127 { 128 t: 'Open with legacy UI', 129 a: popupFileSelectionDialogOldUI, 130 i: 'filter_none' 131 }, 132 {t: 'Record new trace', a: navigateRecord, i: 'fiber_smart_record'}, 133 ], 134 }, 135 { 136 title: 'Current Trace', 137 summary: 'Actions on the current trace', 138 expanded: true, 139 hideIfNoTraceLoaded: true, 140 appendOpenedTraceTitle: true, 141 items: [ 142 {t: 'Show timeline', a: navigateViewer, i: 'line_style'}, 143 { 144 t: 'Share', 145 a: dispatchCreatePermalink, 146 i: 'share', 147 checkDownloadDisabled: true, 148 internalUserOnly: true, 149 }, 150 { 151 t: 'Download', 152 a: downloadTrace, 153 i: 'file_download', 154 checkDownloadDisabled: true, 155 }, 156 {t: 'Legacy UI', a: openCurrentTraceWithOldUI, i: 'filter_none'}, 157 {t: 'Analyze', a: navigateAnalyze, i: 'control_camera'}, 158 ], 159 }, 160 { 161 title: 'Example Traces', 162 expanded: true, 163 summary: 'Open an example trace', 164 items: [ 165 { 166 t: 'Open Android example', 167 a: openTraceUrl(EXAMPLE_ANDROID_TRACE_URL), 168 i: 'description' 169 }, 170 { 171 t: 'Open Chrome example', 172 a: openTraceUrl(EXAMPLE_CHROME_TRACE_URL), 173 i: 'description' 174 }, 175 ], 176 }, 177 { 178 title: 'Metrics and auditors', 179 summary: 'Compute summary statistics', 180 items: [ 181 { 182 t: 'All Processes', 183 a: createCannedQuery(ALL_PROCESSES_QUERY), 184 i: 'search', 185 }, 186 { 187 t: 'CPU Time by process', 188 a: createCannedQuery(CPU_TIME_FOR_PROCESSES), 189 i: 'search', 190 }, 191 { 192 t: 'Cycles by p-state by CPU', 193 a: createCannedQuery(CYCLES_PER_P_STATE_PER_CPU), 194 i: 'search', 195 }, 196 { 197 t: 'CPU Time by cluster by process', 198 a: createCannedQuery(CPU_TIME_BY_CLUSTER_BY_PROCESS), 199 i: 'search', 200 }, 201 { 202 t: 'Heap Graph: Bytes per type', 203 a: createCannedQuery(HEAP_GRAPH_BYTES_PER_TYPE), 204 i: 'search', 205 }, 206 { 207 t: 'Trace stats', 208 a: createCannedQuery(TRACE_STATS), 209 i: 'bug_report', 210 }, 211 { 212 t: 'Debug SQL performance', 213 a: createCannedQuery(SQL_STATS), 214 i: 'bug_report', 215 }, 216 ], 217 }, 218 { 219 title: 'Support', 220 summary: 'Documentation & Bugs', 221 items: [ 222 { 223 t: 'Controls', 224 a: openHelp, 225 i: 'help', 226 }, 227 { 228 t: 'Documentation', 229 a: 'https://perfetto.dev', 230 i: 'find_in_page', 231 }, 232 { 233 t: 'Report a bug', 234 a: 'https://goto.google.com/perfetto-ui-bug', 235 i: 'bug_report', 236 }, 237 ], 238 }, 239 240]; 241 242const vidSection = { 243 title: 'Video', 244 summary: 'Open a screen recording', 245 expanded: true, 246 items: [ 247 {t: 'Open video file', a: popupVideoSelectionDialog, i: 'folder_open'}, 248 ], 249}; 250 251function openHelp(e: Event) { 252 e.preventDefault(); 253 toggleHelp(); 254} 255 256function getFileElement(): HTMLInputElement { 257 return document.querySelector('input[type=file]')! as HTMLInputElement; 258} 259 260function popupFileSelectionDialog(e: Event) { 261 e.preventDefault(); 262 delete getFileElement().dataset['useCatapultLegacyUi']; 263 delete getFileElement().dataset['video']; 264 getFileElement().click(); 265} 266 267function popupFileSelectionDialogOldUI(e: Event) { 268 e.preventDefault(); 269 delete getFileElement().dataset['video']; 270 getFileElement().dataset['useCatapultLegacyUi'] = '1'; 271 getFileElement().click(); 272} 273 274function openCurrentTraceWithOldUI(e: Event) { 275 e.preventDefault(); 276 console.assert(isTraceLoaded()); 277 if (!isTraceLoaded) return; 278 const engine = Object.values(globals.state.engines)[0]; 279 const src = engine.source; 280 if (src.type === 'ARRAY_BUFFER') { 281 openInOldUIWithSizeCheck(new Blob([src.buffer])); 282 } else if (src.type === 'FILE') { 283 openInOldUIWithSizeCheck(src.file); 284 } else { 285 throw new Error('Loading from a URL to catapult is not yet supported'); 286 // TODO(nicomazz): Find how to get the data of the current trace if it is 287 // from a URL. It seems that the trace downloaded is given to the trace 288 // processor, but not kept somewhere accessible. Maybe the only way is to 289 // download the trace (again), and then open it. An alternative can be to 290 // save a copy. 291 } 292} 293 294function isTraceLoaded(): boolean { 295 const engine = Object.values(globals.state.engines)[0]; 296 return engine !== undefined; 297} 298 299function popupVideoSelectionDialog(e: Event) { 300 e.preventDefault(); 301 delete getFileElement().dataset['useCatapultLegacyUi']; 302 getFileElement().dataset['video'] = '1'; 303 getFileElement().click(); 304} 305 306function openTraceUrl(url: string): (e: Event) => void { 307 return e => { 308 e.preventDefault(); 309 globals.frontendLocalState.localOnlyMode = false; 310 globals.dispatch(Actions.openTraceFromUrl({url})); 311 }; 312} 313 314function onInputElementFileSelectionChanged(e: Event) { 315 if (!(e.target instanceof HTMLInputElement)) { 316 throw new Error('Not an input element'); 317 } 318 if (!e.target.files) return; 319 const file = e.target.files[0]; 320 // Reset the value so onchange will be fired with the same file. 321 e.target.value = ''; 322 323 globals.frontendLocalState.localOnlyMode = false; 324 325 if (e.target.dataset['useCatapultLegacyUi'] === '1') { 326 openWithLegacyUi(file); 327 return; 328 } 329 330 if (e.target.dataset['video'] === '1') { 331 // TODO(hjd): Update this to use a controller and publish. 332 globals.dispatch(Actions.executeQuery({ 333 engineId: '0', queryId: 'command', 334 query: `select ts from slices where name = 'first_frame' union ` + 335 `select start_ts from trace_bounds`})); 336 setTimeout(() => { 337 const resp = globals.queryResults.get('command') as QueryResponse; 338 // First value is screenrecord trace event timestamp 339 // and second value is trace boundary's start timestamp 340 const offset = (Number(resp.rows[1]['ts'].toString()) - 341 Number(resp.rows[0]['ts'].toString())) / 342 1e9; 343 globals.queryResults.delete('command'); 344 globals.rafScheduler.scheduleFullRedraw(); 345 globals.dispatch(Actions.deleteQuery({queryId: 'command'})); 346 globals.dispatch(Actions.setVideoOffset({offset})); 347 }, 1000); 348 globals.dispatch(Actions.openVideoFromFile({file})); 349 return; 350 } 351 352 globals.dispatch(Actions.openTraceFromFile({file})); 353} 354 355async function openWithLegacyUi(file: File) { 356 // Switch back to the old catapult UI. 357 if (await isLegacyTrace(file)) { 358 openFileWithLegacyTraceViewer(file); 359 return; 360 } 361 openInOldUIWithSizeCheck(file); 362} 363 364function openInOldUIWithSizeCheck(trace: Blob) { 365 // Perfetto traces smaller than 50mb can be safely opened in the legacy UI. 366 if (trace.size < 1024 * 1024 * 50) { 367 globals.dispatch(Actions.convertTraceToJson({file: trace})); 368 return; 369 } 370 371 // Give the user the option to truncate larger perfetto traces. 372 const size = Math.round(trace.size / (1024 * 1024)); 373 showModal({ 374 title: 'Legacy UI may fail to open this trace', 375 content: 376 m('div', 377 m('p', 378 `This trace is ${size}mb, opening it in the legacy UI ` + 379 `may fail.`), 380 m('p', 381 'More options can be found at ', 382 m('a', 383 { 384 href: 'https://goto.google.com/opening-large-traces', 385 target: '_blank' 386 }, 387 'go/opening-large-traces'), 388 '.')), 389 buttons: [ 390 { 391 text: 'Open full trace (not recommended)', 392 primary: false, 393 id: 'open', 394 action: () => { 395 globals.dispatch(Actions.convertTraceToJson({file: trace})); 396 } 397 }, 398 { 399 text: 'Open beginning of trace', 400 primary: true, 401 id: 'truncate-start', 402 action: () => { 403 globals.dispatch( 404 Actions.convertTraceToJson({file: trace, truncate: 'start'})); 405 } 406 }, 407 { 408 text: 'Open end of trace', 409 primary: true, 410 id: 'truncate-end', 411 action: () => { 412 globals.dispatch( 413 Actions.convertTraceToJson({file: trace, truncate: 'end'})); 414 } 415 } 416 417 ] 418 }); 419 return; 420} 421 422function navigateRecord(e: Event) { 423 e.preventDefault(); 424 globals.dispatch(Actions.navigate({route: '/record'})); 425} 426 427function navigateAnalyze(e: Event) { 428 e.preventDefault(); 429 globals.dispatch(Actions.navigate({route: '/analyze'})); 430} 431 432function navigateViewer(e: Event) { 433 e.preventDefault(); 434 globals.dispatch(Actions.navigate({route: '/viewer'})); 435} 436 437function isDownloadAndShareDisabled(): boolean { 438 if (globals.frontendLocalState.localOnlyMode) return true; 439 const engine = Object.values(globals.state.engines)[0]; 440 if (engine && engine.source.type === 'HTTP_RPC') return true; 441 return false; 442} 443 444function dispatchCreatePermalink(e: Event) { 445 e.preventDefault(); 446 if (isDownloadAndShareDisabled() || !isTraceLoaded()) return; 447 448 const result = confirm( 449 `Upload the trace and generate a permalink. ` + 450 `The trace will be accessible by anybody with the permalink.`); 451 if (result) globals.dispatch(Actions.createPermalink({})); 452} 453 454function downloadTrace(e: Event) { 455 e.preventDefault(); 456 if (!isTraceLoaded() || isDownloadAndShareDisabled()) return; 457 458 const engine = Object.values(globals.state.engines)[0]; 459 if (!engine) return; 460 let url = ''; 461 let fileName = 'trace.pftrace'; 462 const src = engine.source; 463 if (src.type === 'URL') { 464 url = src.url; 465 fileName = url.split('/').slice(-1)[0]; 466 } else if (src.type === 'ARRAY_BUFFER') { 467 const blob = new Blob([src.buffer], {type: 'application/octet-stream'}); 468 url = URL.createObjectURL(blob); 469 } else if (src.type === 'FILE') { 470 const file = src.file; 471 url = URL.createObjectURL(file); 472 fileName = file.name; 473 } else { 474 throw new Error(`Download from ${JSON.stringify(src)} is not supported`); 475 } 476 477 const a = document.createElement('a'); 478 a.href = url; 479 a.download = fileName; 480 document.body.appendChild(a); 481 a.click(); 482 document.body.removeChild(a); 483 URL.revokeObjectURL(url); 484} 485 486 487const EngineRPCWidget: m.Component = { 488 view() { 489 let cssClass = ''; 490 let title = 'Number of pending SQL queries'; 491 let label: string; 492 let failed = false; 493 let mode: EngineMode|undefined; 494 495 // We are assuming we have at most one engine here. 496 const engines = Object.values(globals.state.engines); 497 assertTrue(engines.length <= 1); 498 for (const engine of engines) { 499 mode = engine.mode; 500 if (engine.failed !== undefined) { 501 cssClass += '.red'; 502 title = 'Query engine crashed\n' + engine.failed; 503 failed = true; 504 } 505 } 506 507 // If we don't have an engine yet, guess what will be the mode that will 508 // be used next time we'll create one. Even if we guess it wrong (somehow 509 // trace_controller.ts takes a different decision later, e.g. because the 510 // RPC server is shut down after we load the UI and cached httpRpcState) 511 // this will eventually become consistent once the engine is created. 512 if (mode === undefined) { 513 if (globals.frontendLocalState.httpRpcState.connected && 514 globals.state.newEngineMode === 'USE_HTTP_RPC_IF_AVAILABLE') { 515 mode = 'HTTP_RPC'; 516 } else { 517 mode = 'WASM'; 518 } 519 } 520 521 if (mode === 'HTTP_RPC') { 522 cssClass += '.green'; 523 label = 'RPC'; 524 title += '\n(Query engine: native accelerator over HTTP+RPC)'; 525 } else { 526 label = 'WSM'; 527 title += '\n(Query engine: built-in WASM)'; 528 } 529 530 return m( 531 `.dbg-info-square${cssClass}`, 532 {title}, 533 m('div', label), 534 m('div', `${failed ? 'FAIL' : globals.numQueuedQueries}`)); 535 } 536}; 537 538const ServiceWorkerWidget: m.Component = { 539 view() { 540 let cssClass = ''; 541 let title = 'Service Worker: '; 542 let label = 'N/A'; 543 const ctl = globals.serviceWorkerController; 544 if ((!('serviceWorker' in navigator))) { 545 label = 'N/A'; 546 title += 'not supported by the browser (requires HTTPS)'; 547 } else if (ctl.bypassed) { 548 label = 'OFF'; 549 cssClass = '.red'; 550 title += 'Bypassed, using live network. Double-click to re-enable'; 551 } else if (ctl.installing) { 552 label = 'UPD'; 553 cssClass = '.amber'; 554 title += 'Installing / updating ...'; 555 } else if (!navigator.serviceWorker.controller) { 556 label = 'N/A'; 557 title += 'Not available, using network'; 558 } else { 559 label = 'ON'; 560 cssClass = '.green'; 561 title += 'Serving from cache. Ready for offline use'; 562 } 563 564 const toggle = async () => { 565 if (globals.serviceWorkerController.bypassed) { 566 globals.serviceWorkerController.setBypass(false); 567 return; 568 } 569 showModal({ 570 title: 'Disable service worker?', 571 content: m( 572 'div', 573 m('p', `If you continue the service worker will be disabled until 574 manually re-enabled.`), 575 m('p', `All future requests will be served from the network and the 576 UI won't be available offline.`), 577 m('p', `You should do this only if you are debugging the UI 578 or if you are experiencing caching-related problems.`), 579 m('p', `Disabling will cause a refresh of the UI, the current state 580 will be lost.`), 581 ), 582 buttons: [ 583 { 584 text: 'Disable and reload', 585 primary: true, 586 id: 'sw-bypass-enable', 587 action: () => { 588 globals.serviceWorkerController.setBypass(true).then( 589 () => location.reload()); 590 } 591 }, 592 { 593 text: 'Cancel', 594 primary: false, 595 id: 'sw-bypass-cancel', 596 action: () => {} 597 } 598 ] 599 }); 600 }; 601 602 return m( 603 `.dbg-info-square${cssClass}`, 604 {title, ondblclick: toggle}, 605 m('div', 'SW'), 606 m('div', label)); 607 } 608}; 609 610const SidebarFooter: m.Component = { 611 view() { 612 return m( 613 '.sidebar-footer', 614 m('button', 615 { 616 onclick: () => globals.frontendLocalState.togglePerfDebug(), 617 }, 618 m('i.material-icons', 619 {title: 'Toggle Perf Debug Mode'}, 620 'assessment')), 621 m(EngineRPCWidget), 622 m(ServiceWorkerWidget), 623 ); 624 } 625}; 626 627 628export class Sidebar implements m.ClassComponent { 629 private _redrawWhileAnimating = 630 new Animation(() => globals.rafScheduler.scheduleFullRedraw()); 631 view() { 632 const vdomSections = []; 633 for (const section of SECTIONS) { 634 if (section.hideIfNoTraceLoaded && !isTraceLoaded()) continue; 635 const vdomItems = []; 636 for (const item of section.items) { 637 let attrs = { 638 onclick: typeof item.a === 'function' ? item.a : null, 639 href: typeof item.a === 'string' ? item.a : '#', 640 target: typeof item.a === 'string' ? '_blank' : null, 641 disabled: false, 642 }; 643 if ((item as {internalUserOnly: boolean}).internalUserOnly === true) { 644 if (!globals.isInternalUser) continue; 645 } 646 if (isDownloadAndShareDisabled() && 647 item.hasOwnProperty('checkDownloadDisabled')) { 648 attrs = { 649 onclick: () => alert('Can not download or share external trace.'), 650 href: '#', 651 target: null, 652 disabled: true, 653 }; 654 } 655 vdomItems.push( 656 m('li', m('a', attrs, m('i.material-icons', item.i), item.t))); 657 } 658 if (section.appendOpenedTraceTitle) { 659 const engines = Object.values(globals.state.engines); 660 if (engines.length === 1) { 661 let traceTitle = ''; 662 switch (engines[0].source.type) { 663 case 'FILE': 664 // Split on both \ and / (because C:\Windows\paths\are\like\this). 665 traceTitle = engines[0].source.file.name.split(/[/\\]/).pop()!; 666 const fileSizeMB = Math.ceil(engines[0].source.file.size / 1e6); 667 traceTitle += ` (${fileSizeMB} MB)`; 668 break; 669 case 'URL': 670 traceTitle = engines[0].source.url.split('/').pop()!; 671 break; 672 case 'ARRAY_BUFFER': 673 traceTitle = engines[0].source.title; 674 break; 675 case 'HTTP_RPC': 676 traceTitle = 'External trace (RPC)'; 677 break; 678 default: 679 break; 680 } 681 if (traceTitle !== '') { 682 const tabTitle = `${traceTitle} - Perfetto UI`; 683 if (tabTitle !== lastTabTitle) { 684 document.title = lastTabTitle = tabTitle; 685 } 686 vdomItems.unshift(m('li', m('a.trace-file-name', traceTitle))); 687 } 688 } 689 } 690 vdomSections.push( 691 m(`section${section.expanded ? '.expanded' : ''}`, 692 m('.section-header', 693 { 694 onclick: () => { 695 section.expanded = !section.expanded; 696 globals.rafScheduler.scheduleFullRedraw(); 697 } 698 }, 699 m('h1', {title: section.summary}, section.title), 700 m('h2', section.summary)), 701 m('.section-content', m('ul', vdomItems)))); 702 } 703 if (globals.state.videoEnabled) { 704 const videoVdomItems = []; 705 for (const item of vidSection.items) { 706 videoVdomItems.push( 707 m('li', 708 m(`a`, 709 { 710 onclick: typeof item.a === 'function' ? item.a : null, 711 href: typeof item.a === 'string' ? item.a : '#', 712 }, 713 m('i.material-icons', item.i), 714 item.t))); 715 } 716 vdomSections.push( 717 m(`section${vidSection.expanded ? '.expanded' : ''}`, 718 m('.section-header', 719 { 720 onclick: () => { 721 vidSection.expanded = !vidSection.expanded; 722 globals.rafScheduler.scheduleFullRedraw(); 723 } 724 }, 725 m('h1', vidSection.title), 726 m('h2', vidSection.summary), ), 727 m('.section-content', m('ul', videoVdomItems)))); 728 } 729 return m( 730 'nav.sidebar', 731 { 732 class: globals.frontendLocalState.sidebarVisible ? 'show-sidebar' : 733 'hide-sidebar', 734 // 150 here matches --sidebar-timing in the css. 735 ontransitionstart: () => this._redrawWhileAnimating.start(150), 736 ontransitionend: () => this._redrawWhileAnimating.stop(), 737 }, 738 m( 739 'header', 740 m('img[src=assets/brand.png].brand'), 741 m('button.sidebar-button', 742 { 743 onclick: () => { 744 globals.frontendLocalState.toggleSidebar(); 745 }, 746 }, 747 m('i.material-icons', 748 { 749 title: globals.frontendLocalState.sidebarVisible ? 750 'Hide menu' : 751 'Show menu', 752 }, 753 'menu')), 754 ), 755 m('input[type=file]', {onchange: onInputElementFileSelectionChanged}), 756 m('.sidebar-scroll', 757 m( 758 '.sidebar-scroll-container', 759 ...vdomSections, 760 m(SidebarFooter), 761 )), 762 ); 763 } 764} 765