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