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