• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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