1// Copyright (C) 2023 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 {copyToClipboard} from '../base/clipboard'; 17import {findRef} from '../base/dom_utils'; 18import {FuzzyFinder} from '../base/fuzzy'; 19import {assertExists, assertUnreachable} from '../base/logging'; 20import {undoCommonChatAppReplacements} from '../base/string_utils'; 21import { 22 setDurationPrecision, 23 setTimestampFormat, 24} from '../core/timestamp_format'; 25import {raf} from '../core/raf_scheduler'; 26import {Command} from '../public/command'; 27import {HotkeyConfig, HotkeyContext} from '../widgets/hotkey_context'; 28import {HotkeyGlyphs} from '../widgets/hotkey_glyphs'; 29import {maybeRenderFullscreenModalDialog, showModal} from '../widgets/modal'; 30import {CookieConsent} from '../core/cookie_consent'; 31import {toggleHelp} from './help_modal'; 32import {Omnibox, OmniboxOption} from './omnibox'; 33import {addQueryResultsTab} from '../components/query_table/query_result_tab'; 34import {Sidebar} from './sidebar'; 35import {Topbar} from './topbar'; 36import {shareTrace} from './trace_share_utils'; 37import {AggregationsTabs} from './aggregation_tab'; 38import {OmniboxMode} from '../core/omnibox_manager'; 39import {DisposableStack} from '../base/disposable_stack'; 40import {Spinner} from '../widgets/spinner'; 41import {TraceImpl} from '../core/trace_impl'; 42import {AppImpl} from '../core/app_impl'; 43import {NotesEditorTab} from './notes_panel'; 44import {NotesListEditor} from './notes_list_editor'; 45import {getTimeSpanOfSelectionOrVisibleWindow} from '../public/utils'; 46import {DurationPrecision, TimestampFormat} from '../public/timeline'; 47 48const OMNIBOX_INPUT_REF = 'omnibox'; 49 50// This wrapper creates a new instance of UiMainPerTrace for each new trace 51// loaded (including the case of no trace at the beginning). 52export class UiMain implements m.ClassComponent { 53 view() { 54 const currentTraceId = AppImpl.instance.trace?.engine.engineId ?? ''; 55 return [m(UiMainPerTrace, {key: currentTraceId})]; 56 } 57} 58 59// This components gets destroyed and recreated every time the current trace 60// changes. Note that in the beginning the current trace is undefined. 61export class UiMainPerTrace implements m.ClassComponent { 62 // NOTE: this should NOT need to be an AsyncDisposableStack. If you feel the 63 // need of making it async because you want to clean up SQL resources, that 64 // will cause bugs (see comments in oncreate()). 65 private trash = new DisposableStack(); 66 private omniboxInputEl?: HTMLInputElement; 67 private recentCommands: string[] = []; 68 private trace?: TraceImpl; 69 70 // This function is invoked once per trace. 71 constructor() { 72 const app = AppImpl.instance; 73 const trace = app.trace; 74 this.trace = trace; 75 76 // Register global commands (commands that are useful even without a trace 77 // loaded). 78 const globalCmds: Command[] = [ 79 { 80 id: 'perfetto.OpenCommandPalette', 81 name: 'Open command palette', 82 callback: () => app.omnibox.setMode(OmniboxMode.Command), 83 defaultHotkey: '!Mod+Shift+P', 84 }, 85 86 { 87 id: 'perfetto.ShowHelp', 88 name: 'Show help', 89 callback: () => toggleHelp(), 90 defaultHotkey: '?', 91 }, 92 ]; 93 globalCmds.forEach((cmd) => { 94 this.trash.use(app.commands.registerCommand(cmd)); 95 }); 96 97 // When the UI loads there is no trace. There is no point registering 98 // commands or anything in this state as they will be useless. 99 if (trace === undefined) return; 100 document.title = `${trace.traceInfo.traceTitle || 'Trace'} - Perfetto UI`; 101 this.maybeShowJsonWarning(); 102 103 // Register the aggregation tabs. 104 this.trash.use(new AggregationsTabs(trace)); 105 106 // Register the notes manager+editor. 107 this.trash.use(trace.tabs.registerDetailsPanel(new NotesEditorTab(trace))); 108 109 this.trash.use( 110 trace.tabs.registerTab({ 111 uri: 'notes.manager', 112 isEphemeral: false, 113 content: { 114 getTitle: () => 'Notes & markers', 115 render: () => m(NotesListEditor, {trace}), 116 }, 117 }), 118 ); 119 120 const cmds: Command[] = [ 121 { 122 id: 'perfetto.SetTimestampFormat', 123 name: 'Set timestamp and duration format', 124 callback: async () => { 125 const TF = TimestampFormat; 126 const result = await app.omnibox.prompt('Select format...', { 127 values: [ 128 {format: TF.Timecode, name: 'Timecode'}, 129 {format: TF.UTC, name: 'Realtime (UTC)'}, 130 {format: TF.TraceTz, name: 'Realtime (Trace TZ)'}, 131 {format: TF.Seconds, name: 'Seconds'}, 132 {format: TF.Milliseconds, name: 'Milliseconds'}, 133 {format: TF.Microseconds, name: 'Microseconds'}, 134 {format: TF.TraceNs, name: 'Trace nanoseconds'}, 135 { 136 format: TF.TraceNsLocale, 137 name: 'Trace nanoseconds (with locale-specific formatting)', 138 }, 139 ], 140 getName: (x) => x.name, 141 }); 142 result && setTimestampFormat(result.format); 143 raf.scheduleFullRedraw(); 144 }, 145 }, 146 { 147 id: 'perfetto.SetDurationPrecision', 148 name: 'Set duration precision', 149 callback: async () => { 150 const DF = DurationPrecision; 151 const result = await app.omnibox.prompt( 152 'Select duration precision mode...', 153 { 154 values: [ 155 {format: DF.Full, name: 'Full'}, 156 {format: DF.HumanReadable, name: 'Human readable'}, 157 ], 158 getName: (x) => x.name, 159 }, 160 ); 161 result && setDurationPrecision(result.format); 162 raf.scheduleFullRedraw(); 163 }, 164 }, 165 { 166 id: 'perfetto.TogglePerformanceMetrics', 167 name: 'Toggle performance metrics', 168 callback: () => 169 (app.perfDebugging.enabled = !app.perfDebugging.enabled), 170 }, 171 { 172 id: 'perfetto.ShareTrace', 173 name: 'Share trace', 174 callback: shareTrace, 175 }, 176 { 177 id: 'perfetto.SearchNext', 178 name: 'Go to next search result', 179 callback: () => { 180 trace.search.stepForward(); 181 }, 182 defaultHotkey: 'Enter', 183 }, 184 { 185 id: 'perfetto.SearchPrev', 186 name: 'Go to previous search result', 187 callback: () => { 188 trace.search.stepBackwards(); 189 }, 190 defaultHotkey: 'Shift+Enter', 191 }, 192 { 193 id: 'perfetto.RunQuery', 194 name: 'Run query', 195 callback: () => trace.omnibox.setMode(OmniboxMode.Query), 196 }, 197 { 198 id: 'perfetto.Search', 199 name: 'Search', 200 callback: () => trace.omnibox.setMode(OmniboxMode.Search), 201 defaultHotkey: '/', 202 }, 203 { 204 id: 'perfetto.CopyTimeWindow', 205 name: `Copy selected time window to clipboard`, 206 callback: async () => { 207 const window = await getTimeSpanOfSelectionOrVisibleWindow(trace); 208 const query = `ts >= ${window.start} and ts < ${window.end}`; 209 copyToClipboard(query); 210 }, 211 }, 212 { 213 id: 'perfetto.FocusSelection', 214 name: 'Focus current selection', 215 callback: () => trace.selection.scrollToCurrentSelection(), 216 defaultHotkey: 'F', 217 }, 218 { 219 id: 'perfetto.Deselect', 220 name: 'Deselect', 221 callback: () => { 222 trace.selection.clear(); 223 }, 224 defaultHotkey: 'Escape', 225 }, 226 { 227 id: 'perfetto.SetTemporarySpanNote', 228 name: 'Set the temporary span note based on the current selection', 229 callback: () => { 230 const range = trace.selection.findTimeRangeOfSelection(); 231 if (range) { 232 trace.notes.addSpanNote({ 233 start: range.start, 234 end: range.end, 235 id: '__temp__', 236 }); 237 238 // Also select an area for this span 239 const selection = trace.selection.selection; 240 if (selection.kind === 'track_event') { 241 trace.selection.selectArea({ 242 start: range.start, 243 end: range.end, 244 trackUris: [selection.trackUri], 245 }); 246 } 247 } 248 }, 249 defaultHotkey: 'M', 250 }, 251 { 252 id: 'perfetto.AddSpanNote', 253 name: 'Add a new span note based on the current selection', 254 callback: () => { 255 const range = trace.selection.findTimeRangeOfSelection(); 256 if (range) { 257 trace.notes.addSpanNote({ 258 start: range.start, 259 end: range.end, 260 }); 261 } 262 }, 263 defaultHotkey: 'Shift+M', 264 }, 265 { 266 id: 'perfetto.RemoveSelectedNote', 267 name: 'Remove selected note', 268 callback: () => { 269 const selection = trace.selection.selection; 270 if (selection.kind === 'note') { 271 trace.notes.removeNote(selection.id); 272 } 273 }, 274 defaultHotkey: 'Delete', 275 }, 276 { 277 id: 'perfetto.NextFlow', 278 name: 'Next flow', 279 callback: () => trace.flows.focusOtherFlow('Forward'), 280 defaultHotkey: 'Mod+]', 281 }, 282 { 283 id: 'perfetto.PrevFlow', 284 name: 'Prev flow', 285 callback: () => trace.flows.focusOtherFlow('Backward'), 286 defaultHotkey: 'Mod+[', 287 }, 288 { 289 id: 'perfetto.MoveNextFlow', 290 name: 'Move next flow', 291 callback: () => trace.flows.moveByFocusedFlow('Forward'), 292 defaultHotkey: ']', 293 }, 294 { 295 id: 'perfetto.MovePrevFlow', 296 name: 'Move prev flow', 297 callback: () => trace.flows.moveByFocusedFlow('Backward'), 298 defaultHotkey: '[', 299 }, 300 { 301 id: 'perfetto.SelectAll', 302 name: 'Select all', 303 callback: () => { 304 // This is a dual state command: 305 // - If one ore more tracks are already area selected, expand the time 306 // range to include the entire trace, but keep the selection on just 307 // these tracks. 308 // - If nothing is selected, or all selected tracks are entirely 309 // selected, then select the entire trace. This allows double tapping 310 // Ctrl+A to select the entire track, then select the entire trace. 311 let tracksToSelect: string[]; 312 const selection = trace.selection.selection; 313 if (selection.kind === 'area') { 314 // Something is already selected, let's see if it covers the entire 315 // span of the trace or not 316 const coversEntireTimeRange = 317 trace.traceInfo.start === selection.start && 318 trace.traceInfo.end === selection.end; 319 if (!coversEntireTimeRange) { 320 // If the current selection is an area which does not cover the 321 // entire time range, preserve the list of selected tracks and 322 // expand the time range. 323 tracksToSelect = selection.trackUris; 324 } else { 325 // If the entire time range is already covered, update the selection 326 // to cover all tracks. 327 tracksToSelect = trace.workspace.flatTracks 328 .map((t) => t.uri) 329 .filter((uri) => uri !== undefined); 330 } 331 } else { 332 // If the current selection is not an area, select all. 333 tracksToSelect = trace.workspace.flatTracks 334 .map((t) => t.uri) 335 .filter((uri) => uri !== undefined); 336 } 337 const {start, end} = trace.traceInfo; 338 trace.selection.selectArea({ 339 start, 340 end, 341 trackUris: tracksToSelect, 342 }); 343 }, 344 defaultHotkey: 'Mod+A', 345 }, 346 { 347 id: 'perfetto.ConvertSelectionToArea', 348 name: 'Convert the current selection to an area selection', 349 callback: () => { 350 const selection = trace.selection.selection; 351 const range = trace.selection.findTimeRangeOfSelection(); 352 if (selection.kind === 'track_event' && range) { 353 trace.selection.selectArea({ 354 start: range.start, 355 end: range.end, 356 trackUris: [selection.trackUri], 357 }); 358 } 359 }, 360 // TODO(stevegolton): Decide on a sensible hotkey. 361 // defaultHotkey: 'L', 362 }, 363 { 364 id: 'perfetto.ToggleDrawer', 365 name: 'Toggle drawer', 366 defaultHotkey: 'Q', 367 callback: () => trace.tabs.toggleTabPanelVisibility(), 368 }, 369 ]; 370 371 // Register each command with the command manager 372 cmds.forEach((cmd) => { 373 this.trash.use(trace.commands.registerCommand(cmd)); 374 }); 375 } 376 377 private renderOmnibox(): m.Children { 378 const omnibox = AppImpl.instance.omnibox; 379 const omniboxMode = omnibox.mode; 380 const statusMessage = omnibox.statusMessage; 381 if (statusMessage !== undefined) { 382 return m( 383 `.omnibox.message-mode`, 384 m(`input[readonly][disabled][ref=omnibox]`, { 385 value: '', 386 placeholder: statusMessage, 387 }), 388 ); 389 } else if (omniboxMode === OmniboxMode.Command) { 390 return this.renderCommandOmnibox(); 391 } else if (omniboxMode === OmniboxMode.Prompt) { 392 return this.renderPromptOmnibox(); 393 } else if (omniboxMode === OmniboxMode.Query) { 394 return this.renderQueryOmnibox(); 395 } else if (omniboxMode === OmniboxMode.Search) { 396 return this.renderSearchOmnibox(); 397 } else { 398 assertUnreachable(omniboxMode); 399 } 400 } 401 402 renderPromptOmnibox(): m.Children { 403 const omnibox = AppImpl.instance.omnibox; 404 const prompt = assertExists(omnibox.pendingPrompt); 405 406 let options: OmniboxOption[] | undefined = undefined; 407 408 if (prompt.options) { 409 const fuzzy = new FuzzyFinder( 410 prompt.options, 411 ({displayName}) => displayName, 412 ); 413 const result = fuzzy.find(omnibox.text); 414 options = result.map((result) => { 415 return { 416 key: result.item.key, 417 displayName: result.segments, 418 }; 419 }); 420 } 421 422 return m(Omnibox, { 423 value: omnibox.text, 424 placeholder: prompt.text, 425 inputRef: OMNIBOX_INPUT_REF, 426 extraClasses: 'prompt-mode', 427 closeOnOutsideClick: true, 428 options, 429 selectedOptionIndex: omnibox.selectionIndex, 430 onSelectedOptionChanged: (index) => { 431 omnibox.setSelectionIndex(index); 432 raf.scheduleFullRedraw(); 433 }, 434 onInput: (value) => { 435 omnibox.setText(value); 436 omnibox.setSelectionIndex(0); 437 raf.scheduleFullRedraw(); 438 }, 439 onSubmit: (value, _alt) => { 440 omnibox.resolvePrompt(value); 441 }, 442 onClose: () => { 443 omnibox.rejectPrompt(); 444 }, 445 }); 446 } 447 448 renderCommandOmnibox(): m.Children { 449 // Fuzzy-filter commands by the filter string. 450 const {commands, omnibox} = AppImpl.instance; 451 const filteredCmds = commands.fuzzyFilterCommands(omnibox.text); 452 453 // Create an array of commands with attached heuristics from the recent 454 // command register. 455 const commandsWithHeuristics = filteredCmds.map((cmd) => { 456 return { 457 recentsIndex: this.recentCommands.findIndex((id) => id === cmd.id), 458 cmd, 459 }; 460 }); 461 462 // Sort recentsIndex first 463 const sorted = commandsWithHeuristics.sort((a, b) => { 464 if (b.recentsIndex === a.recentsIndex) { 465 // If recentsIndex is the same, retain original sort order 466 return 0; 467 } else { 468 return b.recentsIndex - a.recentsIndex; 469 } 470 }); 471 472 const options = sorted.map(({recentsIndex, cmd}): OmniboxOption => { 473 const {segments, id, defaultHotkey} = cmd; 474 return { 475 key: id, 476 displayName: segments, 477 tag: recentsIndex !== -1 ? 'recently used' : undefined, 478 rightContent: defaultHotkey && m(HotkeyGlyphs, {hotkey: defaultHotkey}), 479 }; 480 }); 481 482 return m(Omnibox, { 483 value: omnibox.text, 484 placeholder: 'Filter commands...', 485 inputRef: OMNIBOX_INPUT_REF, 486 extraClasses: 'command-mode', 487 options, 488 closeOnSubmit: true, 489 closeOnOutsideClick: true, 490 selectedOptionIndex: omnibox.selectionIndex, 491 onSelectedOptionChanged: (index) => { 492 omnibox.setSelectionIndex(index); 493 raf.scheduleFullRedraw(); 494 }, 495 onInput: (value) => { 496 omnibox.setText(value); 497 omnibox.setSelectionIndex(0); 498 raf.scheduleFullRedraw(); 499 }, 500 onClose: () => { 501 if (this.omniboxInputEl) { 502 this.omniboxInputEl.blur(); 503 } 504 omnibox.reset(); 505 }, 506 onSubmit: (key: string) => { 507 this.addRecentCommand(key); 508 commands.runCommand(key); 509 }, 510 onGoBack: () => { 511 omnibox.reset(); 512 }, 513 }); 514 } 515 516 private addRecentCommand(id: string): void { 517 this.recentCommands = this.recentCommands.filter((x) => x !== id); 518 this.recentCommands.push(id); 519 while (this.recentCommands.length > 6) { 520 this.recentCommands.shift(); 521 } 522 } 523 524 renderQueryOmnibox(): m.Children { 525 const ph = 'e.g. select * from sched left join thread using(utid) limit 10'; 526 return m(Omnibox, { 527 value: AppImpl.instance.omnibox.text, 528 placeholder: ph, 529 inputRef: OMNIBOX_INPUT_REF, 530 extraClasses: 'query-mode', 531 532 onInput: (value) => { 533 AppImpl.instance.omnibox.setText(value); 534 raf.scheduleFullRedraw(); 535 }, 536 onSubmit: (query, alt) => { 537 const config = { 538 query: undoCommonChatAppReplacements(query), 539 title: alt ? 'Pinned query' : 'Omnibox query', 540 }; 541 const tag = alt ? undefined : 'omnibox_query'; 542 const trace = AppImpl.instance.trace; 543 if (trace === undefined) return; // No trace loaded 544 addQueryResultsTab(trace, config, tag); 545 }, 546 onClose: () => { 547 AppImpl.instance.omnibox.setText(''); 548 if (this.omniboxInputEl) { 549 this.omniboxInputEl.blur(); 550 } 551 AppImpl.instance.omnibox.reset(); 552 raf.scheduleFullRedraw(); 553 }, 554 onGoBack: () => { 555 AppImpl.instance.omnibox.reset(); 556 }, 557 }); 558 } 559 560 renderSearchOmnibox(): m.Children { 561 return m(Omnibox, { 562 value: AppImpl.instance.omnibox.text, 563 placeholder: "Search or type '>' for commands or ':' for SQL mode", 564 inputRef: OMNIBOX_INPUT_REF, 565 onInput: (value, _prev) => { 566 if (value === '>') { 567 AppImpl.instance.omnibox.setMode(OmniboxMode.Command); 568 return; 569 } else if (value === ':') { 570 AppImpl.instance.omnibox.setMode(OmniboxMode.Query); 571 return; 572 } 573 AppImpl.instance.omnibox.setText(value); 574 if (this.trace === undefined) return; // No trace loaded. 575 if (value.length >= 4) { 576 this.trace.search.search(value); 577 } else { 578 this.trace.search.reset(); 579 } 580 }, 581 onClose: () => { 582 if (this.omniboxInputEl) { 583 this.omniboxInputEl.blur(); 584 } 585 }, 586 onSubmit: (value, _mod, shift) => { 587 if (this.trace === undefined) return; // No trace loaded. 588 this.trace.search.search(value); 589 if (shift) { 590 this.trace.search.stepBackwards(); 591 } else { 592 this.trace.search.stepForward(); 593 } 594 if (this.omniboxInputEl) { 595 this.omniboxInputEl.blur(); 596 } 597 }, 598 rightContent: this.renderStepThrough(), 599 }); 600 } 601 602 private renderStepThrough() { 603 const children = []; 604 const results = this.trace?.search.searchResults; 605 if (this.trace?.search.searchInProgress) { 606 children.push(m('.current', m(Spinner))); 607 } else if (results !== undefined) { 608 const searchMgr = assertExists(this.trace).search; 609 const index = searchMgr.resultIndex; 610 const total = results.totalResults ?? 0; 611 children.push( 612 m('.current', `${total === 0 ? '0 / 0' : `${index + 1} / ${total}`}`), 613 m( 614 'button', 615 { 616 onclick: () => searchMgr.stepBackwards(), 617 }, 618 m('i.material-icons.left', 'keyboard_arrow_left'), 619 ), 620 m( 621 'button', 622 { 623 onclick: () => searchMgr.stepForward(), 624 }, 625 m('i.material-icons.right', 'keyboard_arrow_right'), 626 ), 627 ); 628 } 629 return m('.stepthrough', children); 630 } 631 632 oncreate(vnode: m.VnodeDOM) { 633 this.updateOmniboxInputRef(vnode.dom); 634 this.maybeFocusOmnibar(); 635 } 636 637 view(): m.Children { 638 const app = AppImpl.instance; 639 const hotkeys: HotkeyConfig[] = []; 640 for (const {id, defaultHotkey} of app.commands.commands) { 641 if (defaultHotkey) { 642 hotkeys.push({ 643 callback: () => app.commands.runCommand(id), 644 hotkey: defaultHotkey, 645 }); 646 } 647 } 648 649 return m( 650 HotkeyContext, 651 {hotkeys}, 652 m( 653 'main', 654 m(Sidebar, {trace: this.trace}), 655 m(Topbar, { 656 omnibox: this.renderOmnibox(), 657 trace: this.trace, 658 }), 659 app.pages.renderPageForCurrentRoute(app.trace), 660 m(CookieConsent), 661 maybeRenderFullscreenModalDialog(), 662 app.perfDebugging.renderPerfStats(), 663 ), 664 ); 665 } 666 667 onupdate({dom}: m.VnodeDOM) { 668 this.updateOmniboxInputRef(dom); 669 this.maybeFocusOmnibar(); 670 } 671 672 onremove(_: m.VnodeDOM) { 673 this.omniboxInputEl = undefined; 674 675 // NOTE: if this becomes ever an asyncDispose(), then the promise needs to 676 // be returned to onbeforeremove, so mithril delays the removal until 677 // the promise is resolved, but then also the UiMain wrapper needs to be 678 // more complex to linearize the destruction of the old instane with the 679 // creation of the new one, without overlaps. 680 // However, we should not add disposables that issue cleanup queries on the 681 // Engine. Doing so is: (1) useless: we throw away the whole wasm instance 682 // on each trace load, so what's the point of deleting tables from a TP 683 // instance that is going to be destroyed?; (2) harmful: we don't have 684 // precise linearization with the wasm teardown, so we might end up awaiting 685 // forever for the asyncDispose() because the query will never run. 686 this.trash.dispose(); 687 } 688 689 private updateOmniboxInputRef(dom: Element): void { 690 const el = findRef(dom, OMNIBOX_INPUT_REF); 691 if (el && el instanceof HTMLInputElement) { 692 this.omniboxInputEl = el; 693 } 694 } 695 696 private maybeFocusOmnibar() { 697 if (AppImpl.instance.omnibox.focusOmniboxNextRender) { 698 const omniboxEl = this.omniboxInputEl; 699 if (omniboxEl) { 700 omniboxEl.focus(); 701 if (AppImpl.instance.omnibox.pendingCursorPlacement === undefined) { 702 omniboxEl.select(); 703 } else { 704 omniboxEl.setSelectionRange( 705 AppImpl.instance.omnibox.pendingCursorPlacement, 706 AppImpl.instance.omnibox.pendingCursorPlacement, 707 ); 708 } 709 } 710 AppImpl.instance.omnibox.clearFocusFlag(); 711 } 712 } 713 714 private async maybeShowJsonWarning() { 715 // Show warning if the trace is in JSON format. 716 const isJsonTrace = this.trace?.traceInfo.traceType === 'json'; 717 const SHOWN_JSON_WARNING_KEY = 'shownJsonWarning'; 718 719 if ( 720 !isJsonTrace || 721 window.localStorage.getItem(SHOWN_JSON_WARNING_KEY) === 'true' || 722 AppImpl.instance.embeddedMode 723 ) { 724 // When in embedded mode, the host app will control which trace format 725 // it passes to Perfetto, so we don't need to show this warning. 726 return; 727 } 728 729 // Save that the warning has been shown. Value is irrelevant since only 730 // the presence of key is going to be checked. 731 window.localStorage.setItem(SHOWN_JSON_WARNING_KEY, 'true'); 732 733 showModal({ 734 title: 'Warning', 735 content: m( 736 'div', 737 m( 738 'span', 739 'Perfetto UI features are limited for JSON traces. ', 740 'We recommend recording ', 741 m( 742 'a', 743 {href: 'https://perfetto.dev/docs/quickstart/chrome-tracing'}, 744 'proto-format traces', 745 ), 746 ' from Chrome.', 747 ), 748 m('br'), 749 ), 750 buttons: [], 751 }); 752 } 753} 754