• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2022 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 {Attributes} from 'mithril';
17
18import {assertExists} from '../base/logging';
19import {RecordingConfigUtils} from '../common/recordingV2/recording_config_utils';
20import {
21  ChromeTargetInfo,
22  RecordingTargetV2,
23  TargetInfo,
24} from '../common/recordingV2/recording_interfaces_v2';
25import {
26  RecordingPageController,
27  RecordingState,
28} from '../common/recordingV2/recording_page_controller';
29import {
30  EXTENSION_NAME,
31  EXTENSION_URL,
32} from '../common/recordingV2/recording_utils';
33import {targetFactoryRegistry} from '../common/recordingV2/target_factory_registry';
34import {raf} from '../core/raf_scheduler';
35
36import {globals} from './globals';
37import {createPage, PageAttrs} from './pages';
38import {recordConfigStore} from './record_config';
39import {
40  Configurations,
41  maybeGetActiveCss,
42  PERSIST_CONFIG_FLAG,
43  RECORDING_SECTIONS,
44} from './record_page';
45import {CodeSnippet} from './record_widgets';
46import {AdvancedSettings} from './recording/advanced_settings';
47import {AndroidSettings} from './recording/android_settings';
48import {ChromeSettings} from './recording/chrome_settings';
49import {CpuSettings} from './recording/cpu_settings';
50import {EtwSettings} from './recording/etw_settings';
51import {GpuSettings} from './recording/gpu_settings';
52import {LinuxPerfSettings} from './recording/linux_perf_settings';
53import {MemorySettings} from './recording/memory_settings';
54import {PowerSettings} from './recording/power_settings';
55import {RecordingSectionAttrs} from './recording/recording_sections';
56import {RecordingSettings} from './recording/recording_settings';
57import {FORCE_RESET_MESSAGE} from './recording/recording_ui_utils';
58import {showAddNewTargetModal} from './recording/reset_target_modal';
59import {createPermalink} from './permalink';
60
61const START_RECORDING_MESSAGE = 'Start Recording';
62
63const controller = new RecordingPageController();
64const recordConfigUtils = new RecordingConfigUtils();
65
66// Options for displaying a target selection menu.
67export interface TargetSelectionOptions {
68  // css attributes passed to the mithril components which displays the target
69  // selection menu.
70  attributes: Attributes;
71  // Whether the selection should be preceded by a text label.
72  shouldDisplayLabel: boolean;
73}
74
75function isChromeTargetInfo(
76  targetInfo: TargetInfo,
77): targetInfo is ChromeTargetInfo {
78  return ['CHROME', 'CHROME_OS'].includes(targetInfo.targetType);
79}
80
81function RecordHeader() {
82  const platformSelection = RecordingPlatformSelection();
83  const statusLabel = RecordingStatusLabel();
84  const buttons = RecordingButton();
85  const notes = RecordingNotes();
86  if (!platformSelection && !statusLabel && !buttons && !notes) {
87    // The header should not be displayed when it has no content.
88    return undefined;
89  }
90  return m(
91    '.record-header',
92    m(
93      '.top-part',
94      m('.target-and-status', platformSelection, statusLabel),
95      buttons,
96    ),
97    notes,
98  );
99}
100
101function RecordingPlatformSelection() {
102  // Don't show the platform selector while we are recording a trace.
103  if (controller.getState() >= RecordingState.RECORDING) return undefined;
104
105  return m(
106    '.target',
107    m(
108      '.chip',
109      {onclick: () => showAddNewTargetModal(controller)},
110      m('button', 'Add new recording target'),
111      m('i.material-icons', 'add'),
112    ),
113    targetSelection(),
114  );
115}
116
117export function targetSelection(): m.Vnode | undefined {
118  if (!controller.shouldShowTargetSelection()) {
119    return undefined;
120  }
121
122  const targets: RecordingTargetV2[] = targetFactoryRegistry.listTargets();
123  const targetNames = [];
124  const targetInfo = controller.getTargetInfo();
125  if (!targetInfo) {
126    targetNames.push(m('option', 'PLEASE_SELECT_TARGET'));
127  }
128
129  let selectedIndex = 0;
130  for (let i = 0; i < targets.length; i++) {
131    const targetName = targets[i].getInfo().name;
132    targetNames.push(m('option', targetName));
133    if (targetInfo && targetName === targetInfo.name) {
134      selectedIndex = i;
135    }
136  }
137
138  return m(
139    'label',
140    'Target platform:',
141    m(
142      'select',
143      {
144        selectedIndex,
145        onchange: (e: Event) => {
146          controller.onTargetSelection((e.target as HTMLSelectElement).value);
147        },
148        onupdate: (select) => {
149          // Work around mithril bug
150          // (https://github.com/MithrilJS/mithril.js/issues/2107): We may
151          // update the select's options while also changing the
152          // selectedIndex at the same time. The update of selectedIndex
153          // may be applied before the new options are added to the select
154          // element. Because the new selectedIndex may be outside of the
155          // select's options at that time, we have to reselect the
156          // correct index here after any new children were added.
157          (select.dom as HTMLSelectElement).selectedIndex = selectedIndex;
158        },
159      },
160      ...targetNames,
161    ),
162  );
163}
164
165// This will display status messages which are informative, but do not require
166// user action, such as: "Recording in progress for X seconds" in the recording
167// page header.
168function RecordingStatusLabel() {
169  const recordingStatus = globals.state.recordingStatus;
170  if (!recordingStatus) return undefined;
171  return m('label', recordingStatus);
172}
173
174function Instructions(cssClass: string) {
175  if (controller.getState() < RecordingState.TARGET_SELECTED) {
176    return undefined;
177  }
178  // We will have a valid target at this step because we checked the state.
179  const targetInfo = assertExists(controller.getTargetInfo());
180
181  return m(
182    `.record-section.instructions${cssClass}`,
183    m('header', 'Recording command'),
184    PERSIST_CONFIG_FLAG.get()
185      ? m(
186          'button.permalinkconfig',
187          {
188            onclick: () => {
189              createPermalink({isRecordingConfig: true});
190            },
191          },
192          'Share recording settings',
193        )
194      : null,
195    RecordingSnippet(targetInfo),
196    BufferUsageProgressBar(),
197    m('.buttons', StopCancelButtons()),
198  );
199}
200
201function BufferUsageProgressBar() {
202  // Show the Buffer Usage bar only after we start recording a trace.
203  if (controller.getState() !== RecordingState.RECORDING) {
204    return undefined;
205  }
206
207  controller.fetchBufferUsage();
208
209  const bufferUsage = controller.getBufferUsagePercentage();
210  // Buffer usage is not available yet on Android.
211  if (bufferUsage === 0) return undefined;
212
213  return m(
214    'label',
215    'Buffer usage: ',
216    m('progress', {max: 100, value: bufferUsage * 100}),
217  );
218}
219
220function RecordingNotes() {
221  if (controller.getState() !== RecordingState.TARGET_INFO_DISPLAYED) {
222    return undefined;
223  }
224  // We will have a valid target at this step because we checked the state.
225  const targetInfo = assertExists(controller.getTargetInfo());
226
227  const linuxUrl = 'https://perfetto.dev/docs/quickstart/linux-tracing';
228  const cmdlineUrl =
229    'https://perfetto.dev/docs/quickstart/android-tracing#perfetto-cmdline';
230
231  const notes: m.Children = [];
232
233  const msgFeatNotSupported = m(
234    'span',
235    `Some probes are only supported in Perfetto versions running
236      on Android Q+. Therefore, Perfetto will sideload the latest version onto
237      the device.`,
238  );
239
240  const msgPerfettoNotSupported = m(
241    'span',
242    `Perfetto is not supported natively before Android P. Therefore, Perfetto
243       will sideload the latest version onto the device.`,
244  );
245
246  const msgLinux = m(
247    '.note',
248    `Use this `,
249    m('a', {href: linuxUrl, target: '_blank'}, `quickstart guide`),
250    ` to get started with tracing on Linux.`,
251  );
252
253  const msgLongTraces = m(
254    '.note',
255    `Recording in long trace mode through the UI is not supported. Please copy
256    the command and `,
257    m(
258      'a',
259      {href: cmdlineUrl, target: '_blank'},
260      `collect the trace using ADB.`,
261    ),
262  );
263
264  if (
265    !recordConfigUtils.fetchLatestRecordCommand(
266      globals.state.recordConfig,
267      targetInfo,
268    ).hasDataSources
269  ) {
270    notes.push(
271      m(
272        '.note',
273        "It looks like you didn't add any probes. " +
274          'Please add at least one to get a non-empty trace.',
275      ),
276    );
277  }
278
279  targetFactoryRegistry.listRecordingProblems().map((recordingProblem) => {
280    if (recordingProblem.includes(EXTENSION_URL)) {
281      // Special case for rendering the link to the Chrome extension.
282      const parts = recordingProblem.split(EXTENSION_URL);
283      notes.push(
284        m(
285          '.note',
286          parts[0],
287          m('a', {href: EXTENSION_URL, target: '_blank'}, EXTENSION_NAME),
288          parts[1],
289        ),
290      );
291    }
292  });
293
294  switch (targetInfo.targetType) {
295    case 'LINUX':
296      notes.push(msgLinux);
297      break;
298    case 'ANDROID': {
299      const androidApiLevel = targetInfo.androidApiLevel;
300      if (androidApiLevel === 28) {
301        notes.push(m('.note', msgFeatNotSupported));
302        /* eslint-disable @typescript-eslint/strict-boolean-expressions */
303      } else if (androidApiLevel && androidApiLevel <= 27) {
304        /* eslint-enable */
305        notes.push(m('.note', msgPerfettoNotSupported));
306      }
307      break;
308    }
309    default:
310  }
311
312  if (globals.state.recordConfig.mode === 'LONG_TRACE') {
313    notes.unshift(msgLongTraces);
314  }
315
316  return notes.length > 0 ? m('div', notes) : undefined;
317}
318
319function RecordingSnippet(targetInfo: TargetInfo) {
320  // We don't need commands to start tracing on chrome
321  if (isChromeTargetInfo(targetInfo) || targetInfo.targetType === 'WINDOWS') {
322    if (controller.getState() > RecordingState.AUTH_P2) {
323      // If the UI has started tracing, don't display a message guiding the user
324      // to start recording.
325      return undefined;
326    }
327    return m(
328      'div',
329      m(
330        'label',
331        `To trace Chrome from the Perfetto UI you just have to press
332         '${START_RECORDING_MESSAGE}'.`,
333      ),
334    );
335  }
336  return m(CodeSnippet, {text: getRecordCommand(targetInfo)});
337}
338
339function getRecordCommand(targetInfo: TargetInfo): string {
340  const recordCommand = recordConfigUtils.fetchLatestRecordCommand(
341    globals.state.recordConfig,
342    targetInfo,
343  );
344
345  const pbBase64 = recordCommand?.configProtoBase64 ?? '';
346  const pbtx = recordCommand?.configProtoText ?? '';
347  let cmd = '';
348  if (
349    targetInfo.targetType === 'ANDROID' &&
350    targetInfo.androidApiLevel === 28
351  ) {
352    cmd += `echo '${pbBase64}' | \n`;
353    cmd += 'base64 --decode | \n';
354    cmd += 'adb shell "perfetto -c - -o /data/misc/perfetto-traces/trace"\n';
355  } else {
356    cmd +=
357      targetInfo.targetType === 'ANDROID'
358        ? 'adb shell perfetto \\\n'
359        : 'perfetto \\\n';
360    cmd += '  -c - --txt \\\n';
361    cmd += '  -o /data/misc/perfetto-traces/trace \\\n';
362    cmd += '<<EOF\n\n';
363    cmd += pbtx;
364    cmd += '\nEOF\n';
365  }
366  return cmd;
367}
368
369function RecordingButton() {
370  if (
371    controller.getState() !== RecordingState.TARGET_INFO_DISPLAYED ||
372    !controller.canCreateTracingSession()
373  ) {
374    return undefined;
375  }
376
377  // We know we have a target because we checked the state.
378  const targetInfo = assertExists(controller.getTargetInfo());
379  const hasDataSources = recordConfigUtils.fetchLatestRecordCommand(
380    globals.state.recordConfig,
381    targetInfo,
382  ).hasDataSources;
383  if (!hasDataSources) {
384    return undefined;
385  }
386
387  return m(
388    '.button',
389    m(
390      'button',
391      {
392        class: 'selected',
393        onclick: () => controller.onStartRecordingPressed(),
394      },
395      START_RECORDING_MESSAGE,
396    ),
397  );
398}
399
400function StopCancelButtons() {
401  // Show the Stop/Cancel buttons only while we are recording a trace.
402  if (!controller.shouldShowStopCancelButtons()) {
403    return undefined;
404  }
405
406  const stop = m(
407    `button.selected`,
408    {onclick: () => controller.onStop()},
409    'Stop',
410  );
411
412  const cancel = m(`button`, {onclick: () => controller.onCancel()}, 'Cancel');
413
414  return [stop, cancel];
415}
416
417function recordMenu(routePage: string) {
418  const chromeProbe = m(
419    'a[href="#!/record/chrome"]',
420    m(
421      `li${routePage === 'chrome' ? '.active' : ''}`,
422      m('i.material-icons', 'laptop_chromebook'),
423      m('.title', 'Chrome'),
424      m('.sub', 'Chrome traces'),
425    ),
426  );
427  const cpuProbe = m(
428    'a[href="#!/record/cpu"]',
429    m(
430      `li${routePage === 'cpu' ? '.active' : ''}`,
431      m('i.material-icons', 'subtitles'),
432      m('.title', 'CPU'),
433      m('.sub', 'CPU usage, scheduling, wakeups'),
434    ),
435  );
436  const gpuProbe = m(
437    'a[href="#!/record/gpu"]',
438    m(
439      `li${routePage === 'gpu' ? '.active' : ''}`,
440      m('i.material-icons', 'aspect_ratio'),
441      m('.title', 'GPU'),
442      m('.sub', 'GPU frequency, memory'),
443    ),
444  );
445  const powerProbe = m(
446    'a[href="#!/record/power"]',
447    m(
448      `li${routePage === 'power' ? '.active' : ''}`,
449      m('i.material-icons', 'battery_charging_full'),
450      m('.title', 'Power'),
451      m('.sub', 'Battery and other energy counters'),
452    ),
453  );
454  const memoryProbe = m(
455    'a[href="#!/record/memory"]',
456    m(
457      `li${routePage === 'memory' ? '.active' : ''}`,
458      m('i.material-icons', 'memory'),
459      m('.title', 'Memory'),
460      m('.sub', 'Physical mem, VM, LMK'),
461    ),
462  );
463  const androidProbe = m(
464    'a[href="#!/record/android"]',
465    m(
466      `li${routePage === 'android' ? '.active' : ''}`,
467      m('i.material-icons', 'android'),
468      m('.title', 'Android apps & svcs'),
469      m('.sub', 'atrace and logcat'),
470    ),
471  );
472  const advancedProbe = m(
473    'a[href="#!/record/advanced"]',
474    m(
475      `li${routePage === 'advanced' ? '.active' : ''}`,
476      m('i.material-icons', 'settings'),
477      m('.title', 'Advanced settings'),
478      m('.sub', 'Complicated stuff for wizards'),
479    ),
480  );
481  const tracePerfProbe = m(
482    'a[href="#!/record/tracePerf"]',
483    m(
484      `li${routePage === 'tracePerf' ? '.active' : ''}`,
485      m('i.material-icons', 'full_stacked_bar_chart'),
486      m('.title', 'Stack Samples'),
487      m('.sub', 'Lightweight stack polling'),
488    ),
489  );
490  const etwProbe = m(
491    'a[href="#!/record/etw"]',
492    m(
493      `li${routePage === 'etw' ? '.active' : ''}`,
494      m('i.material-icons', 'subtitles'),
495      m('.title', 'ETW Tracing Config'),
496      m('.sub', 'Context switch, Thread state'),
497    ),
498  );
499
500  // We only display the probes when we have a valid target, so it's not
501  // possible for the target to be undefined here.
502  const targetType = assertExists(controller.getTargetInfo()).targetType;
503  const probes = [];
504  if (targetType === 'CHROME_OS' || targetType === 'LINUX') {
505    probes.push(cpuProbe, powerProbe, memoryProbe, chromeProbe, advancedProbe);
506  } else if (targetType === 'WINDOWS') {
507    probes.push(chromeProbe, etwProbe);
508  } else if (targetType === 'CHROME') {
509    probes.push(chromeProbe);
510  } else {
511    probes.push(
512      cpuProbe,
513      gpuProbe,
514      powerProbe,
515      memoryProbe,
516      androidProbe,
517      chromeProbe,
518      tracePerfProbe,
519      advancedProbe,
520    );
521  }
522
523  return m(
524    '.record-menu',
525    {
526      class:
527        controller.getState() > RecordingState.TARGET_INFO_DISPLAYED
528          ? 'disabled'
529          : '',
530      onclick: () => raf.scheduleFullRedraw(),
531    },
532    m('header', 'Trace config'),
533    m(
534      'ul',
535      m(
536        'a[href="#!/record/buffers"]',
537        m(
538          `li${routePage === 'buffers' ? '.active' : ''}`,
539          m('i.material-icons', 'tune'),
540          m('.title', 'Recording settings'),
541          m('.sub', 'Buffer mode, size and duration'),
542        ),
543      ),
544      m(
545        'a[href="#!/record/instructions"]',
546        m(
547          `li${routePage === 'instructions' ? '.active' : ''}`,
548          m('i.material-icons-filled.rec', 'fiber_manual_record'),
549          m('.title', 'Recording command'),
550          m('.sub', 'Manually record trace'),
551        ),
552      ),
553      PERSIST_CONFIG_FLAG.get()
554        ? m(
555            'a[href="#!/record/config"]',
556            {
557              onclick: () => {
558                recordConfigStore.reloadFromLocalStorage();
559              },
560            },
561            m(
562              `li${routePage === 'config' ? '.active' : ''}`,
563              m('i.material-icons', 'save'),
564              m('.title', 'Saved configs'),
565              m('.sub', 'Manage local configs'),
566            ),
567          )
568        : null,
569    ),
570    m('header', 'Probes'),
571    m('ul', probes),
572  );
573}
574
575// eslint-disable-next-line @typescript-eslint/no-explicit-any
576function getRecordContainer(subpage?: string): m.Vnode<any, any> {
577  const components: m.Children[] = [RecordHeader()];
578  if (controller.getState() === RecordingState.NO_TARGET) {
579    components.push(m('.full-centered', 'Please connect a valid target.'));
580    return m('.record-container', components);
581  } else if (controller.getState() <= RecordingState.ASK_TO_FORCE_P1) {
582    components.push(
583      m(
584        '.full-centered',
585        'Can not access the device without resetting the ' +
586          `connection. Please refresh the page, then click ` +
587          `'${FORCE_RESET_MESSAGE}.'`,
588      ),
589    );
590    return m('.record-container', components);
591  } else if (controller.getState() === RecordingState.AUTH_P1) {
592    components.push(
593      m('.full-centered', 'Please allow USB debugging on the device.'),
594    );
595    return m('.record-container', components);
596  } else if (
597    controller.getState() === RecordingState.WAITING_FOR_TRACE_DISPLAY
598  ) {
599    components.push(
600      m('.full-centered', 'Waiting for the trace to be collected.'),
601    );
602    return m('.record-container', components);
603  }
604
605  const pages: m.Children = [];
606  // we need to remove the `/` character from the route
607  let routePage = subpage ? subpage.substr(1) : '';
608  if (!RECORDING_SECTIONS.includes(routePage)) {
609    routePage = 'buffers';
610  }
611  pages.push(recordMenu(routePage));
612
613  pages.push(
614    m(RecordingSettings, {
615      dataSources: [],
616      cssClass: maybeGetActiveCss(routePage, 'buffers'),
617    } as RecordingSectionAttrs),
618  );
619  pages.push(Instructions(maybeGetActiveCss(routePage, 'instructions')));
620  pages.push(Configurations(maybeGetActiveCss(routePage, 'config')));
621
622  const settingsSections = new Map([
623    ['cpu', CpuSettings],
624    ['gpu', GpuSettings],
625    ['power', PowerSettings],
626    ['memory', MemorySettings],
627    ['android', AndroidSettings],
628    ['chrome', ChromeSettings],
629    ['tracePerf', LinuxPerfSettings],
630    ['advanced', AdvancedSettings],
631    ['etw', EtwSettings],
632  ]);
633  for (const [section, component] of settingsSections.entries()) {
634    pages.push(
635      m(component, {
636        dataSources: controller.getTargetInfo()?.dataSources || [],
637        cssClass: maybeGetActiveCss(routePage, section),
638      } as RecordingSectionAttrs),
639    );
640  }
641
642  components.push(m('.record-container-content', pages));
643  return m('.record-container', components);
644}
645
646export const RecordPageV2 = createPage({
647  oninit(): void {
648    controller.initFactories();
649  },
650
651  view({attrs}: m.Vnode<PageAttrs>) {
652    return m(
653      '.record-page',
654      controller.getState() > RecordingState.TARGET_INFO_DISPLAYED
655        ? m('.hider')
656        : [],
657      getRecordContainer(attrs.subpage),
658    );
659  },
660});
661