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