blob: 2bad8f1d4f42cb0338df3ec5e3128d387cbff7db [file] [log] [blame]
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/*
* Copyright (C) 2012 Google Inc. All rights reserved.
* Copyright (C) 2012 Intel Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google Inc. nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import type * as TimelineModel from '../../models/timeline_model/timeline_model.js';
import * as TraceEngine from '../../models/trace/trace.js';
import * as TraceBounds from '../../services/trace_bounds/trace_bounds.js';
import * as PanelFeedback from '../../ui/components/panel_feedback/panel_feedback.js';
import * as PerfUI from '../../ui/legacy/components/perf_ui/perf_ui.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as MobileThrottling from '../mobile_throttling/mobile_throttling.js';
import historyToolbarButtonStyles from './historyToolbarButton.css.js';
import {Events, PerformanceModel, type WindowChangedEvent} from './PerformanceModel.js';
import {cpuprofileJsonGenerator, traceJsonGenerator} from './SaveFileFormatter.js';
import {NodeNamesUpdated, SourceMapsResolver} from './SourceMapsResolver.js';
import {type Client, TimelineController} from './TimelineController.js';
import {TimelineFlameChartView} from './TimelineFlameChartView.js';
import {TimelineHistoryManager} from './TimelineHistoryManager.js';
import {TimelineLoader} from './TimelineLoader.js';
import {TimelineMiniMap} from './TimelineMiniMap.js';
import timelinePanelStyles from './timelinePanel.css.js';
import {TimelineSelection} from './TimelineSelection.js';
import timelineStatusDialogStyles from './timelineStatusDialog.css.js';
import {TimelineUIUtils} from './TimelineUIUtils.js';
import {UIDevtoolsController} from './UIDevtoolsController.js';
import {UIDevtoolsUtils} from './UIDevtoolsUtils.js';
const UIStrings = {
/**
*@description Text that appears when user drag and drop something (for example, a file) in Timeline Panel of the Performance panel
*/
dropTimelineFileOrUrlHere: 'Drop timeline file or URL here',
/**
*@description Title of disable capture jsprofile setting in timeline panel of the performance panel
*/
disableJavascriptSamples: 'Disable JavaScript samples',
/**
*@description Title of capture layers and pictures setting in timeline panel of the performance panel
*/
enableAdvancedPaint: 'Enable advanced paint instrumentation (slow)',
/**
*@description Title of show screenshots setting in timeline panel of the performance panel
*/
screenshots: 'Screenshots',
/**
*@description Text for the memory of the page
*/
memory: 'Memory',
/**
*@description Text to clear content
*/
clear: 'Clear',
/**
*@description Tooltip text that appears when hovering over the largeicon load button
*/
loadProfile: 'Load profile…',
/**
*@description Tooltip text that appears when hovering over the largeicon download button
*/
saveProfile: 'Save profile…',
/**
*@description Text to take screenshots
*/
captureScreenshots: 'Capture screenshots',
/**
*@description Text in Timeline Panel of the Performance panel
*/
showMemoryTimeline: 'Show memory timeline',
/**
*@description Tooltip text that appears when hovering over the largeicon settings gear in show settings pane setting in timeline panel of the performance panel
*/
captureSettings: 'Capture settings',
/**
*@description Text in Timeline Panel of the Performance panel
*/
disablesJavascriptSampling: 'Disables JavaScript sampling, reduces overhead when running against mobile devices',
/**
*@description Text in Timeline Panel of the Performance panel
*/
capturesAdvancedPaint: 'Captures advanced paint instrumentation, introduces significant performance overhead',
/**
*@description Text in Timeline Panel of the Performance panel
*/
network: 'Network:',
/**
*@description Text in Timeline Panel of the Performance panel
*/
cpu: 'CPU:',
/**
*@description Title of the 'Network conditions' tool in the bottom drawer
*/
networkConditions: 'Network conditions',
/**
*@description Text in Timeline Panel of the Performance panel
*@example {wrong format} PH1
*@example {ERROR_FILE_NOT_FOUND} PH2
*/
failedToSaveTimelineSS: 'Failed to save timeline: {PH1} ({PH2})',
/**
*@description Text in Timeline Panel of the Performance panel
*/
CpuThrottlingIsEnabled: '- CPU throttling is enabled',
/**
*@description Text in Timeline Panel of the Performance panel
*/
NetworkThrottlingIsEnabled: '- Network throttling is enabled',
/**
*@description Text in Timeline Panel of the Performance panel
*/
HardwareConcurrencyIsEnabled: '- Hardware concurrency override is enabled',
/**
*@description Text in Timeline Panel of the Performance panel
*/
SignificantOverheadDueToPaint: '- Significant overhead due to paint instrumentation',
/**
*@description Text in Timeline Panel of the Performance panel
*/
JavascriptSamplingIsDisabled: '- JavaScript sampling is disabled',
/**
*@description Text in Timeline Panel of the Performance panel
*/
stoppingTimeline: 'Stopping timeline…',
/**
*@description Text in Timeline Panel of the Performance panel
*/
received: 'Received',
/**
*@description Text to close something
*/
close: 'Close',
/**
*@description Text to download the raw trace files after an error
*/
downloadAfterError: 'Download raw trace events',
/**
*@description Status text to indicate the recording has failed in the Performance panel
*/
recordingFailed: 'Recording failed',
/**
* @description Text to indicate the progress of a profile. Informs the user that we are currently
* creating a peformance profile.
*/
profiling: 'Profiling…',
/**
*@description Text in Timeline Panel of the Performance panel
*/
bufferUsage: 'Buffer usage',
/**
*@description Text for an option to learn more about something
*/
learnmore: 'Learn more',
/**
*@description Text in Timeline Panel of the Performance panel
*/
wasd: 'WASD',
/**
*@description Text in Timeline Panel of the Performance panel
*@example {record} PH1
*@example {Ctrl + R} PH2
*/
clickTheRecordButtonSOrHitSTo: 'Click the record button {PH1} or hit {PH2} to start a new recording.',
/**
* @description Text in Timeline Panel of the Performance panel
* @example {reload button} PH1
* @example {Ctrl + R} PH2
*/
clickTheReloadButtonSOrHitSTo: 'Click the reload button {PH1} or hit {PH2} to record the page load.',
/**
*@description Text in Timeline Panel of the Performance panel
*@example {Ctrl + U} PH1
*@example {Learn more} PH2
*/
afterRecordingSelectAnAreaOf:
'After recording, select an area of interest in the overview by dragging. Then, zoom and pan the timeline with the mousewheel or {PH1} keys. {PH2}',
/**
*@description Text in Timeline Panel of the Performance panel
*/
loadingProfile: 'Loading profile…',
/**
*@description Text in Timeline Panel of the Performance panel
*/
processingProfile: 'Processing profile…',
/**
*@description Text in Timeline Panel of the Performance panel
*/
initializingProfiler: 'Initializing profiler…',
/**
*@description Text for the status of something
*/
status: 'Status',
/**
*@description Text that refers to the time
*/
time: 'Time',
/**
*@description Text for the description of something
*/
description: 'Description',
/**
*@description Text of an item that stops the running task
*/
stop: 'Stop',
/**
*@description Time text content in Timeline Panel of the Performance panel
*@example {2.12} PH1
*/
ssec: '{PH1} sec',
};
const str_ = i18n.i18n.registerUIStrings('panels/timeline/TimelinePanel.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
let timelinePanelInstance: TimelinePanel;
let isNode: boolean;
// TODO(crbug.com/1386091): Remove this enum when we can remove the
// old engine.
// eslint-disable-next-line rulesdir/const_enum
export enum ThreadTracksSource {
NEW_ENGINE = 'NEW_ENGINE',
OLD_ENGINE = 'OLD_ENGINE',
BOTH_ENGINES = 'BOTH_ENGINES',
}
// TODO(crbug.com/1428024): Use the new engine.
const DEFAULT_THREAD_TRACKS_SOURCE = ThreadTracksSource.NEW_ENGINE;
// TypeScript will presumably get these types at some stage, and when it
// does these temporary types should be removed.
// TODO: Remove types when available in TypeScript.
declare global {
interface FileSystemWritableFileStream extends WritableStream {
write(data: unknown): Promise<void>;
close(): Promise<void>;
}
interface FileSystemHandle {
createWritable(): Promise<FileSystemWritableFileStream>;
}
interface Window {
showSaveFilePicker(opts: unknown): Promise<FileSystemHandle>;
}
}
export class TimelinePanel extends UI.Panel.Panel implements Client, TimelineModeViewDelegate {
private readonly dropTarget: UI.DropTarget.DropTarget;
private readonly recordingOptionUIControls: UI.Toolbar.ToolbarItem[];
private state: State;
private recordingPageReload: boolean;
private readonly millisecondsToRecordAfterLoadEvent: number;
private readonly toggleRecordAction: UI.ActionRegistration.Action;
private readonly recordReloadAction: UI.ActionRegistration.Action;
readonly #historyManager: TimelineHistoryManager;
private performanceModel: PerformanceModel|null;
private disableCaptureJSProfileSetting: Common.Settings.Setting<boolean>;
private readonly captureLayersAndPicturesSetting: Common.Settings.Setting<boolean>;
private showScreenshotsSetting: Common.Settings.Setting<boolean>;
private showMemorySetting: Common.Settings.Setting<boolean>;
private readonly panelToolbar: UI.Toolbar.Toolbar;
private readonly panelRightToolbar: UI.Toolbar.Toolbar;
private readonly timelinePane: UI.Widget.VBox;
readonly #minimapComponent;
private readonly statusPaneContainer: HTMLElement;
private readonly flameChart: TimelineFlameChartView;
private readonly searchableViewInternal: UI.SearchableView.SearchableView;
private showSettingsPaneButton!: UI.Toolbar.ToolbarSettingToggle;
private showSettingsPaneSetting!: Common.Settings.Setting<boolean>;
private settingsPane!: UI.Widget.Widget;
private controller!: TimelineController|null;
private cpuProfiler!: SDK.CPUProfilerModel.CPUProfilerModel|null;
private clearButton!: UI.Toolbar.ToolbarButton;
private loadButton!: UI.Toolbar.ToolbarButton;
private saveButton!: UI.Toolbar.ToolbarButton;
private statusPane!: StatusPane|null;
private landingPage!: UI.Widget.Widget;
private loader?: TimelineLoader;
private showScreenshotsToolbarCheckbox?: UI.Toolbar.ToolbarItem;
private showMemoryToolbarCheckbox?: UI.Toolbar.ToolbarItem;
private networkThrottlingSelect?: UI.Toolbar.ToolbarComboBox;
private cpuThrottlingSelect?: UI.Toolbar.ToolbarComboBox;
private fileSelectorElement?: HTMLInputElement;
private selection?: TimelineSelection|null;
private traceLoadStart!: number|null;
private primaryPageTargetPromiseCallback = (_target: SDK.Target.Target): void => {};
// Note: this is technically unused, but we need it to define the promiseCallback function above.
private primaryPageTargetPromise = new Promise<SDK.Target.Target>(res => {
this.primaryPageTargetPromiseCallback = res;
});
#traceEngineModel: TraceEngine.TraceModel.Model<typeof TraceEngine.Handlers.Migration.ENABLED_TRACE_HANDLERS>;
// Tracks the index of the trace that the user is currently viewing.
#traceEngineActiveTraceIndex = -1;
#threadTracksSource: ThreadTracksSource;
#sourceMapsResolver: SourceMapsResolver|null = null;
#onSourceMapsNodeNamesResolvedBound = this.#onSourceMapsNodeNamesResolved.bind(this);
constructor(threadTracksSource: ThreadTracksSource) {
super('timeline');
this.#threadTracksSource = threadTracksSource;
this.#minimapComponent = new TimelineMiniMap(threadTracksSource);
switch (threadTracksSource) {
case ThreadTracksSource.BOTH_ENGINES:
case ThreadTracksSource.NEW_ENGINE:
this.#traceEngineModel = TraceEngine.TraceModel.Model.createWithAllHandlers();
break;
default:
this.#traceEngineModel = TraceEngine.TraceModel.Model.createWithRequiredHandlersForMigration();
}
this.element.addEventListener('contextmenu', this.contextMenu.bind(this), false);
this.dropTarget = new UI.DropTarget.DropTarget(
this.element, [UI.DropTarget.Type.File, UI.DropTarget.Type.URI],
i18nString(UIStrings.dropTimelineFileOrUrlHere), this.handleDrop.bind(this));
this.recordingOptionUIControls = [];
this.state = State.Idle;
this.recordingPageReload = false;
this.millisecondsToRecordAfterLoadEvent = 5000;
this.toggleRecordAction =
(UI.ActionRegistry.ActionRegistry.instance().action('timeline.toggle-recording') as
UI.ActionRegistration.Action);
this.recordReloadAction =
(UI.ActionRegistry.ActionRegistry.instance().action('timeline.record-reload') as UI.ActionRegistration.Action);
this.#historyManager = new TimelineHistoryManager(threadTracksSource);
this.performanceModel = null;
this.traceLoadStart = null;
this.disableCaptureJSProfileSetting =
Common.Settings.Settings.instance().createSetting('timelineDisableJSSampling', false);
this.disableCaptureJSProfileSetting.setTitle(i18nString(UIStrings.disableJavascriptSamples));
this.captureLayersAndPicturesSetting =
Common.Settings.Settings.instance().createSetting('timelineCaptureLayersAndPictures', false);
this.captureLayersAndPicturesSetting.setTitle(i18nString(UIStrings.enableAdvancedPaint));
this.showScreenshotsSetting =
Common.Settings.Settings.instance().createSetting('timelineShowScreenshots', isNode ? false : true);
this.showScreenshotsSetting.setTitle(i18nString(UIStrings.screenshots));
this.showScreenshotsSetting.addChangeListener(this.updateOverviewControls, this);
this.showMemorySetting = Common.Settings.Settings.instance().createSetting('timelineShowMemory', false);
this.showMemorySetting.setTitle(i18nString(UIStrings.memory));
this.showMemorySetting.addChangeListener(this.onModeChanged, this);
const timelineToolbarContainer = this.element.createChild('div', 'timeline-toolbar-container');
this.panelToolbar = new UI.Toolbar.Toolbar('timeline-main-toolbar', timelineToolbarContainer);
this.panelToolbar.makeWrappable(true);
this.panelRightToolbar = new UI.Toolbar.Toolbar('', timelineToolbarContainer);
if (!isNode) {
this.createSettingsPane();
this.updateShowSettingsToolbarButton();
}
this.timelinePane = new UI.Widget.VBox();
this.timelinePane.show(this.element);
const topPaneElement = this.timelinePane.element.createChild('div', 'hbox');
topPaneElement.id = 'timeline-overview-panel';
this.#minimapComponent.show(topPaneElement);
this.#minimapComponent.addEventListener(
PerfUI.TimelineOverviewPane.Events.WindowChanged, this.onOverviewWindowChanged.bind(this));
this.statusPaneContainer = this.timelinePane.element.createChild('div', 'status-pane-container fill');
this.createFileSelector();
SDK.TargetManager.TargetManager.instance().addModelListener(
SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.Load, this.loadEventFired, this);
this.flameChart = new TimelineFlameChartView(this, threadTracksSource);
this.searchableViewInternal = new UI.SearchableView.SearchableView(this.flameChart, null);
this.searchableViewInternal.setMinimumSize(0, 100);
this.searchableViewInternal.element.classList.add('searchable-view');
this.searchableViewInternal.show(this.timelinePane.element);
this.flameChart.show(this.searchableViewInternal.element);
this.flameChart.setSearchableView(this.searchableViewInternal);
this.searchableViewInternal.hideWidget();
this.onModeChanged();
this.populateToolbar();
this.showLandingPage();
this.updateTimelineControls();
SDK.TargetManager.TargetManager.instance().addEventListener(
SDK.TargetManager.Events.SuspendStateChanged, this.onSuspendStateChanged, this);
if (Root.Runtime.experiments.isEnabled('timelineAsConsoleProfileResultPanel')) {
const profilerModels = SDK.TargetManager.TargetManager.instance().models(SDK.CPUProfilerModel.CPUProfilerModel);
for (const model of profilerModels) {
for (const message of model.registeredConsoleProfileMessages) {
this.consoleProfileFinished(message);
}
}
SDK.TargetManager.TargetManager.instance().observeModels(
SDK.CPUProfilerModel.CPUProfilerModel,
{
modelAdded: (model: SDK.CPUProfilerModel.CPUProfilerModel) => {
model.addEventListener(
SDK.CPUProfilerModel.Events.ConsoleProfileFinished, event => this.consoleProfileFinished(event.data));
},
modelRemoved: (_model: SDK.CPUProfilerModel.CPUProfilerModel) => {
},
},
);
}
SDK.TargetManager.TargetManager.instance().observeTargets({
targetAdded: (target: SDK.Target.Target) => {
if (target !== SDK.TargetManager.TargetManager.instance().primaryPageTarget()) {
return;
}
this.primaryPageTargetPromiseCallback(target);
},
targetRemoved: (_: SDK.Target.Target) => {},
});
}
static instance(opts: {
forceNew: boolean|null,
isNode: boolean,
threadTracksSource?: ThreadTracksSource,
}|undefined = {forceNew: null, isNode: false}): TimelinePanel {
const {forceNew, isNode: isNodeMode} = opts;
isNode = isNodeMode;
if (!timelinePanelInstance || forceNew) {
timelinePanelInstance = new TimelinePanel(opts.threadTracksSource || DEFAULT_THREAD_TRACKS_SOURCE);
}
return timelinePanelInstance;
}
override searchableView(): UI.SearchableView.SearchableView|null {
return this.searchableViewInternal;
}
override wasShown(): void {
super.wasShown();
UI.Context.Context.instance().setFlavor(TimelinePanel, this);
this.registerCSSFiles([timelinePanelStyles]);
// Record the performance tool load time.
Host.userMetrics.panelLoaded('timeline', 'DevTools.Launch.Timeline');
}
override willHide(): void {
UI.Context.Context.instance().setFlavor(TimelinePanel, null);
this.#historyManager.cancelIfShowing();
}
loadFromEvents(events: TraceEngine.TracingManager.EventPayload[]): void {
if (this.state !== State.Idle) {
return;
}
this.prepareToLoadTimeline();
this.loader = TimelineLoader.loadFromEvents(events, this);
}
getFlameChart(): TimelineFlameChartView {
return this.flameChart;
}
private loadFromCpuProfile(profile: Protocol.Profiler.Profile|null, title?: string): void {
if (this.state !== State.Idle) {
return;
}
this.prepareToLoadTimeline();
this.loader = TimelineLoader.loadFromCpuProfile(profile, this, title);
}
private onOverviewWindowChanged(
event: Common.EventTarget.EventTargetEvent<PerfUI.TimelineOverviewPane.WindowChangedEvent>): void {
if (!this.performanceModel) {
return;
}
const left = (event.data.startTime > 0) ? event.data.startTime : this.performanceModel.minimumRecordTime();
const right = Number.isFinite(event.data.endTime) ? event.data.endTime : this.performanceModel.maximumRecordTime();
this.performanceModel.setWindow({left, right}, /* animate */ true, event.data.breadcrumb);
TraceBounds.TraceBounds.BoundsManager.instance().setTimelineVisibleWindow(
TraceEngine.Helpers.Timing.traceWindowFromMilliSeconds(
TraceEngine.Types.Timing.MilliSeconds(left),
TraceEngine.Types.Timing.MilliSeconds(right),
),
{
shouldAnimate: true,
},
);
}
private onModelWindowChanged(event: Common.EventTarget.EventTargetEvent<WindowChangedEvent>): void {
const window = event.data.window;
this.#minimapComponent.setWindowTimes(window.left, window.right);
}
private setState(state: State): void {
this.state = state;
this.updateTimelineControls();
}
private createSettingCheckbox(setting: Common.Settings.Setting<boolean>, tooltip: string): UI.Toolbar.ToolbarItem {
const checkboxItem = new UI.Toolbar.ToolbarSettingCheckbox(setting, tooltip);
this.recordingOptionUIControls.push(checkboxItem);
return checkboxItem;
}
private populateToolbar(): void {
// Record
this.panelToolbar.appendToolbarItem(UI.Toolbar.Toolbar.createActionButton(this.toggleRecordAction));
this.panelToolbar.appendToolbarItem(UI.Toolbar.Toolbar.createActionButton(this.recordReloadAction));
this.clearButton = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.clear), 'clear');
this.clearButton.addEventListener(UI.Toolbar.ToolbarButton.Events.Click, () => this.onClearButton());
this.panelToolbar.appendToolbarItem(this.clearButton);
// Load / Save
this.loadButton = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.loadProfile), 'import');
this.loadButton.addEventListener(UI.Toolbar.ToolbarButton.Events.Click, () => {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.PerfPanelTraceImported);
this.selectFileToLoad();
});
this.saveButton = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.saveProfile), 'download');
this.saveButton.addEventListener(UI.Toolbar.ToolbarButton.Events.Click, _event => {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.PerfPanelTraceExported);
void this.saveToFile();
});
this.panelToolbar.appendSeparator();
this.panelToolbar.appendToolbarItem(this.loadButton);
this.panelToolbar.appendToolbarItem(this.saveButton);
// History
this.panelToolbar.appendSeparator();
this.panelToolbar.appendToolbarItem(this.#historyManager.button());
this.panelToolbar.registerCSSFiles([historyToolbarButtonStyles]);
this.panelToolbar.appendSeparator();
// View
this.panelToolbar.appendSeparator();
if (!isNode) {
this.showScreenshotsToolbarCheckbox =
this.createSettingCheckbox(this.showScreenshotsSetting, i18nString(UIStrings.captureScreenshots));
this.panelToolbar.appendToolbarItem(this.showScreenshotsToolbarCheckbox);
}
this.showMemoryToolbarCheckbox =
this.createSettingCheckbox(this.showMemorySetting, i18nString(UIStrings.showMemoryTimeline));
this.panelToolbar.appendToolbarItem(this.showMemoryToolbarCheckbox);
// GC
this.panelToolbar.appendToolbarItem(UI.Toolbar.Toolbar.createActionButtonForId('components.collect-garbage'));
// Settings
if (!isNode) {
this.panelRightToolbar.appendSeparator();
this.panelRightToolbar.appendToolbarItem(this.showSettingsPaneButton);
}
}
private createSettingsPane(): void {
this.showSettingsPaneSetting =
Common.Settings.Settings.instance().createSetting('timelineShowSettingsToolbar', false);
this.showSettingsPaneButton = new UI.Toolbar.ToolbarSettingToggle(
this.showSettingsPaneSetting, 'gear', i18nString(UIStrings.captureSettings), 'gear-filled');
SDK.NetworkManager.MultitargetNetworkManager.instance().addEventListener(
SDK.NetworkManager.MultitargetNetworkManager.Events.ConditionsChanged, this.updateShowSettingsToolbarButton,
this);
SDK.CPUThrottlingManager.CPUThrottlingManager.instance().addEventListener(
SDK.CPUThrottlingManager.Events.RateChanged, this.updateShowSettingsToolbarButton, this);
SDK.CPUThrottlingManager.CPUThrottlingManager.instance().addEventListener(
SDK.CPUThrottlingManager.Events.HardwareConcurrencyChanged, this.updateShowSettingsToolbarButton, this);
this.disableCaptureJSProfileSetting.addChangeListener(this.updateShowSettingsToolbarButton, this);
this.captureLayersAndPicturesSetting.addChangeListener(this.updateShowSettingsToolbarButton, this);
this.settingsPane = new UI.Widget.HBox();
this.settingsPane.element.classList.add('timeline-settings-pane');
this.settingsPane.show(this.element);
const captureToolbar = new UI.Toolbar.Toolbar('', this.settingsPane.element);
captureToolbar.element.classList.add('flex-auto');
captureToolbar.makeVertical();
captureToolbar.appendToolbarItem(this.createSettingCheckbox(
this.disableCaptureJSProfileSetting, i18nString(UIStrings.disablesJavascriptSampling)));
captureToolbar.appendToolbarItem(
this.createSettingCheckbox(this.captureLayersAndPicturesSetting, i18nString(UIStrings.capturesAdvancedPaint)));
const throttlingPane = new UI.Widget.VBox();
throttlingPane.element.classList.add('flex-auto');
throttlingPane.show(this.settingsPane.element);
const cpuThrottlingToolbar = new UI.Toolbar.Toolbar('', throttlingPane.element);
cpuThrottlingToolbar.appendText(i18nString(UIStrings.cpu));
this.cpuThrottlingSelect = MobileThrottling.ThrottlingManager.throttlingManager().createCPUThrottlingSelector();
cpuThrottlingToolbar.appendToolbarItem(this.cpuThrottlingSelect);
const networkThrottlingToolbar = new UI.Toolbar.Toolbar('', throttlingPane.element);
networkThrottlingToolbar.appendText(i18nString(UIStrings.network));
this.networkThrottlingSelect = this.createNetworkConditionsSelect();
networkThrottlingToolbar.appendToolbarItem(this.networkThrottlingSelect);
const hardwareConcurrencyPane = new UI.Widget.VBox();
hardwareConcurrencyPane.element.classList.add('flex-auto');
hardwareConcurrencyPane.show(this.settingsPane.element);
const {toggle, input, reset, warning} =
MobileThrottling.ThrottlingManager.throttlingManager().createHardwareConcurrencySelector();
const concurrencyThrottlingToolbar = new UI.Toolbar.Toolbar('', hardwareConcurrencyPane.element);
concurrencyThrottlingToolbar.registerCSSFiles([timelinePanelStyles]);
input.element.classList.add('timeline-concurrency-input');
concurrencyThrottlingToolbar.appendToolbarItem(toggle);
concurrencyThrottlingToolbar.appendToolbarItem(input);
concurrencyThrottlingToolbar.appendToolbarItem(reset);
concurrencyThrottlingToolbar.appendToolbarItem(warning);
this.showSettingsPaneSetting.addChangeListener(this.updateSettingsPaneVisibility.bind(this));
this.updateSettingsPaneVisibility();
}
private createNetworkConditionsSelect(): UI.Toolbar.ToolbarComboBox {
const toolbarItem = new UI.Toolbar.ToolbarComboBox(null, i18nString(UIStrings.networkConditions));
toolbarItem.setMaxWidth(140);
MobileThrottling.ThrottlingManager.throttlingManager().decorateSelectWithNetworkThrottling(
toolbarItem.selectElement());
return toolbarItem;
}
private prepareToLoadTimeline(): void {
console.assert(this.state === State.Idle);
this.setState(State.Loading);
if (this.performanceModel) {
this.performanceModel = null;
}
}
private createFileSelector(): void {
if (this.fileSelectorElement) {
this.fileSelectorElement.remove();
}
this.fileSelectorElement = UI.UIUtils.createFileSelectorElement(this.loadFromFile.bind(this));
this.timelinePane.element.appendChild(this.fileSelectorElement);
}
private contextMenu(event: Event): void {
const contextMenu = new UI.ContextMenu.ContextMenu(event);
contextMenu.appendItemsAtLocation('timelineMenu');
void contextMenu.show();
}
async saveToFile(): Promise<void> {
if (this.state !== State.Idle) {
return;
}
const performanceModel = this.performanceModel;
if (!performanceModel) {
return;
}
const traceEvents = this.#traceEngineModel.traceEvents(this.#traceEngineActiveTraceIndex);
const metadata = this.#traceEngineModel.metadata(this.#traceEngineActiveTraceIndex);
if (!traceEvents) {
return;
}
const traceStart = Platform.DateUtilities.toISO8601Compact(new Date());
let fileName: Platform.DevToolsPath.RawPathString;
if (metadata?.dataOrigin === TraceEngine.Types.File.DataOrigin.CPUProfile) {
fileName = `CPU-${traceStart}.cpuprofile` as Platform.DevToolsPath.RawPathString;
} else {
fileName = `Trace-${traceStart}.json` as Platform.DevToolsPath.RawPathString;
}
try {
const handler = await window.showSaveFilePicker({
suggestedName: fileName,
});
const encoder = new TextEncoder();
// TODO(crbug.com/1456818): Extract this logic and add more tests.
let traceAsString;
if (metadata?.dataOrigin === TraceEngine.Types.File.DataOrigin.CPUProfile) {
const profileEvent = traceEvents.find(e => e.name === 'CpuProfile');
if (!profileEvent || !profileEvent.args?.data) {
return;
}
const profileEventData = profileEvent.args?.data;
if (profileEventData.hasOwnProperty('cpuProfile')) {
// TODO(crbug.com/1456799): Currently use a hack way because we can't differentiate
// cpuprofile from trace events when loading a file.
// The loader will directly add the fake trace created from CpuProfile to the tracingModel.
// And there is where the old saving logic saves the cpuprofile.
// This will be solved when the CPUProfileHandler is done. Then we can directly get it
// from the new traceEngine
const profile = (profileEventData as {cpuProfile: Protocol.Profiler.Profile}).cpuProfile;
traceAsString = cpuprofileJsonGenerator(profile as Protocol.Profiler.Profile);
}
} else {
const formattedTraceIter = traceJsonGenerator(traceEvents, metadata);
traceAsString = Array.from(formattedTraceIter).join('');
}
const buffer = encoder.encode(traceAsString);
const writable = await handler.createWritable();
await writable.write(buffer);
await writable.close();
} catch (error) {
console.error(error.stack);
if (error.name === 'AbortError') {
// The user cancelled the action, so this is not an error we need to report.
return;
}
Common.Console.Console.instance().error(
i18nString(UIStrings.failedToSaveTimelineSS, {PH1: error.message, PH2: error.name}));
}
}
async showHistory(): Promise<void> {
const recordingData = await this.#historyManager.showHistoryDropDown();
if (recordingData && recordingData.legacyModel !== this.performanceModel) {
this.setModel(recordingData.legacyModel, /* exclusiveFilter= */ null, recordingData.traceParseDataIndex);
}
}
navigateHistory(direction: number): boolean {
const recordingData = this.#historyManager.navigate(direction);
if (recordingData && recordingData.legacyModel !== this.performanceModel) {
this.setModel(recordingData.legacyModel, /* exclusiveFilter= */ null, recordingData.traceParseDataIndex);
}
return true;
}
selectFileToLoad(): void {
if (this.fileSelectorElement) {
this.fileSelectorElement.click();
}
}
async loadFromFile(file: File): Promise<void> {
if (this.state !== State.Idle) {
return;
}
this.prepareToLoadTimeline();
this.loader = await TimelineLoader.loadFromFile(file, this);
this.createFileSelector();
}
async loadFromURL(url: Platform.DevToolsPath.UrlString): Promise<void> {
if (this.state !== State.Idle) {
return;
}
this.prepareToLoadTimeline();
this.loader = await TimelineLoader.loadFromURL(url, this);
}
private updateOverviewControls(): void {
const traceParsedData = this.#traceEngineModel.traceParsedData(this.#traceEngineActiveTraceIndex);
const isCpuProfile = this.#traceEngineModel.metadata(this.#traceEngineActiveTraceIndex)?.dataOrigin ===
TraceEngine.Types.File.DataOrigin.CPUProfile;
this.#minimapComponent.setData({
performanceModel: this.performanceModel,
traceParsedData,
isCpuProfile,
settings: {
showScreenshots: this.showScreenshotsSetting.get(),
showMemory: this.showMemorySetting.get(),
},
});
}
private onModeChanged(): void {
this.updateOverviewControls();
this.doResize();
this.select(null);
}
private updateSettingsPaneVisibility(): void {
if (isNode) {
return;
}
if (this.showSettingsPaneSetting.get()) {
this.settingsPane.showWidget();
} else {
this.settingsPane.hideWidget();
}
}
private updateShowSettingsToolbarButton(): void {
const messages: string[] = [];
if (SDK.CPUThrottlingManager.CPUThrottlingManager.instance().cpuThrottlingRate() !== 1) {
messages.push(i18nString(UIStrings.CpuThrottlingIsEnabled));
}
if (MobileThrottling.ThrottlingManager.throttlingManager().hardwareConcurrencyOverrideEnabled) {
messages.push(i18nString(UIStrings.HardwareConcurrencyIsEnabled));
}
if (SDK.NetworkManager.MultitargetNetworkManager.instance().isThrottling()) {
messages.push(i18nString(UIStrings.NetworkThrottlingIsEnabled));
}
if (this.captureLayersAndPicturesSetting.get()) {
messages.push(i18nString(UIStrings.SignificantOverheadDueToPaint));
}
if (this.disableCaptureJSProfileSetting.get()) {
messages.push(i18nString(UIStrings.JavascriptSamplingIsDisabled));
}
this.showSettingsPaneButton.setDefaultWithRedColor(messages.length > 0);
this.showSettingsPaneButton.setToggleWithRedColor(messages.length > 0);
if (messages.length) {
const tooltipElement = document.createElement('div');
messages.forEach(message => {
tooltipElement.createChild('div').textContent = message;
});
this.showSettingsPaneButton.setTitle(tooltipElement.textContent || '');
} else {
this.showSettingsPaneButton.setTitle(i18nString(UIStrings.captureSettings));
}
}
private setUIControlsEnabled(enabled: boolean): void {
this.recordingOptionUIControls.forEach(control => control.setEnabled(enabled));
}
async #evaluateInspectedURL(): Promise<Platform.DevToolsPath.UrlString> {
if (!this.controller) {
return Platform.DevToolsPath.EmptyUrlString;
}
// target.inspectedURL is reliably populated, however it lacks any url #hash
const inspectedURL = this.controller.primaryPageTarget.inspectedURL();
// We'll use the navigationHistory to acquire the current URL including hash
const resourceTreeModel = this.controller.primaryPageTarget.model(SDK.ResourceTreeModel.ResourceTreeModel);
const navHistory = resourceTreeModel && await resourceTreeModel.navigationHistory();
if (!resourceTreeModel || !navHistory) {
return inspectedURL;
}
const {currentIndex, entries} = navHistory;
const navigationEntry = entries[currentIndex];
return navigationEntry.url as Platform.DevToolsPath.UrlString;
}
async #navigateToAboutBlank(): Promise<void> {
const aboutBlankNavigationComplete = new Promise<void>(async (resolve, reject) => {
if (!this.controller) {
reject('Could not find TimelineController');
return;
}
const target = this.controller.primaryPageTarget;
const resourceModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
if (!resourceModel) {
reject('Could not load resourceModel');
return;
}
// To clear out the page and any state from prior test runs, we
// navigate to about:blank before initiating the trace recording.
// Once we have navigated to about:blank, we start recording and
// then navigate to the original page URL, to ensure we profile the
// page load.
function waitForAboutBlank(event: Common.EventTarget.EventTargetEvent<SDK.ResourceTreeModel.ResourceTreeFrame>):
void {
if (event.data.url === 'about:blank') {
resolve();
} else {
reject(`Unexpected navigation to ${event.data.url}`);
}
resourceModel?.removeEventListener(SDK.ResourceTreeModel.Events.FrameNavigated, waitForAboutBlank);
}
resourceModel.addEventListener(SDK.ResourceTreeModel.Events.FrameNavigated, waitForAboutBlank);
await resourceModel.navigate('about:blank' as Platform.DevToolsPath.UrlString);
});
await aboutBlankNavigationComplete;
}
async #startCPUProfilingRecording(): Promise<void> {
try {
// Only profile the first target devtools connects to. If we profile all target, but this will cause some bugs
// like time for the function is calculated wrong, because the profiles will be concated and sorted together,
// so the total time will be amplified.
// Multiple targets problem might happen when you inspect multiple node servers on different port at same time,
// or when you let DevTools listen to both locolhost:9229 & 127.0.0.1:9229.
const firstNodeTarget =
SDK.TargetManager.TargetManager.instance().targets().find(target => target.type() === SDK.Target.Type.Node);
if (!firstNodeTarget) {
throw new Error('Could not load any Node target.');
}
if (firstNodeTarget) {
this.cpuProfiler = firstNodeTarget.model(SDK.CPUProfilerModel.CPUProfilerModel);
}
this.setUIControlsEnabled(false);
this.hideLandingPage();
if (!this.cpuProfiler) {
throw new Error('No Node target is found.');
}
await SDK.TargetManager.TargetManager.instance().suspendAllTargets('performance-timeline');
await this.cpuProfiler.startRecording();
this.recordingStarted();
} catch (e) {
await this.recordingFailed(e.message);
}
}
async #startTraceRecording(): Promise<void> {
try {
// We record against the root target, but also need to use the
// primaryPageTarget to inspect the current URL. For more info, see the
// JSDoc comment on the TimelineController constructor.
const rootTarget = SDK.TargetManager.TargetManager.instance().rootTarget();
const primaryPageTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
if (!primaryPageTarget) {
throw new Error('Could not load primary page target.');
}
if (!rootTarget) {
throw new Error('Could not load root target.');
}
if (UIDevtoolsUtils.isUiDevTools()) {
this.controller = new UIDevtoolsController(rootTarget, primaryPageTarget, this);
} else {
this.controller = new TimelineController(rootTarget, primaryPageTarget, this);
}
this.setUIControlsEnabled(false);
this.hideLandingPage();
if (!this.controller) {
throw new Error('Could not create Timeline controller');
}
const urlToTrace = await this.#evaluateInspectedURL();
// If we are doing "Reload & record", we first navigate the page to
// about:blank. This is to ensure any data on the timeline from any
// previous performance recording is lost, avoiding the problem where a
// timeline will show data & screenshots from a previous page load that
// was not relevant.
if (this.recordingPageReload) {
await this.#navigateToAboutBlank();
}
const recordingOptions = {
enableJSSampling: !this.disableCaptureJSProfileSetting.get(),
capturePictures: this.captureLayersAndPicturesSetting.get(),
captureFilmStrip: this.showScreenshotsSetting.get(),
};
// Order is important here: we tell the controller to start recording, which enables tracing.
const response = await this.controller.startRecording(recordingOptions);
if (response.getError()) {
throw new Error(response.getError());
}
// Once we get here, we know tracing is active.
// This is when, if the user has hit "Reload & Record" that we now need to navigate to the original URL.
// If the user has just hit "record", we don't do any navigating.
const recordingConfig = this.recordingPageReload ? {navigateToUrl: urlToTrace} : undefined;
this.recordingStarted(recordingConfig);
} catch (e) {
await this.recordingFailed(e.message);
}
}
private async startRecording(): Promise<void> {
console.assert(!this.statusPane, 'Status pane is already opened.');
this.setState(State.StartPending);
this.showRecordingStarted();
if (isNode) {
await this.#startCPUProfilingRecording();
} else {
await this.#startTraceRecording();
}
}
private async stopRecording(): Promise<void> {
if (this.statusPane) {
this.statusPane.finish();
this.statusPane.updateStatus(i18nString(UIStrings.stoppingTimeline));
this.statusPane.updateProgressBar(i18nString(UIStrings.received), 0);
}
this.setState(State.StopPending);
if (this.controller) {
this.performanceModel = this.controller.getPerformanceModel();
await this.controller.stopRecording();
this.setUIControlsEnabled(true);
await this.controller.dispose();
this.controller = null;
return;
}
if (this.cpuProfiler) {
const profile = await this.cpuProfiler.stopRecording();
this.setState(State.Idle);
this.loadFromCpuProfile(profile);
this.setUIControlsEnabled(true);
this.cpuProfiler = null;
await SDK.TargetManager.TargetManager.instance().resumeAllTargets();
}
}
private async recordingFailed(error: string, rawEvents?: TraceEngine.Types.TraceEvents.TraceEventData[]):
Promise<void> {
if (this.statusPane) {
this.statusPane.remove();
}
this.statusPane = new StatusPane(
{
description: error,
buttonText: i18nString(UIStrings.close),
buttonDisabled: false,
showProgress: undefined,
showTimer: undefined,
},
// When recording failed, we should load null to go back to the landing page.
async () => {
this.statusPane?.remove();
await this.loadingComplete(/* tracingModel= */ null, /* exclusiveFilter= */ null, /* isCpuProfile= */ false);
});
this.statusPane.showPane(this.statusPaneContainer);
this.statusPane.updateStatus(i18nString(UIStrings.recordingFailed));
if (rawEvents) {
this.statusPane.enableDownloadOfEvents(rawEvents);
}
this.setState(State.RecordingFailed);
this.performanceModel = null;
this.traceLoadStart = null;
this.setUIControlsEnabled(true);
if (this.controller) {
await this.controller.dispose();
this.controller = null;
}
// Ensure we resume all targets, otherwise DevTools remains unresponsive in the event of an error.
void SDK.TargetManager.TargetManager.instance().resumeAllTargets();
}
private onSuspendStateChanged(): void {
this.updateTimelineControls();
}
private consoleProfileFinished(data: SDK.CPUProfilerModel.ProfileFinishedData): void {
this.loadFromCpuProfile(data.cpuProfile, data.title);
void UI.InspectorView.InspectorView.instance().showPanel('timeline');
}
private updateTimelineControls(): void {
const state = State;
this.toggleRecordAction.setToggled(this.state === state.Recording);
this.toggleRecordAction.setEnabled(this.state === state.Recording || this.state === state.Idle);
this.recordReloadAction.setEnabled(isNode ? false : this.state === state.Idle);
this.#historyManager.setEnabled(this.state === state.Idle);
this.clearButton.setEnabled(this.state === state.Idle);
this.panelToolbar.setEnabled(this.state !== state.Loading);
this.panelRightToolbar.setEnabled(this.state !== state.Loading);
this.dropTarget.setEnabled(this.state === state.Idle);
this.loadButton.setEnabled(this.state === state.Idle);
this.saveButton.setEnabled(this.state === state.Idle && Boolean(this.performanceModel));
}
async toggleRecording(): Promise<void> {
if (this.state === State.Idle) {
this.recordingPageReload = false;
await this.startRecording();
Host.userMetrics.actionTaken(Host.UserMetrics.Action.TimelineStarted);
} else if (this.state === State.Recording) {
await this.stopRecording();
}
}
recordReload(): void {
if (this.state !== State.Idle) {
return;
}
this.recordingPageReload = true;
void this.startRecording();
Host.userMetrics.actionTaken(Host.UserMetrics.Action.TimelinePageReloadStarted);
}
private onClearButton(): void {
this.#historyManager.clear();
this.clear();
}
private clear(): void {
if (this.statusPane) {
this.statusPane.remove();
}
this.showLandingPage();
this.reset();
}
private reset(): void {
PerfUI.LineLevelProfile.Performance.instance().reset();
if (this.performanceModel) {
this.performanceModel.removeEventListener(Events.NamesResolved, this.updateModelAndFlameChart, this);
}
if (this.#sourceMapsResolver) {
this.#sourceMapsResolver.removeEventListener(
NodeNamesUpdated.eventName, this.#onSourceMapsNodeNamesResolvedBound);
this.#sourceMapsResolver.uninstall();
this.#sourceMapsResolver = null;
}
this.setModel(null);
}
private applyFilters(
model: PerformanceModel,
exclusiveFilter: TimelineModel.TimelineModelFilter.TimelineModelFilter|null = null): void {
if (model.timelineModel().isGenericTrace() || Root.Runtime.experiments.isEnabled('timelineShowAllEvents')) {
return;
}
model.setFilters(exclusiveFilter ? [exclusiveFilter] : [TimelineUIUtils.visibleEventsFilter()]);
}
setModel(
model: PerformanceModel|null, exclusiveFilter: TimelineModel.TimelineModelFilter.TimelineModelFilter|null = null,
traceEngineIndex: number = -1): void {
if (this.performanceModel) {
this.performanceModel.removeEventListener(Events.WindowChanged, this.onModelWindowChanged, this);
}
this.performanceModel = model;
if (model) {
this.searchableViewInternal.showWidget();
this.applyFilters(model, exclusiveFilter);
} else {
this.searchableViewInternal.hideWidget();
}
this.#traceEngineActiveTraceIndex = traceEngineIndex;
const traceParsedData = this.#traceEngineModel.traceParsedData(this.#traceEngineActiveTraceIndex);
const isCpuProfile = this.#traceEngineModel.metadata(this.#traceEngineActiveTraceIndex)?.dataOrigin ===
TraceEngine.Types.File.DataOrigin.CPUProfile;
if (traceParsedData) {
TraceBounds.TraceBounds.BoundsManager.instance({
forceNew: true,
initialBounds: traceParsedData.Meta.traceBounds,
});
}
this.flameChart.setModel(model, traceParsedData, isCpuProfile);
this.updateOverviewControls();
this.#minimapComponent.reset();
if (model) {
model.addEventListener(Events.WindowChanged, this.onModelWindowChanged, this);
this.#minimapComponent.setBounds(
TraceEngine.Types.Timing.MilliSeconds(model.timelineModel().minimumRecordTime()),
TraceEngine.Types.Timing.MilliSeconds(model.timelineModel().maximumRecordTime()));
PerfUI.LineLevelProfile.Performance.instance().reset();
for (const profile of model.timelineModel().cpuProfiles()) {
PerfUI.LineLevelProfile.Performance.instance().appendCPUProfile(profile.cpuProfileData, profile.target);
}
this.flameChart.setSelection(null);
const {left, right} = model.calculateWindowForMainThreadActivity();
model.setWindow({left, right});
this.#minimapComponent.setWindowTimes(left, right);
if (traceParsedData) {
TraceBounds.TraceBounds.BoundsManager.instance().setTimelineVisibleWindow(
TraceEngine.Helpers.Timing.traceWindowFromMilliSeconds(left, right),
);
}
}
this.updateOverviewControls();
if (this.flameChart) {
this.flameChart.resizeToPreferredHeights();
}
this.updateTimelineControls();
}
private recordingStarted(config?: {navigateToUrl: Platform.DevToolsPath.UrlString}): void {
if (config && this.recordingPageReload && this.controller) {
// If the user hit "Reload & record", by this point we have:
// 1. Navigated to about:blank
// 2. Initiated tracing.
// We therefore now should navigate back to the original URL that the user wants to profile.
const resourceModel = this.controller?.primaryPageTarget.model(SDK.ResourceTreeModel.ResourceTreeModel);
if (!resourceModel) {
void this.recordingFailed('Could not navigate to original URL');
return;
}
// We don't need to await this because we are purposefully showing UI
// progress as the page loads & tracing is underway.
void resourceModel.navigate(config.navigateToUrl);
}
this.reset();
this.setState(State.Recording);
this.showRecordingStarted();
if (this.statusPane) {
this.statusPane.enableAndFocusButton();
this.statusPane.updateStatus(i18nString(UIStrings.profiling));
this.statusPane.updateProgressBar(i18nString(UIStrings.bufferUsage), 0);
this.statusPane.startTimer();
}
this.hideLandingPage();
}
recordingProgress(usage: number): void {
if (this.statusPane) {
this.statusPane.updateProgressBar(i18nString(UIStrings.bufferUsage), usage * 100);
}
}
private showLandingPage(): void {
this.updateSettingsPaneVisibility();
if (this.landingPage) {
this.landingPage.show(this.statusPaneContainer);
return;
}
function encloseWithTag(tagName: string, contents: string): HTMLElement {
const e = document.createElement(tagName);
e.textContent = contents;
return e;
}
const learnMoreNode = UI.XLink.XLink.create(
'https://developer.chrome.com/docs/devtools/evaluate-performance/', i18nString(UIStrings.learnmore));
const recordKey = encloseWithTag(
'b',
UI.ShortcutRegistry.ShortcutRegistry.instance().shortcutsForAction('timeline.toggle-recording')[0].title());
const reloadKey = encloseWithTag(
'b', UI.ShortcutRegistry.ShortcutRegistry.instance().shortcutsForAction('timeline.record-reload')[0].title());
const navigateNode = encloseWithTag('b', i18nString(UIStrings.wasd));
this.landingPage = new UI.Widget.VBox();
this.landingPage.contentElement.classList.add('timeline-landing-page', 'fill');
const centered = this.landingPage.contentElement.createChild('div');
const recordButton = UI.UIUtils.createInlineButton(UI.Toolbar.Toolbar.createActionButton(this.toggleRecordAction));
const reloadButton =
UI.UIUtils.createInlineButton(UI.Toolbar.Toolbar.createActionButtonForId('timeline.record-reload'));
centered.createChild('p').appendChild(i18n.i18n.getFormatLocalizedString(
str_, UIStrings.clickTheRecordButtonSOrHitSTo, {PH1: recordButton, PH2: recordKey}));
centered.createChild('p').appendChild(i18n.i18n.getFormatLocalizedString(
str_, UIStrings.clickTheReloadButtonSOrHitSTo, {PH1: reloadButton, PH2: reloadKey}));
centered.createChild('p').appendChild(i18n.i18n.getFormatLocalizedString(
str_, UIStrings.afterRecordingSelectAnAreaOf, {PH1: navigateNode, PH2: learnMoreNode}));
if (isNode) {
const previewSection = new PanelFeedback.PanelFeedback.PanelFeedback();
previewSection.data = {
feedbackUrl: 'https://bugs.chromium.org/p/chromium/issues/detail?id=1354548' as Platform.DevToolsPath.UrlString,
quickStartUrl: 'https://developer.chrome.com/blog/js-profiler-deprecation/' as Platform.DevToolsPath.UrlString,
quickStartLinkText: i18nString(UIStrings.learnmore),
};
centered.appendChild(previewSection);
const feedbackButton = new PanelFeedback.FeedbackButton.FeedbackButton();
feedbackButton.data = {
feedbackUrl: 'https://bugs.chromium.org/p/chromium/issues/detail?id=1354548' as Platform.DevToolsPath.UrlString,
};
centered.appendChild(feedbackButton);
}
this.landingPage.show(this.statusPaneContainer);
}
private hideLandingPage(): void {
this.landingPage.detach();
// Hide pane settings in trace view to conserve UI space, but preserve underlying setting.
this.showSettingsPaneButton?.setToggled(false);
this.settingsPane?.hideWidget();
}
async loadingStarted(): Promise<void> {
this.hideLandingPage();
if (this.statusPane) {
this.statusPane.remove();
}
this.statusPane = new StatusPane(
{
showProgress: true,
showTimer: undefined,
buttonDisabled: undefined,
buttonText: undefined,
description: undefined,
},
() => this.cancelLoading());
this.statusPane.showPane(this.statusPaneContainer);
this.statusPane.updateStatus(i18nString(UIStrings.loadingProfile));
// FIXME: make loading from backend cancelable as well.
if (!this.loader) {
this.statusPane.finish();
}
this.traceLoadStart = performance.now();
await this.loadingProgress(0);
}
async loadingProgress(progress?: number): Promise<void> {
if (typeof progress === 'number' && this.statusPane) {
this.statusPane.updateProgressBar(i18nString(UIStrings.received), progress * 100);
}
}
async processingStarted(): Promise<void> {
if (this.statusPane) {
this.statusPane.updateStatus(i18nString(UIStrings.processingProfile));
}
}
#onSourceMapsNodeNamesResolved(): void {
this.updateModelAndFlameChart();
}
updateModelAndFlameChart(): void {
if (!this.performanceModel) {
return;
}
this.setModel(this.performanceModel, null, this.#traceEngineActiveTraceIndex);
this.flameChart.updateColorMapper();
}
async loadingComplete(
tracingModel: TraceEngine.Legacy.TracingModel|null,
exclusiveFilter: TimelineModel.TimelineModelFilter.TimelineModelFilter|null = null,
isCpuProfile: boolean): Promise<void> {
this.#traceEngineModel.resetProcessor();
SourceMapsResolver.clearResolvedNodeNames();
delete this.loader;
// If the user just recorded this trace via the record UI, the state will
// be StopPending. Whereas if it was an existing trace they loaded via a
// file, it will be State.Loading. This means we can tell the recording is
// fresh by checking the state value.
const recordingIsFresh = this.state === State.StopPending;
this.setState(State.Idle);
if (!tracingModel) {
this.clear();
return;
}
if (!this.performanceModel) {
this.performanceModel = new PerformanceModel();
}
try {
// Run the new engine in parallel with the parsing done in the performanceModel
await Promise.all([
// Calling setTracingModel now and setModel so much later, leads to several problems due to addEventListener order being unexpected
// TODO(paulirish): Resolve this, or just wait for the death of tracingModel. :)
this.performanceModel.setTracingModel(tracingModel, recordingIsFresh, {
// If we are using the new engine for everything, we do not need to
// resolve sourcemaps within the old engine.
resolveSourceMaps: this.#threadTracksSource !== ThreadTracksSource.NEW_ENGINE,
isCpuProfile,
}),
this.#executeNewTraceEngine(
tracingModel, recordingIsFresh, isCpuProfile, this.performanceModel.recordStartTime()),
]);
// This code path is only executed when a new trace is recorded/imported,
// so we know that the active index will be the size of the model because
// the newest trace will be automatically set to active.
this.#traceEngineActiveTraceIndex = this.#traceEngineModel.size() - 1;
this.setModel(this.performanceModel, exclusiveFilter, this.#traceEngineActiveTraceIndex);
if (this.statusPane) {
this.statusPane.remove();
}
this.statusPane = null;
const traceData = this.#traceEngineModel.traceParsedData(this.#traceEngineActiveTraceIndex);
if (!traceData) {
throw new Error(`Could not get trace data at index ${this.#traceEngineActiveTraceIndex}`);
}
// If we are running the old engine for sync tracks, ensure we listen to
// and update the flamechart on any sourcemap resolution.
if (this.#threadTracksSource !== ThreadTracksSource.NEW_ENGINE &&
!this.performanceModel.hasEventListeners(Events.NamesResolved)) {
this.performanceModel.addEventListener(Events.NamesResolved, this.updateModelAndFlameChart, this);
}
// Otherwise if we are running the new engine, instantiate it with the
// trace data and update the flamechart on any sourcemap resolution
if (this.#threadTracksSource !== ThreadTracksSource.OLD_ENGINE) {
this.#sourceMapsResolver = new SourceMapsResolver(traceData);
this.#sourceMapsResolver.addEventListener(NodeNamesUpdated.eventName, this.#onSourceMapsNodeNamesResolvedBound);
await this.#sourceMapsResolver.install();
}
// We store the Performance Model and the index of the active trace.
// However we also pass in the full trace data because we use it to build
// the preview overview thumbnail of the trace that gets shown in the UI.
this.#historyManager.addRecording({
data: {
legacyModel: this.performanceModel,
traceParseDataIndex: this.#traceEngineActiveTraceIndex,
},
filmStripForPreview: TraceEngine.Extras.FilmStrip.fromTraceData(traceData),
traceParsedData: traceData,
});
if (this.#minimapComponent.breadcrumbsActivated) {
this.#minimapComponent.addInitialBreadcrumb();
}
} catch (error) {
// Try to get the raw events: if we errored during the parsing stage, it
// is useful to get access to the raw events to download the trace. This
// allows us to debug crashes!
// Because we don't know where the error came from, we wrap it in a
// try-catch to protect against the tracing model erroring.
let rawEvents: TraceEngine.Types.TraceEvents.TraceEventData[]|undefined = undefined;
try {
rawEvents = tracingModel.allRawEvents() as unknown as TraceEngine.Types.TraceEvents.TraceEventData[];
} catch {
}
void this.recordingFailed(error.message, rawEvents);
console.error(error);
} finally {
this.recordTraceLoadMetric();
}
}
recordTraceLoadMetric(): void {
if (!this.traceLoadStart) {
return;
}
const start = this.traceLoadStart;
// Right *now* is the end of trace parsing and model building, but the flamechart rendering
// isn't complete yet. To capture that we'll do a rAF+setTimeout to give the most accurate timestamp
// for the first paint of the flamechart
requestAnimationFrame(() => {
setTimeout(() => {
const end = performance.now();
const measure = performance.measure('TraceLoad', {start, end});
Host.userMetrics.performanceTraceLoad(measure);
}, 0);
});
}
/**
* Call into the new Trace Engine to parse the data. We don't currently do
* anything with this data, but we are calling it here to ensure that all the
* pieces are connected together and we are able to parse data in the new engine
* from OPP.
*
* The trace engine model runs the parsing in a worker, so this should not
* impact the main thread, as we `void` it to ensure we don't want for the
* parsing to complete.
**/
async #executeNewTraceEngine(
tracingModel: TraceEngine.Legacy.TracingModel, isFreshRecording: boolean, isCpuProfile: boolean,
recordStartTime?: number): Promise<void> {
const shouldGatherMetadata = isFreshRecording && !isCpuProfile;
const metadata = shouldGatherMetadata ? await TraceEngine.Extras.Metadata.forNewRecording(recordStartTime) : {};
metadata.dataOrigin =
isCpuProfile ? TraceEngine.Types.File.DataOrigin.CPUProfile : TraceEngine.Types.File.DataOrigin.TraceEvents;
return this.#traceEngineModel.parse(
// OPP's data layer uses `EventPayload` as the type to represent raw JSON from the trace.
// When we pass this into the new data engine, we need to tell TS to use the new TraceEventData type.
tracingModel.allRawEvents() as unknown as TraceEngine.Types.TraceEvents.TraceEventData[],
{
metadata,
isFreshRecording,
},
);
}
loadingCompleteForTest(): void {
// Not implemented, added only for allowing the TimelineTestRunner
// to be in sync when a trace load is finished.
}
private showRecordingStarted(): void {
if (this.statusPane) {
return;
}
this.statusPane = new StatusPane(
{
showTimer: true,
showProgress: true,
buttonDisabled: true,
description: undefined,
buttonText: undefined,
},
() => this.stopRecording());
this.statusPane.showPane(this.statusPaneContainer);
this.statusPane.updateStatus(i18nString(UIStrings.initializingProfiler));
}
private cancelLoading(): void {
if (this.loader) {
void this.loader.cancel();
}
}
private async loadEventFired(
event: Common.EventTarget
.EventTargetEvent<{resourceTreeModel: SDK.ResourceTreeModel.ResourceTreeModel, loadTime: number}>):
Promise<void> {
if (this.state !== State.Recording || !this.recordingPageReload || !this.controller ||
this.controller.primaryPageTarget !== event.data.resourceTreeModel.target()) {
return;
}
const controller = this.controller;
await new Promise(r => window.setTimeout(r, this.millisecondsToRecordAfterLoadEvent));
// Check if we're still in the same recording session.
if (controller !== this.controller || this.state !== State.Recording) {
return;
}
void this.stopRecording();
}
private frameForSelection(selection: TimelineSelection): TimelineModel.TimelineFrameModel.TimelineFrame|null {
if (TimelineSelection.isFrameObject(selection.object)) {
return selection.object;
}
if (TimelineSelection.isRangeSelection(selection.object) ||
TimelineSelection.isSyntheticNetworkRequestDetailsEventSelection(selection.object)) {
return null;
}
if (TimelineSelection.isTraceEventSelection(selection.object)) {
if (!this.performanceModel) {
return null;
}
return this.performanceModel.frameModel().getFramesWithinWindow(selection.endTime, selection.endTime)[0];
}
console.assert(false, 'Should never be reached');
return null;
}
jumpToFrame(offset: number): true|undefined {
const currentFrame = this.selection && this.frameForSelection(this.selection);
if (!currentFrame || !this.performanceModel) {
return;
}
const frames = this.performanceModel.frames();
let index = frames.indexOf(currentFrame);
console.assert(index >= 0, 'Can\'t find current frame in the frame list');
index = Platform.NumberUtilities.clamp(index + offset, 0, frames.length - 1);
const frame = frames[index];
this.revealTimeRange(frame.startTime, frame.endTime);
this.select(TimelineSelection.fromFrame(frame));
return true;
}
select(selection: TimelineSelection|null): void {
this.selection = selection;
this.flameChart.setSelection(selection);
}
selectEntryAtTime(events: TraceEngine.Legacy.Event[]|null, time: number): void {
if (!events) {
return;
}
// Find best match, then backtrack to the first visible entry.
for (let index = Platform.ArrayUtilities.upperBound(events, time, (time, event) => time - event.startTime) - 1;
index >= 0; --index) {
const event = events[index];
const endTime = event.endTime || event.startTime;
if (TraceEngine.Legacy.TracingModel.isTopLevelEvent(event) && endTime < time) {
break;
}
if (this.performanceModel && this.performanceModel.isVisible(event) && endTime >= time) {
this.select(TimelineSelection.fromTraceEvent(event));
return;
}
}
this.select(null);
}
highlightEvent(event: TraceEngine.Legacy.Event|null): void {
this.flameChart.highlightEvent(event);
}
private revealTimeRange(startTime: number, endTime: number): void {
if (!this.performanceModel) {
return;
}
const window = this.performanceModel.window();
let offset = 0;
if (window.right < endTime) {
offset = endTime - window.right;
} else if (window.left > startTime) {
offset = startTime - window.left;
}
this.performanceModel.setWindow({left: window.left + offset, right: window.right + offset}, /* animate */ true);
TraceBounds.TraceBounds.BoundsManager.instance().setTimelineVisibleWindow(
TraceEngine.Helpers.Timing.traceWindowFromMilliSeconds(
TraceEngine.Types.Timing.MilliSeconds(window.left + offset),
TraceEngine.Types.Timing.MilliSeconds(window.right + offset),
),
{
shouldAnimate: true,
},
);
}
private handleDrop(dataTransfer: DataTransfer): void {
const items = dataTransfer.items;
if (!items.length) {
return;
}
const item = items[0];
Host.userMetrics.actionTaken(Host.UserMetrics.Action.PerfPanelTraceImported);
if (item.kind === 'string') {
const url = dataTransfer.getData('text/uri-list') as Platform.DevToolsPath.UrlString;
if (new Common.ParsedURL.ParsedURL(url).isValid) {
void this.loadFromURL(url);
}
} else if (item.kind === 'file') {
const file = items[0].getAsFile();
if (!file) {
return;
}
void this.loadFromFile(file);
}
}
}
// TODO(crbug.com/1167717): Make this a const enum again
// eslint-disable-next-line rulesdir/const_enum
export enum State {
Idle = 'Idle',
StartPending = 'StartPending',
Recording = 'Recording',
StopPending = 'StopPending',
Loading = 'Loading',
RecordingFailed = 'RecordingFailed',
}
// Define row and header height, should be in sync with styles for timeline graphs.
export const rowHeight = 18;
export const headerHeight = 20;
export interface TimelineModeViewDelegate {
select(selection: TimelineSelection|null): void;
selectEntryAtTime(events: TraceEngine.Legacy.CompatibleTraceEvent[]|null, time: number): void;
highlightEvent(event: TraceEngine.Legacy.CompatibleTraceEvent|null): void;
}
export class StatusPane extends UI.Widget.VBox {
private status: HTMLElement;
private time: Element|undefined;
private progressLabel!: Element;
private progressBar!: Element;
private readonly description: HTMLElement|undefined;
private button: HTMLButtonElement;
private downloadTraceButton: HTMLButtonElement;
private startTime!: number;
private timeUpdateTimer?: number;
#rawEvents?: TraceEngine.Types.TraceEvents.TraceEventData[];
constructor(
options: {
showTimer?: boolean,
showProgress?: boolean,
description?: string,
buttonText?: string,
buttonDisabled?: boolean,
},
buttonCallback: () => (Promise<void>| void)) {
super(true);
this.contentElement.classList.add('timeline-status-dialog');
const statusLine = this.contentElement.createChild('div', 'status-dialog-line status');
statusLine.createChild('div', 'label').textContent = i18nString(UIStrings.status);
this.status = statusLine.createChild('div', 'content');
UI.ARIAUtils.markAsStatus(this.status);
if (options.showTimer) {
const timeLine = this.contentElement.createChild('div', 'status-dialog-line time');
timeLine.createChild('div', 'label').textContent = i18nString(UIStrings.time);
this.time = timeLine.createChild('div', 'content');
}
if (options.showProgress) {
const progressLine = this.contentElement.createChild('div', 'status-dialog-line progress');
this.progressLabel = progressLine.createChild('div', 'label');
this.progressBar = progressLine.createChild('div', 'indicator-container').createChild('div', 'indicator');
UI.ARIAUtils.markAsProgressBar(this.progressBar);
}
if (typeof options.description === 'string') {
const descriptionLine = this.contentElement.createChild('div', 'status-dialog-line description');
descriptionLine.createChild('div', 'label').textContent = i18nString(UIStrings.description);
this.description = descriptionLine.createChild('div', 'content');
this.description.innerText = options.description;
}
const buttonContainer = this.contentElement.createChild('div', 'stop-button');
this.downloadTraceButton = UI.UIUtils.createTextButton(i18nString(UIStrings.downloadAfterError), async () => {
void this.#downloadRawTraceAfterError();
});
this.downloadTraceButton.disabled = true;
this.downloadTraceButton.style.visibility = 'hidden';
const buttonText = options.buttonText || i18nString(UIStrings.stop);
this.button = UI.UIUtils.createTextButton(buttonText, buttonCallback, '', true);
// Profiling can't be stopped during initialization.
this.button.disabled = !options.buttonDisabled === false;
buttonContainer.append(this.downloadTraceButton);
buttonContainer.append(this.button);
}
finish(): void {
this.stopTimer();
this.button.disabled = true;
}
async #downloadRawTraceAfterError(): Promise<void> {
if (!this.#rawEvents || this.#rawEvents.length === 0) {
return;
}
const traceStart = Platform.DateUtilities.toISO8601Compact(new Date());
const fileName = `Trace-Load-Error-${traceStart}.json` as Platform.DevToolsPath.RawPathString;
const handler = await window.showSaveFilePicker({
suggestedName: fileName,
});
const formattedTraceIter = traceJsonGenerator(this.#rawEvents, {});
const traceAsString = Array.from(formattedTraceIter).join('');
const encoder = new TextEncoder();
const buffer = encoder.encode(traceAsString);
const writable = await handler.createWritable();
await writable.write(buffer);
await writable.close();
}
enableDownloadOfEvents(rawEvents: TraceEngine.Types.TraceEvents.TraceEventData[]): void {
this.#rawEvents = rawEvents;
this.downloadTraceButton.disabled = false;
this.downloadTraceButton.style.visibility = 'visible';
}
remove(): void {
(this.element.parentNode as HTMLElement).classList.remove('tinted');
this.arrangeDialog((this.element.parentNode as HTMLElement));
this.stopTimer();
this.element.remove();
}
showPane(parent: Element): void {
this.arrangeDialog(parent);
this.show(parent);
parent.classList.add('tinted');
}
enableAndFocusButton(): void {
this.button.disabled = false;
this.button.focus();
}
updateStatus(text: string): void {
this.status.textContent = text;
}
updateProgressBar(activity: string, percent: number): void {
this.progressLabel.textContent = activity;
(this.progressBar as HTMLElement).style.width = percent.toFixed(1) + '%';
UI.ARIAUtils.setValueNow(this.progressBar, percent);
this.updateTimer();
}
startTimer(): void {
this.startTime = Date.now();
this.timeUpdateTimer = window.setInterval(this.updateTimer.bind(this, false), 1000);
this.updateTimer();
}
private stopTimer(): void {
if (!this.timeUpdateTimer) {
return;
}
clearInterval(this.timeUpdateTimer);
this.updateTimer(true);
delete this.timeUpdateTimer;
}
private updateTimer(precise?: boolean): void {
this.arrangeDialog((this.element.parentNode as HTMLElement));
if (!this.timeUpdateTimer || !this.time) {
return;
}
const elapsed = (Date.now() - this.startTime) / 1000;
this.time.textContent = i18nString(UIStrings.ssec, {PH1: elapsed.toFixed(precise ? 1 : 0)});
}
private arrangeDialog(parent: Element): void {
const isSmallDialog = parent.clientWidth < 325;
this.element.classList.toggle('small-dialog', isSmallDialog);
this.contentElement.classList.toggle('small-dialog', isSmallDialog);
}
override wasShown(): void {
super.wasShown();
this.registerCSSFiles([timelineStatusDialogStyles]);
}
}
let loadTimelineHandlerInstance: LoadTimelineHandler;
export class LoadTimelineHandler implements Common.QueryParamHandler.QueryParamHandler {
static instance(opts: {
forceNew: boolean|null,
} = {forceNew: null}): LoadTimelineHandler {
const {forceNew} = opts;
if (!loadTimelineHandlerInstance || forceNew) {
loadTimelineHandlerInstance = new LoadTimelineHandler();
}
return loadTimelineHandlerInstance;
}
handleQueryParam(value: string): void {
void UI.ViewManager.ViewManager.instance().showView('timeline').then(async () => {
await TimelinePanel.instance().loadFromURL(window.decodeURIComponent(value) as Platform.DevToolsPath.UrlString);
});
}
}
let actionDelegateInstance: ActionDelegate;
export class ActionDelegate implements UI.ActionRegistration.ActionDelegate {
static instance(opts: {
forceNew: boolean|null,
}|undefined = {forceNew: null}): ActionDelegate {
const {forceNew} = opts;
if (!actionDelegateInstance || forceNew) {
actionDelegateInstance = new ActionDelegate();
}
return actionDelegateInstance;
}
handleAction(_context: UI.Context.Context, actionId: string): boolean {
const panel = (UI.Context.Context.instance().flavor(TimelinePanel) as TimelinePanel);
console.assert(panel && panel instanceof TimelinePanel);
switch (actionId) {
case 'timeline.toggle-recording':
void panel.toggleRecording();
return true;
case 'timeline.record-reload':
panel.recordReload();
return true;
case 'timeline.save-to-file':
void panel.saveToFile();
return true;
case 'timeline.load-from-file':
panel.selectFileToLoad();
return true;
case 'timeline.jump-to-previous-frame':
panel.jumpToFrame(-1);
return true;
case 'timeline.jump-to-next-frame':
panel.jumpToFrame(1);
return true;
case 'timeline.show-history':
void panel.showHistory();
return true;
case 'timeline.previous-recording':
panel.navigateHistory(1);
return true;
case 'timeline.next-recording':
panel.navigateHistory(-1);
return true;
}
return false;
}
}