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 m from 'mithril'; 16import {getCurrentChannel} from '../core/channels'; 17import {TRACE_SUFFIX} from '../public/trace'; 18import { 19 disableMetatracingAndGetTrace, 20 enableMetatracing, 21 isMetatracingEnabled, 22} from '../core/metatracing'; 23import {Engine, EngineMode} from '../trace_processor/engine'; 24import {featureFlags} from '../core/feature_flags'; 25import {raf} from '../core/raf_scheduler'; 26import {SCM_REVISION, VERSION} from '../gen/perfetto_version'; 27import {showModal} from '../widgets/modal'; 28import {Animation} from './animation'; 29import {downloadData, downloadUrl} from '../base/download_utils'; 30import {globals} from './globals'; 31import {toggleHelp} from './help_modal'; 32import {shareTrace} from './trace_share_utils'; 33import { 34 convertTraceToJsonAndDownload, 35 convertTraceToSystraceAndDownload, 36} from './trace_converter'; 37import {openInOldUIWithSizeCheck} from './legacy_trace_viewer'; 38import {SIDEBAR_SECTIONS, SidebarSections} from '../public/sidebar'; 39import {AppImpl} from '../core/app_impl'; 40import {Trace} from '../public/trace'; 41import {OptionalTraceImplAttrs, TraceImpl} from '../core/trace_impl'; 42import {Command} from '../public/command'; 43import {SidebarMenuItemInternal} from '../core/sidebar_manager'; 44import {exists, getOrCreate} from '../base/utils'; 45import {copyToClipboard} from '../base/clipboard'; 46import {classNames} from '../base/classnames'; 47import {formatHotkey} from '../base/hotkeys'; 48import {assetSrc} from '../base/assets'; 49 50const GITILES_URL = 51 'https://android.googlesource.com/platform/external/perfetto'; 52 53function getBugReportUrl(): string { 54 if (globals.isInternalUser) { 55 return 'https://goto.google.com/perfetto-ui-bug'; 56 } else { 57 return 'https://github.com/google/perfetto/issues/new'; 58 } 59} 60 61const HIRING_BANNER_FLAG = featureFlags.register({ 62 id: 'showHiringBanner', 63 name: 'Show hiring banner', 64 description: 'Show the "We\'re hiring" banner link in the side bar.', 65 defaultValue: false, 66}); 67 68function shouldShowHiringBanner(): boolean { 69 return globals.isInternalUser && HIRING_BANNER_FLAG.get(); 70} 71 72async function openCurrentTraceWithOldUI(trace: Trace): Promise<void> { 73 AppImpl.instance.analytics.logEvent( 74 'Trace Actions', 75 'Open current trace in legacy UI', 76 ); 77 const file = await trace.getTraceFile(); 78 await openInOldUIWithSizeCheck(file); 79} 80 81async function convertTraceToSystrace(trace: Trace): Promise<void> { 82 AppImpl.instance.analytics.logEvent('Trace Actions', 'Convert to .systrace'); 83 const file = await trace.getTraceFile(); 84 await convertTraceToSystraceAndDownload(file); 85} 86 87async function convertTraceToJson(trace: Trace): Promise<void> { 88 AppImpl.instance.analytics.logEvent('Trace Actions', 'Convert to .json'); 89 const file = await trace.getTraceFile(); 90 await convertTraceToJsonAndDownload(file); 91} 92 93function downloadTrace(trace: TraceImpl) { 94 if (!trace.traceInfo.downloadable) return; 95 AppImpl.instance.analytics.logEvent('Trace Actions', 'Download trace'); 96 97 let url = ''; 98 let fileName = `trace${TRACE_SUFFIX}`; 99 const src = trace.traceInfo.source; 100 if (src.type === 'URL') { 101 url = src.url; 102 fileName = url.split('/').slice(-1)[0]; 103 } else if (src.type === 'ARRAY_BUFFER') { 104 const blob = new Blob([src.buffer], {type: 'application/octet-stream'}); 105 const inputFileName = window.prompt( 106 'Please enter a name for your file or leave blank', 107 ); 108 if (inputFileName) { 109 fileName = `${inputFileName}.perfetto_trace.gz`; 110 } else if (src.fileName) { 111 fileName = src.fileName; 112 } 113 url = URL.createObjectURL(blob); 114 } else if (src.type === 'FILE') { 115 const file = src.file; 116 url = URL.createObjectURL(file); 117 fileName = file.name; 118 } else { 119 throw new Error(`Download from ${JSON.stringify(src)} is not supported`); 120 } 121 downloadUrl(fileName, url); 122} 123 124function highPrecisionTimersAvailable(): boolean { 125 // High precision timers are available either when the page is cross-origin 126 // isolated or when the trace processor is a standalone binary. 127 return ( 128 window.crossOriginIsolated || 129 AppImpl.instance.trace?.engine.mode === 'HTTP_RPC' 130 ); 131} 132 133function recordMetatrace(engine: Engine) { 134 AppImpl.instance.analytics.logEvent('Trace Actions', 'Record metatrace'); 135 136 if (!highPrecisionTimersAvailable()) { 137 const PROMPT = `High-precision timers are not available to WASM trace processor yet. 138 139Modern browsers restrict high-precision timers to cross-origin-isolated pages. 140As Perfetto UI needs to open traces via postMessage, it can't be cross-origin 141isolated until browsers ship support for 142'Cross-origin-opener-policy: restrict-properties'. 143 144Do you still want to record a metatrace? 145Note that events under timer precision (1ms) will dropped. 146Alternatively, connect to a trace_processor_shell --httpd instance. 147`; 148 showModal({ 149 title: `Trace processor doesn't have high-precision timers`, 150 content: m('.modal-pre', PROMPT), 151 buttons: [ 152 { 153 text: 'YES, record metatrace', 154 primary: true, 155 action: () => { 156 enableMetatracing(); 157 engine.enableMetatrace(); 158 }, 159 }, 160 { 161 text: 'NO, cancel', 162 }, 163 ], 164 }); 165 } else { 166 engine.enableMetatrace(); 167 } 168} 169 170async function toggleMetatrace(e: Engine) { 171 return isMetatracingEnabled() ? finaliseMetatrace(e) : recordMetatrace(e); 172} 173 174async function finaliseMetatrace(engine: Engine) { 175 AppImpl.instance.analytics.logEvent('Trace Actions', 'Finalise metatrace'); 176 177 const jsEvents = disableMetatracingAndGetTrace(); 178 179 const result = await engine.stopAndGetMetatrace(); 180 if (result.error.length !== 0) { 181 throw new Error(`Failed to read metatrace: ${result.error}`); 182 } 183 184 downloadData('metatrace', result.metatrace, jsEvents); 185} 186 187class EngineRPCWidget implements m.ClassComponent<OptionalTraceImplAttrs> { 188 view({attrs}: m.CVnode<OptionalTraceImplAttrs>) { 189 let cssClass = ''; 190 let title = 'Number of pending SQL queries'; 191 let label: string; 192 let failed = false; 193 let mode: EngineMode | undefined; 194 195 const engine = attrs.trace?.engine; 196 if (engine !== undefined) { 197 mode = engine.mode; 198 if (engine.failed !== undefined) { 199 cssClass += '.red'; 200 title = 'Query engine crashed\n' + engine.failed; 201 failed = true; 202 } 203 } 204 205 // If we don't have an engine yet, guess what will be the mode that will 206 // be used next time we'll create one. Even if we guess it wrong (somehow 207 // trace_controller.ts takes a different decision later, e.g. because the 208 // RPC server is shut down after we load the UI and cached httpRpcState) 209 // this will eventually become consistent once the engine is created. 210 if (mode === undefined) { 211 if ( 212 AppImpl.instance.httpRpc.httpRpcAvailable && 213 AppImpl.instance.httpRpc.newEngineMode === 'USE_HTTP_RPC_IF_AVAILABLE' 214 ) { 215 mode = 'HTTP_RPC'; 216 } else { 217 mode = 'WASM'; 218 } 219 } 220 221 if (mode === 'HTTP_RPC') { 222 cssClass += '.green'; 223 label = 'RPC'; 224 title += '\n(Query engine: native accelerator over HTTP+RPC)'; 225 } else { 226 label = 'WSM'; 227 title += '\n(Query engine: built-in WASM)'; 228 } 229 230 const numReqs = attrs.trace?.engine.numRequestsPending ?? 0; 231 return m( 232 `.dbg-info-square${cssClass}`, 233 {title}, 234 m('div', label), 235 m('div', `${failed ? 'FAIL' : numReqs}`), 236 ); 237 } 238} 239 240const ServiceWorkerWidget: m.Component = { 241 view() { 242 let cssClass = ''; 243 let title = 'Service Worker: '; 244 let label = 'N/A'; 245 const ctl = AppImpl.instance.serviceWorkerController; 246 if (!('serviceWorker' in navigator)) { 247 label = 'N/A'; 248 title += 'not supported by the browser (requires HTTPS)'; 249 } else if (ctl.bypassed) { 250 label = 'OFF'; 251 cssClass = '.red'; 252 title += 'Bypassed, using live network. Double-click to re-enable'; 253 } else if (ctl.installing) { 254 label = 'UPD'; 255 cssClass = '.amber'; 256 title += 'Installing / updating ...'; 257 } else if (!navigator.serviceWorker.controller) { 258 label = 'N/A'; 259 title += 'Not available, using network'; 260 } else { 261 label = 'ON'; 262 cssClass = '.green'; 263 title += 'Serving from cache. Ready for offline use'; 264 } 265 266 const toggle = async () => { 267 if (ctl.bypassed) { 268 ctl.setBypass(false); 269 return; 270 } 271 showModal({ 272 title: 'Disable service worker?', 273 content: m( 274 'div', 275 m( 276 'p', 277 `If you continue the service worker will be disabled until 278 manually re-enabled.`, 279 ), 280 m( 281 'p', 282 `All future requests will be served from the network and the 283 UI won't be available offline.`, 284 ), 285 m( 286 'p', 287 `You should do this only if you are debugging the UI 288 or if you are experiencing caching-related problems.`, 289 ), 290 m( 291 'p', 292 `Disabling will cause a refresh of the UI, the current state 293 will be lost.`, 294 ), 295 ), 296 buttons: [ 297 { 298 text: 'Disable and reload', 299 primary: true, 300 action: () => ctl.setBypass(true).then(() => location.reload()), 301 }, 302 {text: 'Cancel'}, 303 ], 304 }); 305 }; 306 307 return m( 308 `.dbg-info-square${cssClass}`, 309 {title, ondblclick: toggle}, 310 m('div', 'SW'), 311 m('div', label), 312 ); 313 }, 314}; 315 316class SidebarFooter implements m.ClassComponent<OptionalTraceImplAttrs> { 317 view({attrs}: m.CVnode<OptionalTraceImplAttrs>) { 318 return m( 319 '.sidebar-footer', 320 m(EngineRPCWidget, attrs), 321 m(ServiceWorkerWidget), 322 m( 323 '.version', 324 m( 325 'a', 326 { 327 href: `${GITILES_URL}/+/${SCM_REVISION}/ui`, 328 title: `Channel: ${getCurrentChannel()}`, 329 target: '_blank', 330 }, 331 VERSION, 332 ), 333 ), 334 ); 335 } 336} 337 338class HiringBanner implements m.ClassComponent { 339 view() { 340 return m( 341 '.hiring-banner', 342 m( 343 'a', 344 { 345 href: 'http://go/perfetto-open-roles', 346 target: '_blank', 347 }, 348 "We're hiring!", 349 ), 350 ); 351 } 352} 353 354export class Sidebar implements m.ClassComponent<OptionalTraceImplAttrs> { 355 private _redrawWhileAnimating = new Animation(() => 356 raf.scheduleFullRedraw('force'), 357 ); 358 private _asyncJobPending = new Set<string>(); 359 private _sectionExpanded = new Map<string, boolean>(); 360 361 constructor() { 362 registerMenuItems(); 363 } 364 365 view({attrs}: m.CVnode<OptionalTraceImplAttrs>) { 366 const sidebar = AppImpl.instance.sidebar; 367 if (!sidebar.enabled) return null; 368 return m( 369 'nav.sidebar', 370 { 371 class: sidebar.visible ? 'show-sidebar' : 'hide-sidebar', 372 // 150 here matches --sidebar-timing in the css. 373 // TODO(hjd): Should link to the CSS variable. 374 ontransitionstart: (e: TransitionEvent) => { 375 if (e.target !== e.currentTarget) return; 376 this._redrawWhileAnimating.start(150); 377 }, 378 ontransitionend: (e: TransitionEvent) => { 379 if (e.target !== e.currentTarget) return; 380 this._redrawWhileAnimating.stop(); 381 }, 382 }, 383 shouldShowHiringBanner() ? m(HiringBanner) : null, 384 m( 385 `header.${getCurrentChannel()}`, 386 m(`img[src=${assetSrc('assets/brand.png')}].brand`), 387 m( 388 'button.sidebar-button', 389 { 390 onclick: () => sidebar.toggleVisibility(), 391 }, 392 m( 393 'i.material-icons', 394 { 395 title: sidebar.visible ? 'Hide menu' : 'Show menu', 396 }, 397 'menu', 398 ), 399 ), 400 ), 401 m( 402 '.sidebar-scroll', 403 m( 404 '.sidebar-scroll-container', 405 ...(Object.keys(SIDEBAR_SECTIONS) as SidebarSections[]).map((s) => 406 this.renderSection(s), 407 ), 408 m(SidebarFooter, attrs), 409 ), 410 ), 411 ); 412 } 413 414 private renderSection(sectionId: SidebarSections) { 415 const section = SIDEBAR_SECTIONS[sectionId]; 416 const menuItems = AppImpl.instance.sidebar.menuItems 417 .valuesAsArray() 418 .filter((item) => item.section === sectionId) 419 .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) 420 .map((item) => this.renderItem(item)); 421 422 // Don't render empty sections. 423 if (menuItems.length === 0) return undefined; 424 425 const expanded = getOrCreate(this._sectionExpanded, sectionId, () => true); 426 return m( 427 `section${expanded ? '.expanded' : ''}`, 428 m( 429 '.section-header', 430 { 431 onclick: () => { 432 this._sectionExpanded.set(sectionId, !expanded); 433 raf.scheduleFullRedraw(); 434 }, 435 }, 436 m('h1', {title: section.title}, section.title), 437 m('h2', section.summary), 438 ), 439 m('.section-content', m('ul', menuItems)), 440 ); 441 } 442 443 private renderItem(item: SidebarMenuItemInternal): m.Child { 444 let href = '#'; 445 let disabled = false; 446 let target = null; 447 let command: Command | undefined = undefined; 448 let tooltip = valueOrCallback(item.tooltip); 449 let onclick: (() => unknown | Promise<unknown>) | undefined = undefined; 450 const commandId = 'commandId' in item ? item.commandId : undefined; 451 const action = 'action' in item ? item.action : undefined; 452 let text = valueOrCallback(item.text); 453 const disabReason: boolean | string | undefined = valueOrCallback( 454 item.disabled, 455 ); 456 457 if (disabReason === true || typeof disabReason === 'string') { 458 disabled = true; 459 onclick = () => typeof disabReason === 'string' && alert(disabReason); 460 } else if (action !== undefined) { 461 onclick = action; 462 } else if (commandId !== undefined) { 463 const cmdMgr = AppImpl.instance.commands; 464 command = cmdMgr.hasCommand(commandId ?? '') 465 ? cmdMgr.getCommand(commandId) 466 : undefined; 467 if (command === undefined) { 468 disabled = true; 469 } else { 470 text = text !== undefined ? text : command.name; 471 if (command.defaultHotkey !== undefined) { 472 tooltip = 473 `${tooltip ?? command.name}` + 474 ` [${formatHotkey(command.defaultHotkey)}]`; 475 } 476 onclick = () => cmdMgr.runCommand(commandId); 477 } 478 } 479 480 // This is not an else if because in some rare cases the user might want 481 // to have both an href and onclick, with different behaviors. The only case 482 // today is the trace name / URL, where we want the URL in the href to 483 // support right-click -> copy URL, but the onclick does copyToClipboard(). 484 if ('href' in item && item.href !== undefined) { 485 href = item.href; 486 target = href.startsWith('#') ? null : '_blank'; 487 } 488 return m( 489 'li', 490 m( 491 'a', 492 { 493 className: classNames( 494 valueOrCallback(item.cssClass), 495 this._asyncJobPending.has(item.id) && 'pending', 496 ), 497 onclick: onclick && this.wrapClickHandler(item.id, onclick), 498 href, 499 target, 500 disabled, 501 title: tooltip, 502 }, 503 exists(item.icon) && m('i.material-icons', valueOrCallback(item.icon)), 504 text, 505 ), 506 ); 507 } 508 509 // Creates the onClick handlers for the items which provided a function in the 510 // `action` member. The function can be either sync or async. 511 // What we want to achieve here is the following: 512 // - If the action is async (returns a Promise), we want to render a spinner, 513 // next to the menu item, until the promise is resolved. 514 // - [Minor] we want to call e.preventDefault() to override the behaviour of 515 // the <a href='#'> which gets rendered for accessibility reasons. 516 private wrapClickHandler(itemId: string, itemAction: Function) { 517 return (e: Event) => { 518 e.preventDefault(); // Make the <a href="#"> a no-op. 519 const res = itemAction(); 520 if (!(res instanceof Promise)) return; 521 if (this._asyncJobPending.has(itemId)) { 522 return; // Don't queue up another action if not yet finished. 523 } 524 this._asyncJobPending.add(itemId); 525 raf.scheduleFullRedraw(); 526 res.finally(() => { 527 this._asyncJobPending.delete(itemId); 528 raf.scheduleFullRedraw('force'); 529 }); 530 }; 531 } 532} 533 534// TODO(primiano): The registrations below should be moved to dedicated 535// plugins (most of this really belongs to core_plugins/commads/index.ts). 536// For now i'm keeping everything here as splitting these require moving some 537// functions like share_trace() out of core, splitting out permalink, etc. 538 539let globalItemsRegistered = false; 540const traceItemsRegistered = new WeakSet<TraceImpl>(); 541 542function registerMenuItems() { 543 if (!globalItemsRegistered) { 544 globalItemsRegistered = true; 545 registerGlobalSidebarEntries(); 546 } 547 const trace = AppImpl.instance.trace; 548 if (trace !== undefined && !traceItemsRegistered.has(trace)) { 549 traceItemsRegistered.add(trace); 550 registerTraceMenuItems(trace); 551 } 552} 553 554function registerGlobalSidebarEntries() { 555 const app = AppImpl.instance; 556 // TODO(primiano): The Open file / Open with legacy entries are registered by 557 // the 'perfetto.CoreCommands' plugins. Make things consistent. 558 app.sidebar.addMenuItem({ 559 section: 'support', 560 text: 'Keyboard shortcuts', 561 action: toggleHelp, 562 icon: 'help', 563 }); 564 app.sidebar.addMenuItem({ 565 section: 'support', 566 text: 'Documentation', 567 href: 'https://perfetto.dev/docs', 568 icon: 'find_in_page', 569 }); 570 app.sidebar.addMenuItem({ 571 section: 'support', 572 sortOrder: 4, 573 text: 'Report a bug', 574 href: getBugReportUrl(), 575 icon: 'bug_report', 576 }); 577} 578 579function registerTraceMenuItems(trace: TraceImpl) { 580 const downloadDisabled = trace.traceInfo.downloadable 581 ? false 582 : 'Cannot download external trace'; 583 584 const traceTitle = trace?.traceInfo.traceTitle; 585 traceTitle && 586 trace.sidebar.addMenuItem({ 587 section: 'current_trace', 588 text: traceTitle, 589 href: trace.traceInfo.traceUrl, 590 action: () => copyToClipboard(trace.traceInfo.traceUrl), 591 tooltip: 'Click to copy the URL', 592 cssClass: 'trace-file-name', 593 }); 594 trace.sidebar.addMenuItem({ 595 section: 'current_trace', 596 text: 'Show timeline', 597 href: '#!/viewer', 598 icon: 'line_style', 599 }); 600 globals.isInternalUser && 601 trace.sidebar.addMenuItem({ 602 section: 'current_trace', 603 text: 'Share', 604 action: async () => await shareTrace(trace), 605 icon: 'share', 606 }); 607 trace.sidebar.addMenuItem({ 608 section: 'current_trace', 609 text: 'Download', 610 action: () => downloadTrace(trace), 611 icon: 'file_download', 612 disabled: downloadDisabled, 613 }); 614 trace.sidebar.addMenuItem({ 615 section: 'convert_trace', 616 text: 'Switch to legacy UI', 617 action: async () => await openCurrentTraceWithOldUI(trace), 618 icon: 'filter_none', 619 disabled: downloadDisabled, 620 }); 621 trace.sidebar.addMenuItem({ 622 section: 'convert_trace', 623 text: 'Convert to .json', 624 action: async () => await convertTraceToJson(trace), 625 icon: 'file_download', 626 disabled: downloadDisabled, 627 }); 628 trace.traceInfo.hasFtrace && 629 trace.sidebar.addMenuItem({ 630 section: 'convert_trace', 631 text: 'Convert to .systrace', 632 action: async () => await convertTraceToSystrace(trace), 633 icon: 'file_download', 634 disabled: downloadDisabled, 635 }); 636 trace.sidebar.addMenuItem({ 637 section: 'support', 638 sortOrder: 5, 639 text: () => 640 isMetatracingEnabled() ? 'Finalize metatrace' : 'Record metatrace', 641 action: () => toggleMetatrace(trace.engine), 642 icon: () => (isMetatracingEnabled() ? 'download' : 'fiber_smart_record'), 643 }); 644} 645 646// Used to deal with fields like the entry name, which can be either a direct 647// string or a callback that returns the string. 648function valueOrCallback<T>(value: T | (() => T)): T; 649function valueOrCallback<T>(value: T | (() => T) | undefined): T | undefined; 650function valueOrCallback<T>(value: T | (() => T) | undefined): T | undefined { 651 if (value === undefined) return undefined; 652 return value instanceof Function ? value() : value; 653} 654