UNPKG

chrome-devtools-frontend

Version:
613 lines (545 loc) 23.9 kB
// Copyright 2016 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. import type * as Common from '../../core/common/common.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as Platform from '../../core/platform/platform.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as Trace from '../../models/trace/trace.js'; import * as PerfUI from '../../ui/legacy/components/perf_ui/perf_ui.js'; import * as UI from '../../ui/legacy/legacy.js'; import * as ThemeSupport from '../../ui/legacy/theme_support/theme_support.js'; import * as TimelineComponents from './components/components.js'; import {initiatorsDataToDrawForNetwork} from './Initiators.js'; import {ModificationsManager} from './ModificationsManager.js'; import {NetworkTrackAppender, type NetworkTrackEvent} from './NetworkTrackAppender.js'; import timelineFlamechartPopoverStyles from './timelineFlamechartPopover.css.js'; import {FlameChartStyle, Selection} from './TimelineFlameChartView.js'; import { selectionFromEvent, selectionIsRange, selectionsEqual, type TimelineSelection, } from './TimelineSelection.js'; import {buildPersistedConfig, keyForTraceConfig} from './TrackConfiguration.js'; import * as TimelineUtils from './utils/utils.js'; export class TimelineFlameChartNetworkDataProvider implements PerfUI.FlameChart.FlameChartDataProvider { #minimumBoundary = 0; #timeSpan = 0; #events: NetworkTrackEvent[] = []; #maxLevel = 0; #networkTrackAppender: NetworkTrackAppender|null = null; #timelineDataInternal: PerfUI.FlameChart.FlameChartTimelineData|null = null; #lastSelection: Selection|null = null; #parsedTrace: Trace.Handlers.Types.ParsedTrace|null = null; #eventIndexByEvent = new Map<NetworkTrackEvent, number|null>(); // -1 means no entry is selected. #lastInitiatorEntry = -1; #lastInitiatorsData: PerfUI.FlameChart.FlameChartInitiatorData[] = []; #entityMapper: TimelineUtils.EntityMapper.EntityMapper|null = null; #persistedGroupConfigSetting: Common.Settings.Setting<PerfUI.FlameChart.PersistedConfigPerTrace>|null = null; constructor() { this.reset(); } // Reset all data other than the UI elements. // This should be called when // - initialized the data provider // - a new trace file is coming (when `setModel()` is called) // etc. reset(): void { this.#maxLevel = 0; this.#minimumBoundary = 0; this.#timeSpan = 0; this.#eventIndexByEvent.clear(); this.#events = []; this.#timelineDataInternal = null; this.#parsedTrace = null; this.#networkTrackAppender = null; } setModel(parsedTrace: Trace.Handlers.Types.ParsedTrace, entityMapper: TimelineUtils.EntityMapper.EntityMapper): void { this.reset(); this.#parsedTrace = parsedTrace; this.#entityMapper = entityMapper; this.setEvents(this.#parsedTrace); this.#setTimingBoundsData(this.#parsedTrace); } setEvents(parsedTrace: Trace.Handlers.Types.ParsedTrace): void { if (parsedTrace.NetworkRequests.webSocket) { parsedTrace.NetworkRequests.webSocket.forEach(webSocketData => { if (webSocketData.syntheticConnection) { this.#events.push(webSocketData.syntheticConnection); } this.#events.push(...webSocketData.events); }); } if (parsedTrace.NetworkRequests.byTime) { this.#events.push(...parsedTrace.NetworkRequests.byTime); } } isEmpty(): boolean { this.timelineData(); return !this.#events.length; } maxStackDepth(): number { return this.#maxLevel; } hasTrackConfigurationMode(): boolean { return false; } timelineData(): PerfUI.FlameChart.FlameChartTimelineData { if (this.#timelineDataInternal && this.#timelineDataInternal.entryLevels.length !== 0) { // The flame chart data is built already, so return the cached data. return this.#timelineDataInternal; } this.#timelineDataInternal = PerfUI.FlameChart.FlameChartTimelineData.createEmpty(); if (!this.#parsedTrace) { return this.#timelineDataInternal; } if (!this.#events.length) { this.setEvents(this.#parsedTrace); } this.#networkTrackAppender = new NetworkTrackAppender(this.#timelineDataInternal, this.#events); this.#maxLevel = this.#networkTrackAppender.appendTrackAtLevel(0); return this.#timelineDataInternal; } minimumBoundary(): number { return this.#minimumBoundary; } totalTime(): number { return this.#timeSpan; } setWindowTimes(startTime: Trace.Types.Timing.Milli, endTime: Trace.Types.Timing.Milli): void { this.#updateTimelineData(startTime, endTime); } createSelection(index: number): TimelineSelection|null { if (index === -1) { return null; } const event = this.#events[index]; this.#lastSelection = new Selection(selectionFromEvent(event), index); return this.#lastSelection.timelineSelection; } customizedContextMenu(event: MouseEvent, eventIndex: number, _groupIndex: number): UI.ContextMenu.ContextMenu |undefined { const networkRequest = this.eventByIndex(eventIndex); if (!networkRequest || !Trace.Types.Events.isSyntheticNetworkRequest(networkRequest)) { return; } const timelineNetworkRequest = SDK.TraceObject.RevealableNetworkRequest.create(networkRequest); const contextMenu = new UI.ContextMenu.ContextMenu(event); contextMenu.appendApplicableItems(timelineNetworkRequest); return contextMenu; } indexForEvent(event: Trace.Types.Events.Event): number|null { if (!Trace.Types.Events.isNetworkTrackEntry(event)) { return null; } const fromCache = this.#eventIndexByEvent.get(event); // Cached value might be null, which is OK. if (fromCache !== undefined) { return fromCache; } const index = this.#events.indexOf(event); const result = index > -1 ? index : null; this.#eventIndexByEvent.set(event, result); return result; } eventByIndex(entryIndex: number): Trace.Types.Events.SyntheticNetworkRequest|Trace.Types.Events.WebSocketEvent|null { return this.#events.at(entryIndex) ?? null; } entryHasAnnotations(entryIndex: number): boolean { const event = this.eventByIndex(entryIndex); if (!event) { return false; } const entryAnnotations = ModificationsManager.activeManager()?.annotationsForEntry(event); return entryAnnotations !== undefined && entryAnnotations.length > 0; } deleteAnnotationsForEntry(entryIndex: number): void { const event = this.eventByIndex(entryIndex); if (!event) { return; } ModificationsManager.activeManager()?.deleteEntryAnnotations(event); } entryIndexForSelection(selection: TimelineSelection|null): number { if (!selection || selectionIsRange(selection)) { return -1; } if (this.#lastSelection && selectionsEqual(this.#lastSelection.timelineSelection, selection)) { return this.#lastSelection.entryIndex; } if (!Trace.Types.Events.isNetworkTrackEntry(selection.event)) { return -1; } const index = this.#events.indexOf(selection.event); if (index !== -1) { this.#lastSelection = new Selection(selectionFromEvent(selection.event), index); } return index; } groupForEvent(_entryIndex: number): PerfUI.FlameChart.Group|null { // Because the network track only contains one group, we don't actually // need to do any lookups here. const group = this.#networkTrackAppender?.group() ?? null; return group; } entryColor(index: number): string { if (!this.#networkTrackAppender) { throw new Error('networkTrackAppender should not be empty'); } return this.#networkTrackAppender.colorForEvent(this.#events[index]); } textColor(_index: number): string { return FlameChartStyle.textColor; } entryTitle(index: number): string|null { const event = this.#events[index]; return TimelineUtils.EntryName.nameForEntry(event); } entryFont(_index: number): string|null { return this.#networkTrackAppender?.font() || null; } /** * Returns the pixels needed to decorate the event. * The pixels compare to the start of the earliest event of the request. * * Request.beginTime(), which is used in FlameChart to calculate the unclippedBarX * v * |----------------[ (URL text) waiting time | request ]--------| * ^start ^sendStart ^headersEnd ^Finish ^end * @param request * @param unclippedBarX The start pixel of the request. It is calculated with request.beginTime() in FlameChart. * @param timeToPixelRatio * @returns the pixels to draw waiting time and left and right whiskers and url text */ getDecorationPixels( event: Trace.Types.Events.SyntheticNetworkRequest, unclippedBarX: number, timeToPixelRatio: number): {sendStart: number, headersEnd: number, finish: number, start: number, end: number} { const beginTime = Trace.Helpers.Timing.microToMilli(event.ts); const timeToPixel = (time: number): number => unclippedBarX + (time - beginTime) * timeToPixelRatio; const startTime = Trace.Helpers.Timing.microToMilli(event.ts); const endTime = Trace.Helpers.Timing.microToMilli((event.ts + event.dur) as Trace.Types.Timing.Micro); const sendStartTime = Trace.Helpers.Timing.microToMilli(event.args.data.syntheticData.sendStartTime); const headersEndTime = Trace.Helpers.Timing.microToMilli(event.args.data.syntheticData.downloadStart); const sendStart = Math.max(timeToPixel(sendStartTime), unclippedBarX); const headersEnd = Math.max(timeToPixel(headersEndTime), sendStart); const finish = Math.max(timeToPixel(Trace.Helpers.Timing.microToMilli(event.args.data.syntheticData.finishTime)), headersEnd); const start = timeToPixel(startTime); const end = Math.max(timeToPixel(endTime), finish); return {sendStart, headersEnd, finish, start, end}; } /** * Decorates the entry depends on the type of the event: * @param index * @param context * @param barX The x pixel of the visible part request * @param barY The y pixel of the visible part request * @param barWidth The width of the visible part request * @param barHeight The height of the visible part request * @param unclippedBarX The start pixel of the request compare to the visible area. It is calculated with request.beginTime() in FlameChart. * @param timeToPixelRatio * @returns if the entry needs to be decorate, which is alway true if the request has "timing" field */ decorateEntry( index: number, context: CanvasRenderingContext2D, _text: string|null, barX: number, barY: number, barWidth: number, barHeight: number, unclippedBarX: number, timeToPixelRatio: number): boolean { const event = this.#events[index]; if (Trace.Types.Events.isSyntheticWebSocketConnection(event)) { return this.#decorateSyntheticWebSocketConnection( index, context, barY, barHeight, unclippedBarX, timeToPixelRatio); } if (!Trace.Types.Events.isSyntheticNetworkRequest(event)) { return false; } return this.#decorateNetworkRequest( index, context, _text, barX, barY, barWidth, barHeight, unclippedBarX, timeToPixelRatio); } /** * Decorates the Network Request entry with the following steps: * Draw a waiting time between |sendStart| and |headersEnd| * By adding a extra transparent white layer * Draw a whisk between |start| and |sendStart| * Draw a whisk between |finish| and |end| * By draw another layer of background color to "clear" the area * Then draw the whisk * Draw the URL after the |sendStart| * * |----------------[ (URL text) waiting time | request ]--------| * ^start ^sendStart ^headersEnd ^Finish ^end * */ #decorateNetworkRequest( index: number, context: CanvasRenderingContext2D, _text: string|null, barX: number, barY: number, barWidth: number, barHeight: number, unclippedBarX: number, timeToPixelRatio: number): boolean { const event = this.#events[index]; if (!Trace.Types.Events.isSyntheticNetworkRequest(event)) { return false; } const {sendStart, headersEnd, finish, start, end} = this.getDecorationPixels(event, unclippedBarX, timeToPixelRatio); // Draw waiting time. context.fillStyle = 'hsla(0, 100%, 100%, 0.8)'; context.fillRect(sendStart + 0.5, barY + 0.5, headersEnd - sendStart - 0.5, barHeight - 2); // Clear portions of initial rect to prepare for the ticks. context.fillStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-cdt-base-container'); context.fillRect(barX, barY - 0.5, sendStart - barX, barHeight); context.fillRect(finish, barY - 0.5, barX + barWidth - finish, barHeight); // Draws left and right whiskers function drawTick(begin: number, end: number, y: number): void { const /** @const */ tickHeightPx = 6; context.moveTo(begin, y - tickHeightPx / 2); context.lineTo(begin, y + tickHeightPx / 2); context.moveTo(begin, y); context.lineTo(end, y); } context.beginPath(); context.lineWidth = 1; context.strokeStyle = '#ccc'; const lineY = Math.floor(barY + barHeight / 2) + 0.5; const leftTick = start + 0.5; const rightTick = end - 0.5; drawTick(leftTick, sendStart, lineY); drawTick(rightTick, finish, lineY); context.stroke(); // Draw request URL as text const textStart = Math.max(sendStart, 0); const textWidth = finish - textStart; const /** @const */ minTextWidthPx = 20; if (textWidth >= minTextWidthPx) { let title = this.entryTitle(index) || ''; if (event.args.data.fromServiceWorker) { title = '⚙ ' + title; } if (title) { const /** @const */ textPadding = 4; const /** @const */ textBaseline = 5; const textBaseHeight = barHeight - textBaseline; const trimmedText = UI.UIUtils.trimTextEnd(context, title, textWidth - 2 * textPadding); context.fillStyle = '#333'; context.fillText(trimmedText, textStart + textPadding, barY + textBaseHeight); } } return true; } /** * Decorates the synthetic websocket event entry with a whisk from the start to the end. * ------------------------ * ^start ^end * */ #decorateSyntheticWebSocketConnection( index: number, context: CanvasRenderingContext2D, barY: number, barHeight: number, unclippedBarX: number, timeToPixelRatio: number): boolean { context.save(); const event = this.#events[index] as Trace.Types.Events.SyntheticWebSocketConnection; const beginTime = Trace.Helpers.Timing.microToMilli(event.ts); const timeToPixel = (time: number): number => Math.floor(unclippedBarX + (time - beginTime) * timeToPixelRatio); const endTime = Trace.Helpers.Timing.microToMilli((event.ts + event.dur) as Trace.Types.Timing.Micro); const start = timeToPixel(beginTime) + 0.5; const end = timeToPixel(endTime) - 0.5; context.strokeStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--app-color-rendering'); const lineY = Math.floor(barY + barHeight / 2) + 0.5; context.setLineDash([3, 2]); context.moveTo(start, lineY - 1); context.lineTo(end, lineY - 1); context.moveTo(start, lineY + 1); context.lineTo(end, lineY + 1); context.stroke(); context.restore(); return true; } forceDecoration(_index: number): boolean { return true; } /** *In the FlameChart.ts, when filtering through the events for a level, it starts * from the last event of that level and stops when it hit an event that has start * time greater than the filtering window. * For example, in this websocket level we have A(socket event), B, C, D. If C * event has start time greater than the window, the rest of the events (A and B) * wont be drawn. So if this level is the force Drawable level, we wont stop at * event C and will include the socket event A. * */ forceDrawableLevel(levelIndex: number): boolean { return this.#networkTrackAppender?.webSocketIdToLevel.has(levelIndex) || false; } preparePopoverElement(index: number): Element|null { const event = this.#events[index]; if (Trace.Types.Events.isSyntheticNetworkRequest(event)) { const element = document.createElement('div'); const root = UI.UIUtils.createShadowRootWithCoreStyles(element, {cssFile: timelineFlamechartPopoverStyles}); const contents = root.createChild('div', 'timeline-flamechart-popover'); const infoElement = new TimelineComponents.NetworkRequestTooltip.NetworkRequestTooltip(); infoElement.data = {networkRequest: event, entityMapper: this.#entityMapper}; contents.appendChild(infoElement); return element; } return null; } /** * Sets the minimum time and total time span of a trace using the * new engine data. */ #setTimingBoundsData(newParsedTrace: Trace.Handlers.Types.ParsedTrace): void { const {traceBounds} = newParsedTrace.Meta; const minTime = Trace.Helpers.Timing.microToMilli(traceBounds.min); const maxTime = Trace.Helpers.Timing.microToMilli(traceBounds.max); this.#minimumBoundary = minTime; this.#timeSpan = minTime === maxTime ? 1000 : maxTime - this.#minimumBoundary; } /** * When users zoom in the flamechart, we only want to show them the network * requests between startTime and endTime. This function will call the * trackAppender to update the timeline data, and then force to create a new * PerfUI.FlameChart.FlameChartTimelineData instance to force the flamechart * to re-render. */ #updateTimelineData(startTime: Trace.Types.Timing.Milli, endTime: Trace.Types.Timing.Milli): void { if (!this.#networkTrackAppender || !this.#timelineDataInternal) { return; } this.#maxLevel = this.#networkTrackAppender.relayoutEntriesWithinBounds(this.#events, startTime, endTime); // TODO(crbug.com/1459225): Remove this recreating code. // Force to create a new PerfUI.FlameChart.FlameChartTimelineData instance // to force the flamechart to re-render. This also causes crbug.com/1459225. this.#timelineDataInternal = PerfUI.FlameChart.FlameChartTimelineData.create({ entryLevels: this.#timelineDataInternal?.entryLevels, entryTotalTimes: this.#timelineDataInternal?.entryTotalTimes, entryStartTimes: this.#timelineDataInternal?.entryStartTimes, groups: this.#timelineDataInternal?.groups, initiatorsData: this.#timelineDataInternal.initiatorsData, entryDecorations: this.#timelineDataInternal.entryDecorations, }); } /** * Note that although we use the same mechanism to track configuration * changes in the Network part of the timeline, we only really use it to track * the expanded state because the user cannot re-order or hide/show tracks in * here. */ handleTrackConfigurationChange(groups: readonly PerfUI.FlameChart.Group[], indexesInVisualOrder: number[]): void { if (!this.#persistedGroupConfigSetting) { return; } if (!this.#parsedTrace) { return; } const persistedDataForTrace = buildPersistedConfig(groups, indexesInVisualOrder); const traceKey = keyForTraceConfig(this.#parsedTrace); const setting = this.#persistedGroupConfigSetting.get(); setting[traceKey] = persistedDataForTrace; this.#persistedGroupConfigSetting.set(setting); } setPersistedGroupConfigSetting(setting: Common.Settings.Setting<PerfUI.FlameChart.PersistedConfigPerTrace>): void { this.#persistedGroupConfigSetting = setting; } preferredHeight(): number { if (!this.#networkTrackAppender || this.#maxLevel === 0) { return 0; } const group = this.#networkTrackAppender.group(); if (!group) { return 0; } // Bump up to 7 because the tooltip is around 7 rows' height. return group.style.height * (this.isExpanded() ? Platform.NumberUtilities.clamp(this.#maxLevel + 1, 7, 8.5) : 1); } isExpanded(): boolean { return Boolean(this.#networkTrackAppender?.group()?.expanded); } formatValue(value: number, precision?: number): string { return i18n.TimeUtilities.preciseMillisToString(value, precision); } canJumpToEntry(_entryIndex: number): boolean { return false; } /** * searches entries within the specified time and returns a list of entry * indexes */ search( visibleWindow: Trace.Types.Timing.TraceWindowMicro, filter?: Trace.Extras.TraceFilter.TraceFilter, ): PerfUI.FlameChart.DataProviderSearchResult[] { const results: PerfUI.FlameChart.DataProviderSearchResult[] = []; for (let i = 0; i < this.#events.length; i++) { const entry = this.#events.at(i); if (!entry) { continue; } if (!Trace.Helpers.Timing.eventIsInBounds(entry, visibleWindow)) { continue; } if (!filter || filter.accept(entry, this.#parsedTrace ?? undefined)) { const startTimeMilli = Trace.Helpers.Timing.microToMilli(entry.ts); results.push({startTimeMilli, index: i, provider: 'network'}); } } return results; } /** * Returns a map of navigations that happened in the main frame, ignoring any * that happened in other frames. * The map's key is the frame ID. **/ mainFrameNavigationStartEvents(): readonly Trace.Types.Events.NavigationStart[] { if (!this.#parsedTrace) { return []; } return this.#parsedTrace.Meta.mainFrameNavigations; } buildFlowForInitiator(entryIndex: number): boolean { if (!this.#parsedTrace) { return false; } if (!this.#timelineDataInternal) { return false; } if (this.#lastInitiatorEntry === entryIndex) { if (this.#lastInitiatorsData) { this.#timelineDataInternal.initiatorsData = this.#lastInitiatorsData; } return true; } if (!this.#networkTrackAppender) { return false; } // Remove all previously assigned decorations indicating that the flow event entries are hidden const previousInitiatorsDataLength = this.#timelineDataInternal.initiatorsData.length; // |entryIndex| equals -1 means there is no entry selected, just clear the // initiator cache if there is any previous arrow and return true to // re-render. if (entryIndex === -1) { this.#lastInitiatorEntry = entryIndex; if (previousInitiatorsDataLength === 0) { // This means there is no arrow before, so we don't need to re-render. return false; } // Reset to clear any previous arrows from the last event. this.#timelineDataInternal.emptyInitiators(); return true; } const event = this.#events[entryIndex]; // Reset to clear any previous arrows from the last event. this.#timelineDataInternal.emptyInitiators(); this.#lastInitiatorEntry = entryIndex; const initiatorsData = initiatorsDataToDrawForNetwork(this.#parsedTrace, event); // This means there is no change for arrows. if (previousInitiatorsDataLength === 0 && initiatorsData.length === 0) { return false; } for (const initiatorData of initiatorsData) { const eventIndex = this.indexForEvent(initiatorData.event); const initiatorIndex = this.indexForEvent(initiatorData.initiator); if (eventIndex === null || initiatorIndex === null) { continue; } this.#timelineDataInternal.initiatorsData.push({ initiatorIndex, eventIndex, }); } this.#lastInitiatorsData = this.#timelineDataInternal.initiatorsData; return true; } }