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