xref: /aosp_15_r20/external/perfetto/ui/src/frontend/ui_main.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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