// Copyright 2024 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import '../../../ui/components/request_link_icon/request_link_icon.js'; import * as i18n from '../../../core/i18n/i18n.js'; import type * as Platform from '../../../core/platform/platform.js'; import * as SDK from '../../../core/sdk/sdk.js'; import * as Helpers from '../../../models/trace/helpers/helpers.js'; import * as Trace from '../../../models/trace/trace.js'; import * as LegacyComponents from '../../../ui/legacy/components/utils/utils.js'; import * as UI from '../../../ui/legacy/legacy.js'; import * as Lit from '../../../ui/lit/lit.js'; import networkRequestDetailsStyles from './networkRequestDetails.css.js'; import networkRequestTooltipStyles from './networkRequestTooltip.css.js'; import {NetworkRequestTooltip} from './NetworkRequestTooltip.js'; import {colorForNetworkRequest} from './Utils.js'; const {html, render} = Lit; const MAX_URL_LENGTH = 100; const UIStrings = { /** * @description Text that refers to the network request method */ requestMethod: 'Request method', /** * @description Text that refers to the network request protocol */ protocol: 'Protocol', /** * @description Text to show the priority of an item */ priority: 'Priority', /** * @description Text used when referring to the data sent in a network request that is encoded as a particular file format. */ encodedData: 'Encoded data', /** * @description Text used to refer to the data sent in a network request that has been decoded. */ decodedBody: 'Decoded body', /** * @description Text in Timeline indicating that input has happened recently */ yes: 'Yes', /** * @description Text in Timeline indicating that input has not happened recently */ no: 'No', /** * @description Text to indicate to the user they are viewing an event representing a network request. */ networkRequest: 'Network request', /** * @description Text for the data source of a network request. */ fromCache: 'From cache', /** * @description Text used to show the mime-type of the data transferred with a network request (e.g. "application/json"). */ mimeType: 'MIME type', /** * @description Text used to show the user that a request was served from the browser's in-memory cache. */ FromMemoryCache: ' (from memory cache)', /** * @description Text used to show the user that a request was served from the browser's file cache. */ FromCache: ' (from cache)', /** * @description Label for a network request indicating that it was a HTTP2 server push instead of a regular network request, in the Performance panel */ FromPush: ' (from push)', /** * @description Text used to show a user that a request was served from an installed, active service worker. */ FromServiceWorker: ' (from `service worker`)', /** * @description Text for the event initiated by another one */ initiatedBy: 'Initiated by', /** * @description Text that refers to if the network request is blocking */ blocking: 'Blocking', /** * @description Text that refers to if the network request is in-body parser render-blocking */ inBodyParserBlocking: 'In-body parser blocking', /** * @description Text that refers to if the network request is render-blocking */ renderBlocking: 'Render-blocking', /** * @description Text to refer to a 3rd Party entity. */ entity: '3rd party', /** * @description Label for a column containing the names of timings (performance metric) taken in the server side application. */ serverTiming: 'Server timing', /** * @description Label for a column containing the values of timings (performance metric) taken in the server side application. */ time: 'Time', /** * @description Label for a column containing the description of timings (performance metric) taken in the server side application. */ description: 'Description', } as const; const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/NetworkRequestDetails.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export class NetworkRequestDetails extends UI.Widget.Widget { #view: typeof DEFAULT_VIEW; #request: Trace.Types.Events.SyntheticNetworkRequest|null = null; #requestPreviewElements = new WeakMap(); #entityMapper: Trace.EntityMapper.EntityMapper|null = null; #target: SDK.Target.Target|null = null; #linkifier: LegacyComponents.Linkifier.Linkifier|null = null; #serverTimings: SDK.ServerTiming.ServerTiming[]|null = null; #parsedTrace: Trace.TraceModel.ParsedTrace|null = null; constructor(element?: HTMLElement, view = DEFAULT_VIEW) { super(element); this.#view = view; this.requestUpdate(); } set linkifier(linkifier: LegacyComponents.Linkifier.Linkifier|null) { this.#linkifier = linkifier; this.requestUpdate(); } set parsedTrace(parsedTrace: Trace.TraceModel.ParsedTrace|null) { this.#parsedTrace = parsedTrace; this.requestUpdate(); } set target(maybeTarget: SDK.Target.Target|null) { this.#target = maybeTarget; this.requestUpdate(); } set request(event: Trace.Types.Events.SyntheticNetworkRequest) { this.#request = event; for (const header of event.args.data.responseHeaders ?? []) { const headerName = header.name.toLocaleLowerCase(); // Some popular hosting providers like vercel or render get rid of // Server-Timing headers added by users, so as a workaround we // also support server timing headers with the `-test` suffix // while this feature is experimental, to enable easier trials. if (headerName === 'server-timing' || headerName === 'server-timing-test') { header.name = 'server-timing'; this.#serverTimings = SDK.ServerTiming.ServerTiming.parseHeaders([header]); break; } } this.requestUpdate(); } set entityMapper(mapper: Trace.EntityMapper.EntityMapper|null) { this.#entityMapper = mapper; this.requestUpdate(); } override performUpdate(): Promise|void { this.#view( { request: this.#request, previewElementsCache: this.#requestPreviewElements, target: this.#target, entityMapper: this.#entityMapper, serverTimings: this.#serverTimings, linkifier: this.#linkifier, parsedTrace: this.#parsedTrace, }, {}, this.contentElement); } } export interface ViewInput { request: Trace.Types.Events.SyntheticNetworkRequest|null; target: SDK.Target.Target|null; previewElementsCache: WeakMap; entityMapper: Trace.EntityMapper.EntityMapper|null; serverTimings: SDK.ServerTiming.ServerTiming[]|null; linkifier: LegacyComponents.Linkifier.Linkifier|null; parsedTrace: Trace.TraceModel.ParsedTrace|null; } export const DEFAULT_VIEW: ( input: ViewInput, output: object, target: HTMLElement) => void = (input, _output, target) => { if (!input.request) { render(Lit.nothing, target); return; } const {request} = input; const {data} = request.args; const redirectsHtml = NetworkRequestTooltip.renderRedirects(request); // clang-format off render(html`
${renderTitle(input.request)} ${renderURL(input.request)}
${Lit.Directives.until(renderPreviewElement( input.request, input.target, input.previewElementsCache, ))}
${renderRow(i18nString(UIStrings.requestMethod), data.requestMethod)} ${renderRow(i18nString(UIStrings.protocol), data.protocol)} ${renderRow(i18nString(UIStrings.priority), NetworkRequestTooltip.renderPriorityValue(request))} ${renderRow(i18nString(UIStrings.mimeType), data.mimeType)} ${renderEncodedDataLength(request)} ${renderRow(i18nString(UIStrings.decodedBody), i18n.ByteUtilities.bytesToString(request.args.data.decodedBodyLength))} ${renderBlockingRow(request)} ${renderFromCache(request)} ${renderThirdPartyEntity(request, input.entityMapper)}
${NetworkRequestTooltip.renderTimings(request)}
${renderServerTimings(input.serverTimings)} ${redirectsHtml ? html `
${redirectsHtml}
` : Lit.nothing}
${renderInitiatedBy(request, input.parsedTrace, input.target, input.linkifier)}
`, target); // clang-format on }; function renderTitle(request: Trace.Types.Events.SyntheticNetworkRequest): Lit.TemplateResult { const style = { backgroundColor: `${colorForNetworkRequest(request)}`, }; return html`
${i18nString(UIStrings.networkRequest)}
`; } function renderURL(request: Trace.Types.Events.SyntheticNetworkRequest): Lit.TemplateResult { const options: LegacyComponents.Linkifier.LinkifyURLOptions = { tabStop: true, showColumnNumber: false, inlineFrameIndex: 0, maxLength: MAX_URL_LENGTH, }; const linkifiedURL = LegacyComponents.Linkifier.Linkifier.linkifyURL( request.args.data.url as Platform.DevToolsPath.UrlString, options); // Potentially link to request within Network Panel const networkRequest = SDK.TraceObject.RevealableNetworkRequest.create(request); if (networkRequest) { linkifiedURL.addEventListener('contextmenu', (event: MouseEvent) => { const contextMenu = new UI.ContextMenu.ContextMenu(event); contextMenu.appendApplicableItems(networkRequest); void contextMenu.show(); }); // clang-format off const urlElement = html` ${linkifiedURL} `; // clang-format on return html`
${urlElement}
`; } return html`
${linkifiedURL}
`; } async function renderPreviewElement( request: Trace.Types.Events.SyntheticNetworkRequest, target: SDK.Target.Target|null, previewElementsCache: WeakMap): Promise { if (!request.args.data.url || !target) { return Lit.nothing; } const url = request.args.data.url as Platform.DevToolsPath.UrlString; if (!previewElementsCache.get(request)) { const previewOpts = { imageAltText: LegacyComponents.ImagePreview.ImagePreview.defaultAltTextForImageURL(url as Platform.DevToolsPath.UrlString), align: LegacyComponents.ImagePreview.Align.START, hideFileData: true, }; const previewElement = await LegacyComponents.ImagePreview.ImagePreview.build( url as Platform.DevToolsPath.UrlString, false, previewOpts); if (previewElement) { previewElementsCache.set(request, previewElement); } } const requestPreviewElement = previewElementsCache.get(request); if (requestPreviewElement) { // clang-format off return html`
${requestPreviewElement}
`; // clang-format on } return Lit.nothing; } function renderRow(title: string, value?: string|Node|Lit.TemplateResult): Lit.LitTemplate { if (!value) { return Lit.nothing; } // clang-format off return html`
${title}
${value}
`; // clang-format on } function renderEncodedDataLength(request: Trace.Types.Events.SyntheticNetworkRequest): Lit.LitTemplate { let lengthText = ''; if (request.args.data.syntheticData.isMemoryCached) { lengthText += i18nString(UIStrings.FromMemoryCache); } else if (request.args.data.syntheticData.isDiskCached) { lengthText += i18nString(UIStrings.FromCache); } else if (request.args.data.timing?.pushStart) { lengthText += i18nString(UIStrings.FromPush); } if (request.args.data.fromServiceWorker) { lengthText += i18nString(UIStrings.FromServiceWorker); } if (request.args.data.encodedDataLength || !lengthText) { lengthText = `${i18n.ByteUtilities.bytesToString(request.args.data.encodedDataLength)}${lengthText}`; } return renderRow(i18nString(UIStrings.encodedData), lengthText); } function renderBlockingRow(request: Trace.Types.Events.SyntheticNetworkRequest): Lit.LitTemplate { if (!Helpers.Network.isSyntheticNetworkRequestEventRenderBlocking(request)) { return Lit.nothing; } let renderBlockingText; switch (request.args.data.renderBlocking) { case 'blocking': renderBlockingText = UIStrings.renderBlocking; break; case 'in_body_parser_blocking': renderBlockingText = UIStrings.inBodyParserBlocking; break; default: // Shouldn't fall to this block, if so, this network request is not // render-blocking, so return null. return Lit.nothing; } return renderRow(i18nString(UIStrings.blocking), renderBlockingText); } function renderFromCache( request: Trace.Types.Events.SyntheticNetworkRequest, ): Lit.LitTemplate { const cached = request.args.data.syntheticData.isMemoryCached || request.args.data.syntheticData.isDiskCached; return renderRow(i18nString(UIStrings.fromCache), cached ? i18nString(UIStrings.yes) : i18nString(UIStrings.no)); } function renderThirdPartyEntity( request: Trace.Types.Events.SyntheticNetworkRequest, entityMapper: Trace.EntityMapper.EntityMapper|null): Lit.LitTemplate { if (!entityMapper) { return Lit.nothing; } const entity = entityMapper.entityForEvent(request); if (!entity) { return Lit.nothing; } return renderRow(i18nString(UIStrings.entity), entity.name); } function renderServerTimings(timings: SDK.ServerTiming.ServerTiming[]|null): Lit.LitTemplate[]|Lit.LitTemplate { if (!timings || timings.length === 0) { return Lit.nothing; } // clang-format off return html`
${i18nString(UIStrings.serverTiming)}
${i18nString(UIStrings.description)}
${i18nString(UIStrings.time)}
${timings.map(timing => { const classes = timing.metric.startsWith('(c') ? 'synthetic value' : 'value'; return html`
${timing.metric || '-'}
${timing.description || '-'}
${timing.value || '-'}
`; })}
`; // clang-format on } function renderInitiatedBy( request: Trace.Types.Events.SyntheticNetworkRequest, parsedTrace: Trace.TraceModel.ParsedTrace|null, target: SDK.Target.Target|null, linkifier: LegacyComponents.Linkifier.Linkifier|null, ): Lit.LitTemplate { if (!linkifier) { return Lit.nothing; } const hasStackTrace = Trace.Helpers.Trace.stackTraceInEvent(request) !== null; let link: HTMLElement|null = null; const options: LegacyComponents.Linkifier.LinkifyOptions = { tabStop: true, showColumnNumber: true, inlineFrameIndex: 0, }; // If we have a stack trace, that is the most reliable way to get the initiator data and display a link to the source. if (hasStackTrace) { const topFrame = Trace.Helpers.Trace.getStackTraceTopCallFrameInEventPayload(request) ?? null; if (topFrame) { link = linkifier.maybeLinkifyConsoleCallFrame(target, topFrame, options); } } // If we do not, we can see if the network handler found an initiator and try // to link by URL const initiator = parsedTrace ? Trace.Extras.Initiators.getNetworkInitiator(parsedTrace.data, request) : undefined; // Initiator will always be a synthetic network request but TS doesn't know that. if (initiator && Trace.Types.Events.isSyntheticNetworkRequest(initiator)) { link = linkifier.maybeLinkifyScriptLocation( target, null, // this would be the scriptId, but we don't have one. The linkifier will fallback to using the URL. initiator.args.data.url as Platform.DevToolsPath.UrlString, undefined, // line number options); } if (!link) { return Lit.nothing; } // clang-format off return html`
${i18nString(UIStrings.initiatedBy)}
${link}
`; // clang-format on }