• 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 * as m from 'mithril';
16
17import {assertTrue} from '../base/logging';
18import {Actions} from '../common/actions';
19import {QueryResponse} from '../common/queries';
20import {EngineMode} from '../common/state';
21
22import {Animation} from './animation';
23import {globals} from './globals';
24import {toggleHelp} from './help_modal';
25import {
26  isLegacyTrace,
27  openFileWithLegacyTraceViewer,
28} from './legacy_trace_viewer';
29import {showModal} from './modal';
30
31const ALL_PROCESSES_QUERY = 'select name, pid from process order by name;';
32
33const CPU_TIME_FOR_PROCESSES = `
34select
35  process.name,
36  tot_proc/1e9 as cpu_sec
37from
38  (select
39    upid,
40    sum(tot_thd) as tot_proc
41  from
42    (select
43      utid,
44      sum(dur) as tot_thd
45    from sched group by utid)
46  join thread using(utid) group by upid)
47join process using(upid)
48order by cpu_sec desc limit 100;`;
49
50const CYCLES_PER_P_STATE_PER_CPU = `
51select
52  cpu,
53  freq,
54  dur,
55  sum(dur * freq)/1e6 as mcycles
56from (
57  select
58    cpu,
59    value as freq,
60    lead(ts) over (partition by cpu order by ts) - ts as dur
61  from counter
62  inner join cpu_counter_track on counter.track_id = cpu_counter_track.id
63  where name = 'cpufreq'
64) group by cpu, freq
65order by mcycles desc limit 32;`;
66
67const CPU_TIME_BY_CLUSTER_BY_PROCESS = `
68select process.name as process, thread, core, cpu_sec from (
69  select thread.name as thread, upid,
70    case when cpug = 0 then 'little' else 'big' end as core,
71    cpu_sec from (select cpu/4 as cpug, utid, sum(dur)/1e9 as cpu_sec
72    from sched group by utid, cpug order by cpu_sec desc
73  ) inner join thread using(utid)
74) inner join process using(upid) limit 30;`;
75
76const HEAP_GRAPH_BYTES_PER_TYPE = `
77select
78  upid,
79  graph_sample_ts,
80  type_name,
81  sum(self_size) as total_self_size
82from heap_graph_object
83group by
84 upid,
85 graph_sample_ts,
86 type_name
87order by total_self_size desc
88limit 100;`;
89
90const SQL_STATS = `
91with first as (select started as ts from sqlstats limit 1)
92select query,
93    round((max(ended - started, 0))/1e6) as runtime_ms,
94    round((max(started - queued, 0))/1e6) as latency_ms,
95    round((started - first.ts)/1e6) as t_start_ms
96from sqlstats, first
97order by started desc`;
98
99const TRACE_STATS = 'select * from stats order by severity, source, name, idx';
100
101let lastTabTitle = '';
102
103function createCannedQuery(query: string): (_: Event) => void {
104  return (e: Event) => {
105    e.preventDefault();
106    globals.dispatch(Actions.executeQuery({
107      engineId: '0',
108      queryId: 'command',
109      query,
110    }));
111  };
112}
113
114const EXAMPLE_ANDROID_TRACE_URL =
115    'https://storage.googleapis.com/perfetto-misc/example_android_trace_15s';
116
117const EXAMPLE_CHROME_TRACE_URL =
118    'https://storage.googleapis.com/perfetto-misc/example_chrome_trace_4s_1.json';
119
120const SECTIONS = [
121  {
122    title: 'Navigation',
123    summary: 'Open or record a new trace',
124    expanded: true,
125    items: [
126      {t: 'Open trace file', a: popupFileSelectionDialog, i: 'folder_open'},
127      {
128        t: 'Open with legacy UI',
129        a: popupFileSelectionDialogOldUI,
130        i: 'filter_none'
131      },
132      {t: 'Record new trace', a: navigateRecord, i: 'fiber_smart_record'},
133    ],
134  },
135  {
136    title: 'Current Trace',
137    summary: 'Actions on the current trace',
138    expanded: true,
139    hideIfNoTraceLoaded: true,
140    appendOpenedTraceTitle: true,
141    items: [
142      {t: 'Show timeline', a: navigateViewer, i: 'line_style'},
143      {
144        t: 'Share',
145        a: dispatchCreatePermalink,
146        i: 'share',
147        checkDownloadDisabled: true,
148        internalUserOnly: true,
149      },
150      {
151        t: 'Download',
152        a: downloadTrace,
153        i: 'file_download',
154        checkDownloadDisabled: true,
155      },
156      {t: 'Legacy UI', a: openCurrentTraceWithOldUI, i: 'filter_none'},
157      {t: 'Analyze', a: navigateAnalyze, i: 'control_camera'},
158    ],
159  },
160  {
161    title: 'Example Traces',
162    expanded: true,
163    summary: 'Open an example trace',
164    items: [
165      {
166        t: 'Open Android example',
167        a: openTraceUrl(EXAMPLE_ANDROID_TRACE_URL),
168        i: 'description'
169      },
170      {
171        t: 'Open Chrome example',
172        a: openTraceUrl(EXAMPLE_CHROME_TRACE_URL),
173        i: 'description'
174      },
175    ],
176  },
177  {
178    title: 'Metrics and auditors',
179    summary: 'Compute summary statistics',
180    items: [
181      {
182        t: 'All Processes',
183        a: createCannedQuery(ALL_PROCESSES_QUERY),
184        i: 'search',
185      },
186      {
187        t: 'CPU Time by process',
188        a: createCannedQuery(CPU_TIME_FOR_PROCESSES),
189        i: 'search',
190      },
191      {
192        t: 'Cycles by p-state by CPU',
193        a: createCannedQuery(CYCLES_PER_P_STATE_PER_CPU),
194        i: 'search',
195      },
196      {
197        t: 'CPU Time by cluster by process',
198        a: createCannedQuery(CPU_TIME_BY_CLUSTER_BY_PROCESS),
199        i: 'search',
200      },
201      {
202        t: 'Heap Graph: Bytes per type',
203        a: createCannedQuery(HEAP_GRAPH_BYTES_PER_TYPE),
204        i: 'search',
205      },
206      {
207        t: 'Trace stats',
208        a: createCannedQuery(TRACE_STATS),
209        i: 'bug_report',
210      },
211      {
212        t: 'Debug SQL performance',
213        a: createCannedQuery(SQL_STATS),
214        i: 'bug_report',
215      },
216    ],
217  },
218  {
219    title: 'Support',
220    summary: 'Documentation & Bugs',
221    items: [
222      {
223        t: 'Controls',
224        a: openHelp,
225        i: 'help',
226      },
227      {
228        t: 'Documentation',
229        a: 'https://perfetto.dev',
230        i: 'find_in_page',
231      },
232      {
233        t: 'Report a bug',
234        a: 'https://goto.google.com/perfetto-ui-bug',
235        i: 'bug_report',
236      },
237    ],
238  },
239
240];
241
242const vidSection = {
243  title: 'Video',
244  summary: 'Open a screen recording',
245  expanded: true,
246  items: [
247    {t: 'Open video file', a: popupVideoSelectionDialog, i: 'folder_open'},
248  ],
249};
250
251function openHelp(e: Event) {
252  e.preventDefault();
253  toggleHelp();
254}
255
256function getFileElement(): HTMLInputElement {
257  return document.querySelector('input[type=file]')! as HTMLInputElement;
258}
259
260function popupFileSelectionDialog(e: Event) {
261  e.preventDefault();
262  delete getFileElement().dataset['useCatapultLegacyUi'];
263  delete getFileElement().dataset['video'];
264  getFileElement().click();
265}
266
267function popupFileSelectionDialogOldUI(e: Event) {
268  e.preventDefault();
269  delete getFileElement().dataset['video'];
270  getFileElement().dataset['useCatapultLegacyUi'] = '1';
271  getFileElement().click();
272}
273
274function openCurrentTraceWithOldUI(e: Event) {
275  e.preventDefault();
276  console.assert(isTraceLoaded());
277  if (!isTraceLoaded) return;
278  const engine = Object.values(globals.state.engines)[0];
279  const src = engine.source;
280  if (src.type === 'ARRAY_BUFFER') {
281    openInOldUIWithSizeCheck(new Blob([src.buffer]));
282  } else if (src.type === 'FILE') {
283    openInOldUIWithSizeCheck(src.file);
284  } else {
285    throw new Error('Loading from a URL to catapult is not yet supported');
286    // TODO(nicomazz): Find how to get the data of the current trace if it is
287    // from a URL. It seems that the trace downloaded is given to the trace
288    // processor, but not kept somewhere accessible. Maybe the only way is to
289    // download the trace (again), and then open it. An alternative can be to
290    // save a copy.
291  }
292}
293
294function isTraceLoaded(): boolean {
295  const engine = Object.values(globals.state.engines)[0];
296  return engine !== undefined;
297}
298
299function popupVideoSelectionDialog(e: Event) {
300  e.preventDefault();
301  delete getFileElement().dataset['useCatapultLegacyUi'];
302  getFileElement().dataset['video'] = '1';
303  getFileElement().click();
304}
305
306function openTraceUrl(url: string): (e: Event) => void {
307  return e => {
308    e.preventDefault();
309    globals.frontendLocalState.localOnlyMode = false;
310    globals.dispatch(Actions.openTraceFromUrl({url}));
311  };
312}
313
314function onInputElementFileSelectionChanged(e: Event) {
315  if (!(e.target instanceof HTMLInputElement)) {
316    throw new Error('Not an input element');
317  }
318  if (!e.target.files) return;
319  const file = e.target.files[0];
320  // Reset the value so onchange will be fired with the same file.
321  e.target.value = '';
322
323  globals.frontendLocalState.localOnlyMode = false;
324
325  if (e.target.dataset['useCatapultLegacyUi'] === '1') {
326    openWithLegacyUi(file);
327    return;
328  }
329
330  if (e.target.dataset['video'] === '1') {
331    // TODO(hjd): Update this to use a controller and publish.
332    globals.dispatch(Actions.executeQuery({
333      engineId: '0', queryId: 'command',
334      query: `select ts from slices where name = 'first_frame' union ` +
335             `select start_ts from trace_bounds`}));
336    setTimeout(() => {
337      const resp = globals.queryResults.get('command') as QueryResponse;
338      // First value is screenrecord trace event timestamp
339      // and second value is trace boundary's start timestamp
340      const offset = (Number(resp.rows[1]['ts'].toString()) -
341                      Number(resp.rows[0]['ts'].toString())) /
342          1e9;
343      globals.queryResults.delete('command');
344      globals.rafScheduler.scheduleFullRedraw();
345      globals.dispatch(Actions.deleteQuery({queryId: 'command'}));
346      globals.dispatch(Actions.setVideoOffset({offset}));
347    }, 1000);
348    globals.dispatch(Actions.openVideoFromFile({file}));
349    return;
350  }
351
352  globals.dispatch(Actions.openTraceFromFile({file}));
353}
354
355async function openWithLegacyUi(file: File) {
356  // Switch back to the old catapult UI.
357  if (await isLegacyTrace(file)) {
358    openFileWithLegacyTraceViewer(file);
359    return;
360  }
361  openInOldUIWithSizeCheck(file);
362}
363
364function openInOldUIWithSizeCheck(trace: Blob) {
365  // Perfetto traces smaller than 50mb can be safely opened in the legacy UI.
366  if (trace.size < 1024 * 1024 * 50) {
367    globals.dispatch(Actions.convertTraceToJson({file: trace}));
368    return;
369  }
370
371  // Give the user the option to truncate larger perfetto traces.
372  const size = Math.round(trace.size / (1024 * 1024));
373  showModal({
374    title: 'Legacy UI may fail to open this trace',
375    content:
376        m('div',
377          m('p',
378            `This trace is ${size}mb, opening it in the legacy UI ` +
379                `may fail.`),
380          m('p',
381            'More options can be found at ',
382            m('a',
383              {
384                href: 'https://goto.google.com/opening-large-traces',
385                target: '_blank'
386              },
387              'go/opening-large-traces'),
388            '.')),
389    buttons: [
390      {
391        text: 'Open full trace (not recommended)',
392        primary: false,
393        id: 'open',
394        action: () => {
395          globals.dispatch(Actions.convertTraceToJson({file: trace}));
396        }
397      },
398      {
399        text: 'Open beginning of trace',
400        primary: true,
401        id: 'truncate-start',
402        action: () => {
403          globals.dispatch(
404              Actions.convertTraceToJson({file: trace, truncate: 'start'}));
405        }
406      },
407      {
408        text: 'Open end of trace',
409        primary: true,
410        id: 'truncate-end',
411        action: () => {
412          globals.dispatch(
413              Actions.convertTraceToJson({file: trace, truncate: 'end'}));
414        }
415      }
416
417    ]
418  });
419  return;
420}
421
422function navigateRecord(e: Event) {
423  e.preventDefault();
424  globals.dispatch(Actions.navigate({route: '/record'}));
425}
426
427function navigateAnalyze(e: Event) {
428  e.preventDefault();
429  globals.dispatch(Actions.navigate({route: '/analyze'}));
430}
431
432function navigateViewer(e: Event) {
433  e.preventDefault();
434  globals.dispatch(Actions.navigate({route: '/viewer'}));
435}
436
437function isDownloadAndShareDisabled(): boolean {
438  if (globals.frontendLocalState.localOnlyMode) return true;
439  const engine = Object.values(globals.state.engines)[0];
440  if (engine && engine.source.type === 'HTTP_RPC') return true;
441  return false;
442}
443
444function dispatchCreatePermalink(e: Event) {
445  e.preventDefault();
446  if (isDownloadAndShareDisabled() || !isTraceLoaded()) return;
447
448  const result = confirm(
449      `Upload the trace and generate a permalink. ` +
450      `The trace will be accessible by anybody with the permalink.`);
451  if (result) globals.dispatch(Actions.createPermalink({}));
452}
453
454function downloadTrace(e: Event) {
455  e.preventDefault();
456  if (!isTraceLoaded() || isDownloadAndShareDisabled()) return;
457
458  const engine = Object.values(globals.state.engines)[0];
459  if (!engine) return;
460  let url = '';
461  let fileName = 'trace.pftrace';
462  const src = engine.source;
463  if (src.type === 'URL') {
464    url = src.url;
465    fileName = url.split('/').slice(-1)[0];
466  } else if (src.type === 'ARRAY_BUFFER') {
467    const blob = new Blob([src.buffer], {type: 'application/octet-stream'});
468    url = URL.createObjectURL(blob);
469  } else if (src.type === 'FILE') {
470    const file = src.file;
471    url = URL.createObjectURL(file);
472    fileName = file.name;
473  } else {
474    throw new Error(`Download from ${JSON.stringify(src)} is not supported`);
475  }
476
477  const a = document.createElement('a');
478  a.href = url;
479  a.download = fileName;
480  document.body.appendChild(a);
481  a.click();
482  document.body.removeChild(a);
483  URL.revokeObjectURL(url);
484}
485
486
487const EngineRPCWidget: m.Component = {
488  view() {
489    let cssClass = '';
490    let title = 'Number of pending SQL queries';
491    let label: string;
492    let failed = false;
493    let mode: EngineMode|undefined;
494
495    // We are assuming we have at most one engine here.
496    const engines = Object.values(globals.state.engines);
497    assertTrue(engines.length <= 1);
498    for (const engine of engines) {
499      mode = engine.mode;
500      if (engine.failed !== undefined) {
501        cssClass += '.red';
502        title = 'Query engine crashed\n' + engine.failed;
503        failed = true;
504      }
505    }
506
507    // If we don't have an engine yet, guess what will be the mode that will
508    // be used next time we'll create one. Even if we guess it wrong (somehow
509    // trace_controller.ts takes a different decision later, e.g. because the
510    // RPC server is shut down after we load the UI and cached httpRpcState)
511    // this will eventually become  consistent once the engine is created.
512    if (mode === undefined) {
513      if (globals.frontendLocalState.httpRpcState.connected &&
514          globals.state.newEngineMode === 'USE_HTTP_RPC_IF_AVAILABLE') {
515        mode = 'HTTP_RPC';
516      } else {
517        mode = 'WASM';
518      }
519    }
520
521    if (mode === 'HTTP_RPC') {
522      cssClass += '.green';
523      label = 'RPC';
524      title += '\n(Query engine: native accelerator over HTTP+RPC)';
525    } else {
526      label = 'WSM';
527      title += '\n(Query engine: built-in WASM)';
528    }
529
530    return m(
531        `.dbg-info-square${cssClass}`,
532        {title},
533        m('div', label),
534        m('div', `${failed ? 'FAIL' : globals.numQueuedQueries}`));
535  }
536};
537
538const ServiceWorkerWidget: m.Component = {
539  view() {
540    let cssClass = '';
541    let title = 'Service Worker: ';
542    let label = 'N/A';
543    const ctl = globals.serviceWorkerController;
544    if ((!('serviceWorker' in navigator))) {
545      label = 'N/A';
546      title += 'not supported by the browser (requires HTTPS)';
547    } else if (ctl.bypassed) {
548      label = 'OFF';
549      cssClass = '.red';
550      title += 'Bypassed, using live network. Double-click to re-enable';
551    } else if (ctl.installing) {
552      label = 'UPD';
553      cssClass = '.amber';
554      title += 'Installing / updating ...';
555    } else if (!navigator.serviceWorker.controller) {
556      label = 'N/A';
557      title += 'Not available, using network';
558    } else {
559      label = 'ON';
560      cssClass = '.green';
561      title += 'Serving from cache. Ready for offline use';
562    }
563
564    const toggle = async () => {
565      if (globals.serviceWorkerController.bypassed) {
566        globals.serviceWorkerController.setBypass(false);
567        return;
568      }
569      showModal({
570        title: 'Disable service worker?',
571        content: m(
572            'div',
573            m('p', `If you continue the service worker will be disabled until
574                      manually re-enabled.`),
575            m('p', `All future requests will be served from the network and the
576                    UI won't be available offline.`),
577            m('p', `You should do this only if you are debugging the UI
578                    or if you are experiencing caching-related problems.`),
579            m('p', `Disabling will cause a refresh of the UI, the current state
580                    will be lost.`),
581            ),
582        buttons: [
583          {
584            text: 'Disable and reload',
585            primary: true,
586            id: 'sw-bypass-enable',
587            action: () => {
588              globals.serviceWorkerController.setBypass(true).then(
589                  () => location.reload());
590            }
591          },
592          {
593            text: 'Cancel',
594            primary: false,
595            id: 'sw-bypass-cancel',
596            action: () => {}
597          }
598        ]
599      });
600    };
601
602    return m(
603        `.dbg-info-square${cssClass}`,
604        {title, ondblclick: toggle},
605        m('div', 'SW'),
606        m('div', label));
607  }
608};
609
610const SidebarFooter: m.Component = {
611  view() {
612    return m(
613        '.sidebar-footer',
614        m('button',
615          {
616            onclick: () => globals.frontendLocalState.togglePerfDebug(),
617          },
618          m('i.material-icons',
619            {title: 'Toggle Perf Debug Mode'},
620            'assessment')),
621        m(EngineRPCWidget),
622        m(ServiceWorkerWidget),
623    );
624  }
625};
626
627
628export class Sidebar implements m.ClassComponent {
629  private _redrawWhileAnimating =
630      new Animation(() => globals.rafScheduler.scheduleFullRedraw());
631  view() {
632    const vdomSections = [];
633    for (const section of SECTIONS) {
634      if (section.hideIfNoTraceLoaded && !isTraceLoaded()) continue;
635      const vdomItems = [];
636      for (const item of section.items) {
637        let attrs = {
638          onclick: typeof item.a === 'function' ? item.a : null,
639          href: typeof item.a === 'string' ? item.a : '#',
640          target: typeof item.a === 'string' ? '_blank' : null,
641          disabled: false,
642        };
643        if ((item as {internalUserOnly: boolean}).internalUserOnly === true) {
644          if (!globals.isInternalUser) continue;
645        }
646        if (isDownloadAndShareDisabled() &&
647            item.hasOwnProperty('checkDownloadDisabled')) {
648          attrs = {
649            onclick: () => alert('Can not download or share external trace.'),
650            href: '#',
651            target: null,
652            disabled: true,
653          };
654        }
655        vdomItems.push(
656            m('li', m('a', attrs, m('i.material-icons', item.i), item.t)));
657      }
658      if (section.appendOpenedTraceTitle) {
659        const engines = Object.values(globals.state.engines);
660        if (engines.length === 1) {
661          let traceTitle = '';
662          switch (engines[0].source.type) {
663            case 'FILE':
664              // Split on both \ and / (because C:\Windows\paths\are\like\this).
665              traceTitle = engines[0].source.file.name.split(/[/\\]/).pop()!;
666              const fileSizeMB = Math.ceil(engines[0].source.file.size / 1e6);
667              traceTitle += ` (${fileSizeMB} MB)`;
668              break;
669            case 'URL':
670              traceTitle = engines[0].source.url.split('/').pop()!;
671              break;
672            case 'ARRAY_BUFFER':
673              traceTitle = engines[0].source.title;
674              break;
675            case 'HTTP_RPC':
676              traceTitle = 'External trace (RPC)';
677              break;
678            default:
679              break;
680          }
681          if (traceTitle !== '') {
682            const tabTitle = `${traceTitle} - Perfetto UI`;
683            if (tabTitle !== lastTabTitle) {
684              document.title = lastTabTitle = tabTitle;
685            }
686            vdomItems.unshift(m('li', m('a.trace-file-name', traceTitle)));
687          }
688        }
689      }
690      vdomSections.push(
691          m(`section${section.expanded ? '.expanded' : ''}`,
692            m('.section-header',
693              {
694                onclick: () => {
695                  section.expanded = !section.expanded;
696                  globals.rafScheduler.scheduleFullRedraw();
697                }
698              },
699              m('h1', {title: section.summary}, section.title),
700              m('h2', section.summary)),
701            m('.section-content', m('ul', vdomItems))));
702    }
703    if (globals.state.videoEnabled) {
704      const videoVdomItems = [];
705      for (const item of vidSection.items) {
706        videoVdomItems.push(
707          m('li',
708            m(`a`,
709              {
710                onclick: typeof item.a === 'function' ? item.a : null,
711                href: typeof item.a === 'string' ? item.a : '#',
712              },
713              m('i.material-icons', item.i),
714              item.t)));
715      }
716      vdomSections.push(
717        m(`section${vidSection.expanded ? '.expanded' : ''}`,
718          m('.section-header',
719            {
720              onclick: () => {
721                vidSection.expanded = !vidSection.expanded;
722                globals.rafScheduler.scheduleFullRedraw();
723              }
724            },
725            m('h1', vidSection.title),
726            m('h2', vidSection.summary), ),
727          m('.section-content', m('ul', videoVdomItems))));
728    }
729    return m(
730        'nav.sidebar',
731        {
732          class: globals.frontendLocalState.sidebarVisible ? 'show-sidebar' :
733                                                             'hide-sidebar',
734          // 150 here matches --sidebar-timing in the css.
735          ontransitionstart: () => this._redrawWhileAnimating.start(150),
736          ontransitionend: () => this._redrawWhileAnimating.stop(),
737        },
738        m(
739            'header',
740            m('img[src=assets/brand.png].brand'),
741            m('button.sidebar-button',
742              {
743                onclick: () => {
744                  globals.frontendLocalState.toggleSidebar();
745                },
746              },
747              m('i.material-icons',
748                {
749                  title: globals.frontendLocalState.sidebarVisible ?
750                      'Hide menu' :
751                      'Show menu',
752                },
753                'menu')),
754            ),
755        m('input[type=file]', {onchange: onInputElementFileSelectionChanged}),
756        m('.sidebar-scroll',
757          m(
758              '.sidebar-scroll-container',
759              ...vdomSections,
760              m(SidebarFooter),
761              )),
762    );
763  }
764}
765