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