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