| // Copyright (C) 2022 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| |
| import m from 'mithril'; |
| import {Attributes} from 'mithril'; |
| |
| import {assertExists} from '../base/logging'; |
| import {Actions} from '../common/actions'; |
| import { |
| RecordingConfigUtils, |
| } from '../common/recordingV2/recording_config_utils'; |
| import { |
| ChromeTargetInfo, |
| RecordingTargetV2, |
| TargetInfo, |
| } from '../common/recordingV2/recording_interfaces_v2'; |
| import { |
| RecordingPageController, |
| RecordingState, |
| } from '../common/recordingV2/recording_page_controller'; |
| import { |
| EXTENSION_NAME, |
| EXTENSION_URL, |
| } from '../common/recordingV2/recording_utils'; |
| import { |
| targetFactoryRegistry, |
| } from '../common/recordingV2/target_factory_registry'; |
| |
| import {globals} from './globals'; |
| import {fullscreenModalContainer} from './modal'; |
| import {createPage, PageAttrs} from './pages'; |
| import {recordConfigStore} from './record_config'; |
| import { |
| Configurations, |
| maybeGetActiveCss, |
| PERSIST_CONFIG_FLAG, |
| RECORDING_SECTIONS, |
| } from './record_page'; |
| import {CodeSnippet} from './record_widgets'; |
| import {AdvancedSettings} from './recording/advanced_settings'; |
| import {AndroidSettings} from './recording/android_settings'; |
| import {ChromeSettings} from './recording/chrome_settings'; |
| import {CpuSettings} from './recording/cpu_settings'; |
| import {GpuSettings} from './recording/gpu_settings'; |
| import {MemorySettings} from './recording/memory_settings'; |
| import {PowerSettings} from './recording/power_settings'; |
| import {RecordingSectionAttrs} from './recording/recording_sections'; |
| import {RecordingSettings} from './recording/recording_settings'; |
| import { |
| FORCE_RESET_MESSAGE, |
| } from './recording/recording_ui_utils'; |
| import {addNewTarget} from './recording/reset_target_modal'; |
| |
| const START_RECORDING_MESSAGE = 'Start Recording'; |
| |
| const controller = new RecordingPageController(); |
| const recordConfigUtils = new RecordingConfigUtils(); |
| // Whether the target selection modal is displayed. |
| let shouldDisplayTargetModal: boolean = false; |
| |
| // Options for displaying a target selection menu. |
| export interface TargetSelectionOptions { |
| // css attributes passed to the mithril components which displays the target |
| // selection menu. |
| attributes: Attributes; |
| // Whether the selection should be preceded by a text label. |
| shouldDisplayLabel: boolean; |
| } |
| |
| function isChromeTargetInfo(targetInfo: TargetInfo): |
| targetInfo is ChromeTargetInfo { |
| return ['CHROME', 'CHROME_OS'].includes(targetInfo.targetType); |
| } |
| |
| function RecordHeader() { |
| const platformSelection = RecordingPlatformSelection(); |
| const statusLabel = RecordingStatusLabel(); |
| const buttons = RecordingButton(); |
| const notes = RecordingNotes(); |
| if (!platformSelection && !statusLabel && !buttons && !notes) { |
| // The header should not be displayed when it has no content. |
| return undefined; |
| } |
| return m( |
| '.record-header', |
| m('.top-part', |
| m('.target-and-status', platformSelection, statusLabel), |
| buttons), |
| notes); |
| } |
| |
| function RecordingPlatformSelection() { |
| // Don't show the platform selector while we are recording a trace. |
| if (controller.getState() >= RecordingState.RECORDING) return undefined; |
| |
| return m( |
| '.target', |
| m('.chip', |
| { |
| onclick: () => { |
| shouldDisplayTargetModal = true; |
| fullscreenModalContainer.createNew(addNewTargetModal()); |
| globals.rafScheduler.scheduleFullRedraw(); |
| }, |
| }, |
| m('button', 'Add new recording target'), |
| m('i.material-icons', 'add')), |
| targetSelection()); |
| } |
| |
| function addNewTargetModal() { |
| return { |
| ...addNewTarget(controller), |
| onClose: () => shouldDisplayTargetModal = false, |
| }; |
| } |
| |
| export function targetSelection(): m.Vnode|undefined { |
| if (!controller.shouldShowTargetSelection()) { |
| return undefined; |
| } |
| |
| const targets: RecordingTargetV2[] = targetFactoryRegistry.listTargets(); |
| const targetNames = []; |
| const targetInfo = controller.getTargetInfo(); |
| if (!targetInfo) { |
| targetNames.push(m('option', 'PLEASE_SELECT_TARGET')); |
| } |
| |
| let selectedIndex = 0; |
| for (let i = 0; i < targets.length; i++) { |
| const targetName = targets[i].getInfo().name; |
| targetNames.push(m('option', targetName)); |
| if (targetInfo && targetName === targetInfo.name) { |
| selectedIndex = i; |
| } |
| } |
| |
| return m( |
| 'label', |
| 'Target platform:', |
| m('select', |
| { |
| selectedIndex, |
| onchange: (e: Event) => { |
| controller.onTargetSelection((e.target as HTMLSelectElement).value); |
| }, |
| onupdate: (select) => { |
| // Work around mithril bug |
| // (https://github.com/MithrilJS/mithril.js/issues/2107): We may |
| // update the select's options while also changing the |
| // selectedIndex at the same time. The update of selectedIndex |
| // may be applied before the new options are added to the select |
| // element. Because the new selectedIndex may be outside of the |
| // select's options at that time, we have to reselect the |
| // correct index here after any new children were added. |
| (select.dom as HTMLSelectElement).selectedIndex = selectedIndex; |
| }, |
| }, |
| ...targetNames), |
| ); |
| } |
| |
| // This will display status messages which are informative, but do not require |
| // user action, such as: "Recording in progress for X seconds" in the recording |
| // page header. |
| function RecordingStatusLabel() { |
| const recordingStatus = globals.state.recordingStatus; |
| if (!recordingStatus) return undefined; |
| return m('label', recordingStatus); |
| } |
| |
| function Instructions(cssClass: string) { |
| if (controller.getState() < RecordingState.TARGET_SELECTED) { |
| return undefined; |
| } |
| // We will have a valid target at this step because we checked the state. |
| const targetInfo = assertExists(controller.getTargetInfo()); |
| |
| return m( |
| `.record-section.instructions${cssClass}`, |
| m('header', 'Recording command'), |
| (PERSIST_CONFIG_FLAG.get()) ? |
| m('button.permalinkconfig', |
| { |
| onclick: () => { |
| globals.dispatch( |
| Actions.createPermalink({isRecordingConfig: true})); |
| }, |
| }, |
| 'Share recording settings') : |
| null, |
| RecordingSnippet(targetInfo), |
| BufferUsageProgressBar(), |
| m('.buttons', StopCancelButtons())); |
| } |
| |
| function BufferUsageProgressBar() { |
| // Show the Buffer Usage bar only after we start recording a trace. |
| if (controller.getState() !== RecordingState.RECORDING) { |
| return undefined; |
| } |
| |
| controller.fetchBufferUsage(); |
| |
| const bufferUsage = controller.getBufferUsagePercentage(); |
| // Buffer usage is not available yet on Android. |
| if (bufferUsage === 0) return undefined; |
| |
| return m( |
| 'label', |
| 'Buffer usage: ', |
| m('progress', {max: 100, value: bufferUsage * 100})); |
| } |
| |
| function RecordingNotes() { |
| if (controller.getState() !== RecordingState.TARGET_INFO_DISPLAYED) { |
| return undefined; |
| } |
| // We will have a valid target at this step because we checked the state. |
| const targetInfo = assertExists(controller.getTargetInfo()); |
| |
| const linuxUrl = 'https://perfetto.dev/docs/quickstart/linux-tracing'; |
| const cmdlineUrl = |
| 'https://perfetto.dev/docs/quickstart/android-tracing#perfetto-cmdline'; |
| |
| const notes: m.Children = []; |
| |
| const msgFeatNotSupported = |
| m('span', `Some probes are only supported in Perfetto versions running |
| on Android Q+. Therefore, Perfetto will sideload the latest version onto |
| the device.`); |
| |
| const msgPerfettoNotSupported = m( |
| 'span', |
| `Perfetto is not supported natively before Android P. Therefore, Perfetto |
| will sideload the latest version onto the device.`); |
| |
| const msgLinux = |
| m('.note', |
| `Use this `, |
| m('a', {href: linuxUrl, target: '_blank'}, `quickstart guide`), |
| ` to get started with tracing on Linux.`); |
| |
| const msgLongTraces = m( |
| '.note', |
| `Recording in long trace mode through the UI is not supported. Please copy |
| the command and `, |
| m('a', |
| {href: cmdlineUrl, target: '_blank'}, |
| `collect the trace using ADB.`)); |
| |
| if (!recordConfigUtils |
| .fetchLatestRecordCommand(globals.state.recordConfig, targetInfo) |
| .hasDataSources) { |
| notes.push( |
| m('.note', |
| 'It looks like you didn\'t add any probes. ' + |
| 'Please add at least one to get a non-empty trace.')); |
| } |
| |
| targetFactoryRegistry.listRecordingProblems().map((recordingProblem) => { |
| if (recordingProblem.includes(EXTENSION_URL)) { |
| // Special case for rendering the link to the Chrome extension. |
| const parts = recordingProblem.split(EXTENSION_URL); |
| notes.push( |
| m('.note', |
| parts[0], |
| m('a', {href: EXTENSION_URL, target: '_blank'}, EXTENSION_NAME), |
| parts[1])); |
| } |
| }); |
| |
| switch (targetInfo.targetType) { |
| case 'LINUX': |
| notes.push(msgLinux); |
| break; |
| case 'ANDROID': { |
| const androidApiLevel = targetInfo.androidApiLevel; |
| if (androidApiLevel === 28) { |
| notes.push(m('.note', msgFeatNotSupported)); |
| } else if (androidApiLevel && androidApiLevel <= 27) { |
| notes.push(m('.note', msgPerfettoNotSupported)); |
| } |
| break; |
| } |
| default: |
| } |
| |
| if (globals.state.recordConfig.mode === 'LONG_TRACE') { |
| notes.unshift(msgLongTraces); |
| } |
| |
| return notes.length > 0 ? m('div', notes) : undefined; |
| } |
| |
| function RecordingSnippet(targetInfo: TargetInfo) { |
| // We don't need commands to start tracing on chrome |
| if (isChromeTargetInfo(targetInfo)) { |
| if (controller.getState() > RecordingState.AUTH_P2) { |
| // If the UI has started tracing, don't display a message guiding the user |
| // to start recording. |
| return undefined; |
| } |
| return m( |
| 'div', |
| m('label', `To trace Chrome from the Perfetto UI you just have to press |
| '${START_RECORDING_MESSAGE}'.`)); |
| } |
| return m(CodeSnippet, {text: getRecordCommand(targetInfo)}); |
| } |
| |
| function getRecordCommand(targetInfo: TargetInfo): string { |
| const recordCommand = recordConfigUtils.fetchLatestRecordCommand( |
| globals.state.recordConfig, targetInfo); |
| |
| const pbBase64 = recordCommand ? recordCommand.configProtoBase64 : ''; |
| const pbtx = recordCommand ? recordCommand.configProtoText : ''; |
| let cmd = ''; |
| if (targetInfo.targetType === 'ANDROID' && |
| targetInfo.androidApiLevel === 28) { |
| cmd += `echo '${pbBase64}' | \n`; |
| cmd += 'base64 --decode | \n'; |
| cmd += 'adb shell "perfetto -c - -o /data/misc/perfetto-traces/trace"\n'; |
| } else { |
| cmd += targetInfo.targetType === 'ANDROID' ? 'adb shell perfetto \\\n' : |
| 'perfetto \\\n'; |
| cmd += ' -c - --txt \\\n'; |
| cmd += ' -o /data/misc/perfetto-traces/trace \\\n'; |
| cmd += '<<EOF\n\n'; |
| cmd += pbtx; |
| cmd += '\nEOF\n'; |
| } |
| return cmd; |
| } |
| |
| function RecordingButton() { |
| if (controller.getState() !== RecordingState.TARGET_INFO_DISPLAYED || |
| !controller.canCreateTracingSession()) { |
| return undefined; |
| } |
| |
| // We know we have a target because we checked the state. |
| const targetInfo = assertExists(controller.getTargetInfo()); |
| const hasDataSources = |
| recordConfigUtils |
| .fetchLatestRecordCommand(globals.state.recordConfig, targetInfo) |
| .hasDataSources; |
| if (!hasDataSources) { |
| return undefined; |
| } |
| |
| return m( |
| '.button', |
| m('button', |
| { |
| class: 'selected', |
| onclick: () => controller.onStartRecordingPressed(), |
| }, |
| START_RECORDING_MESSAGE)); |
| } |
| |
| function StopCancelButtons() { |
| // Show the Stop/Cancel buttons only while we are recording a trace. |
| if (!controller.shouldShowStopCancelButtons()) { |
| return undefined; |
| } |
| |
| const stop = |
| m(`button.selected`, {onclick: () => controller.onStop()}, 'Stop'); |
| |
| const cancel = m(`button`, {onclick: () => controller.onCancel()}, 'Cancel'); |
| |
| return [stop, cancel]; |
| } |
| |
| function recordMenu(routePage: string) { |
| const chromeProbe = |
| m('a[href="#!/record/chrome"]', |
| m(`li${routePage === 'chrome' ? '.active' : ''}`, |
| m('i.material-icons', 'laptop_chromebook'), |
| m('.title', 'Chrome'), |
| m('.sub', 'Chrome traces'))); |
| const cpuProbe = |
| m('a[href="#!/record/cpu"]', |
| m(`li${routePage === 'cpu' ? '.active' : ''}`, |
| m('i.material-icons', 'subtitles'), |
| m('.title', 'CPU'), |
| m('.sub', 'CPU usage, scheduling, wakeups'))); |
| const gpuProbe = |
| m('a[href="#!/record/gpu"]', |
| m(`li${routePage === 'gpu' ? '.active' : ''}`, |
| m('i.material-icons', 'aspect_ratio'), |
| m('.title', 'GPU'), |
| m('.sub', 'GPU frequency, memory'))); |
| const powerProbe = |
| m('a[href="#!/record/power"]', |
| m(`li${routePage === 'power' ? '.active' : ''}`, |
| m('i.material-icons', 'battery_charging_full'), |
| m('.title', 'Power'), |
| m('.sub', 'Battery and other energy counters'))); |
| const memoryProbe = |
| m('a[href="#!/record/memory"]', |
| m(`li${routePage === 'memory' ? '.active' : ''}`, |
| m('i.material-icons', 'memory'), |
| m('.title', 'Memory'), |
| m('.sub', 'Physical mem, VM, LMK'))); |
| const androidProbe = |
| m('a[href="#!/record/android"]', |
| m(`li${routePage === 'android' ? '.active' : ''}`, |
| m('i.material-icons', 'android'), |
| m('.title', 'Android apps & svcs'), |
| m('.sub', 'atrace and logcat'))); |
| const advancedProbe = |
| m('a[href="#!/record/advanced"]', |
| m(`li${routePage === 'advanced' ? '.active' : ''}`, |
| m('i.material-icons', 'settings'), |
| m('.title', 'Advanced settings'), |
| m('.sub', 'Complicated stuff for wizards'))); |
| |
| // We only display the probes when we have a valid target, so it's not |
| // possible for the target to be undefined here. |
| const targetType = assertExists(controller.getTargetInfo()).targetType; |
| const probes = []; |
| if (targetType === 'CHROME_OS' || targetType === 'LINUX') { |
| probes.push(cpuProbe, powerProbe, memoryProbe, chromeProbe, advancedProbe); |
| } else if (targetType === 'CHROME') { |
| probes.push(chromeProbe); |
| } else { |
| probes.push( |
| cpuProbe, |
| gpuProbe, |
| powerProbe, |
| memoryProbe, |
| androidProbe, |
| chromeProbe, |
| advancedProbe); |
| } |
| |
| return m( |
| '.record-menu', |
| { |
| class: controller.getState() > RecordingState.TARGET_INFO_DISPLAYED ? |
| 'disabled' : |
| '', |
| onclick: () => globals.rafScheduler.scheduleFullRedraw(), |
| }, |
| m('header', 'Trace config'), |
| m('ul', |
| m('a[href="#!/record/buffers"]', |
| m(`li${routePage === 'buffers' ? '.active' : ''}`, |
| m('i.material-icons', 'tune'), |
| m('.title', 'Recording settings'), |
| m('.sub', 'Buffer mode, size and duration'))), |
| m('a[href="#!/record/instructions"]', |
| m(`li${routePage === 'instructions' ? '.active' : ''}`, |
| m('i.material-icons-filled.rec', 'fiber_manual_record'), |
| m('.title', 'Recording command'), |
| m('.sub', 'Manually record trace'))), |
| PERSIST_CONFIG_FLAG.get() ? |
| m('a[href="#!/record/config"]', |
| { |
| onclick: () => { |
| recordConfigStore.reloadFromLocalStorage(); |
| }, |
| }, |
| m(`li${routePage === 'config' ? '.active' : ''}`, |
| m('i.material-icons', 'save'), |
| m('.title', 'Saved configs'), |
| m('.sub', 'Manage local configs'))) : |
| null), |
| m('header', 'Probes'), |
| m('ul', probes)); |
| } |
| |
| function getRecordContainer(subpage?: string): m.Vnode<any, any> { |
| const components: m.Children[] = [RecordHeader()]; |
| if (controller.getState() === RecordingState.NO_TARGET) { |
| components.push(m('.full-centered', 'Please connect a valid target.')); |
| return m('.record-container', components); |
| } else if (controller.getState() <= RecordingState.ASK_TO_FORCE_P1) { |
| components.push( |
| m('.full-centered', |
| 'Can not access the device without resetting the ' + |
| `connection. Please refresh the page, then click ` + |
| `'${FORCE_RESET_MESSAGE}.'`)); |
| return m('.record-container', components); |
| } else if (controller.getState() === RecordingState.AUTH_P1) { |
| components.push( |
| m('.full-centered', 'Please allow USB debugging on the device.')); |
| return m('.record-container', components); |
| } else if ( |
| controller.getState() === RecordingState.WAITING_FOR_TRACE_DISPLAY) { |
| components.push( |
| m('.full-centered', 'Waiting for the trace to be collected.')); |
| return m('.record-container', components); |
| } |
| |
| const pages: m.Children = []; |
| // we need to remove the `/` character from the route |
| let routePage = subpage ? subpage.substr(1) : ''; |
| if (!RECORDING_SECTIONS.includes(routePage)) { |
| routePage = 'buffers'; |
| } |
| pages.push(recordMenu(routePage)); |
| |
| pages.push(m(RecordingSettings, { |
| dataSources: [], |
| cssClass: maybeGetActiveCss(routePage, 'buffers'), |
| } as RecordingSectionAttrs)); |
| pages.push(Instructions(maybeGetActiveCss(routePage, 'instructions'))); |
| pages.push(Configurations(maybeGetActiveCss(routePage, 'config'))); |
| |
| const settingsSections = new Map([ |
| ['cpu', CpuSettings], |
| ['gpu', GpuSettings], |
| ['power', PowerSettings], |
| ['memory', MemorySettings], |
| ['android', AndroidSettings], |
| ['chrome', ChromeSettings], |
| ['advanced', AdvancedSettings], |
| ]); |
| for (const [section, component] of settingsSections.entries()) { |
| pages.push(m(component, { |
| dataSources: controller.getTargetInfo()?.dataSources || [], |
| cssClass: maybeGetActiveCss(routePage, section), |
| } as RecordingSectionAttrs)); |
| } |
| |
| components.push(m('.record-container-content', pages)); |
| return m('.record-container', components); |
| } |
| |
| export const RecordPageV2 = createPage({ |
| |
| oninit(): void { |
| controller.initFactories(); |
| }, |
| |
| view({attrs}: m.Vnode<PageAttrs>): void | |
| m.Children { |
| if (shouldDisplayTargetModal) { |
| fullscreenModalContainer.updateVdom(addNewTargetModal()); |
| } |
| |
| return m( |
| '.record-page', |
| controller.getState() > RecordingState.TARGET_INFO_DISPLAYED ? |
| m('.hider') : |
| [], |
| getRecordContainer(attrs.subpage)); |
| }, |
| }); |