Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js");
const {
FILTER_TAGS,
} = require("resource://devtools/client/netmonitor/src/constants.js");
const {
Component,
createFactory,
} = require("resource://devtools/client/shared/vendor/react.js");
const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
const {
connect,
} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js");
const { Chart } = require("resource://devtools/client/shared/widgets/Chart.js");
const { PluralForm } = require("resource://devtools/shared/plural-form.js");
const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js");
const {
Filters,
} = require("resource://devtools/client/netmonitor/src/utils/filter-predicates.js");
const {
getSizeWithDecimals,
getTimeWithDecimals,
} = require("resource://devtools/client/netmonitor/src/utils/format-utils.js");
const {
L10N,
} = require("resource://devtools/client/netmonitor/src/utils/l10n.js");
const {
getPerformanceAnalysisURL,
} = require("resource://devtools/client/netmonitor/src/utils/doc-utils.js");
const {
fetchNetworkUpdatePacket,
} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
// Components
const MDNLink = createFactory(
require("resource://devtools/client/shared/components/MdnLink.js")
);
const { button, div } = dom;
const MediaQueryList = window.matchMedia("(min-width: 700px)");
const NETWORK_ANALYSIS_PIE_CHART_DIAMETER = 200;
const BACK_BUTTON = L10N.getStr("netmonitor.backButton");
const CHARTS_CACHE_ENABLED = L10N.getStr("charts.cacheEnabled");
const CHARTS_CACHE_DISABLED = L10N.getStr("charts.cacheDisabled");
const CHARTS_LEARN_MORE = L10N.getStr("charts.learnMore");
/*
* Statistics panel component
* Performance analysis tool which shows you how long the browser takes to
* download the different parts of your site.
*/
class StatisticsPanel extends Component {
static get propTypes() {
return {
connector: PropTypes.object.isRequired,
closeStatistics: PropTypes.func.isRequired,
enableRequestFilterTypeOnly: PropTypes.func.isRequired,
hasLoad: PropTypes.bool,
requests: PropTypes.array,
};
}
constructor(props) {
super(props);
this.state = {
isVerticalSpliter: MediaQueryList.matches,
};
this.createMDNLink = this.createMDNLink.bind(this);
this.unmountMDNLinkContainers = this.unmountMDNLinkContainers.bind(this);
this.createChart = this.createChart.bind(this);
this.sanitizeChartDataSource = this.sanitizeChartDataSource.bind(this);
this.responseIsFresh = this.responseIsFresh.bind(this);
this.onLayoutChange = this.onLayoutChange.bind(this);
}
UNSAFE_componentWillMount() {
this.mdnLinkContainerNodes = new Map();
}
componentDidMount() {
const { requests, connector } = this.props;
requests.forEach(request => {
fetchNetworkUpdatePacket(connector.requestData, request, [
"eventTimings",
"responseHeaders",
]);
});
}
UNSAFE_componentWillReceiveProps(nextProps) {
const { requests, connector } = nextProps;
requests.forEach(request => {
fetchNetworkUpdatePacket(connector.requestData, request, [
"eventTimings",
"responseHeaders",
]);
});
}
componentDidUpdate() {
MediaQueryList.addListener(this.onLayoutChange);
const { hasLoad, requests } = this.props;
// Display statistics about all requests for which we received enough data,
// as soon as the page is considered to be loaded
const ready = requests.length && hasLoad;
// Ignore requests which are missing data expected by this component:
// - pending/incomplete requests
// - blocked/errored requests
const validRequests = requests.filter(
req =>
req.contentSize !== undefined &&
req.mimeType &&
req.responseHeaders &&
req.status !== undefined &&
req.totalTime !== undefined
);
this.createChart({
id: "primedCacheChart",
title: CHARTS_CACHE_ENABLED,
data: ready ? this.sanitizeChartDataSource(validRequests, false) : null,
});
this.createChart({
id: "emptyCacheChart",
title: CHARTS_CACHE_DISABLED,
data: ready ? this.sanitizeChartDataSource(validRequests, true) : null,
});
this.createMDNLink("primedCacheChart", getPerformanceAnalysisURL());
this.createMDNLink("emptyCacheChart", getPerformanceAnalysisURL());
}
componentWillUnmount() {
MediaQueryList.removeListener(this.onLayoutChange);
this.unmountMDNLinkContainers();
}
createMDNLink(chartId, url) {
if (this.mdnLinkContainerNodes.has(chartId)) {
ReactDOM.unmountComponentAtNode(this.mdnLinkContainerNodes.get(chartId));
}
// MDNLink is a React component but Chart isn't. To get the link
// into the chart we mount a new ReactDOM at the appropriate
// location after the chart has been created.
const title = this.refs[chartId].querySelector(".table-chart-title");
const containerNode = document.createElement("span");
title.appendChild(containerNode);
ReactDOM.render(
MDNLink({
url,
title: CHARTS_LEARN_MORE,
}),
containerNode
);
this.mdnLinkContainerNodes.set(chartId, containerNode);
}
unmountMDNLinkContainers() {
for (const [, node] of this.mdnLinkContainerNodes) {
ReactDOM.unmountComponentAtNode(node);
}
}
createChart({ id, title, data }) {
// Create a new chart.
const chart = Chart.PieTable(document, {
diameter: NETWORK_ANALYSIS_PIE_CHART_DIAMETER,
title,
header: {
count: L10N.getStr("charts.requestsNumber"),
label: L10N.getStr("charts.type"),
size: L10N.getStr("charts.size"),
transferredSize: L10N.getStr("charts.transferred"),
time: L10N.getStr("charts.time"),
nonBlockingTime: L10N.getStr("charts.nonBlockingTime"),
},
data,
strings: {
size: value =>
L10N.getFormatStr(
"charts.size.kB",
getSizeWithDecimals(value / 1000)
),
transferredSize: value =>
L10N.getFormatStr(
"charts.transferredSize.kB",
getSizeWithDecimals(value / 1000)
),
time: value =>
L10N.getFormatStr("charts.totalS", getTimeWithDecimals(value / 1000)),
nonBlockingTime: value =>
L10N.getFormatStr("charts.totalS", getTimeWithDecimals(value / 1000)),
},
totals: {
cached: total => L10N.getFormatStr("charts.totalCached", total),
count: total => L10N.getFormatStr("charts.totalCount", total),
size: total =>
L10N.getFormatStr(
"charts.totalSize.kB",
getSizeWithDecimals(total / 1000)
),
transferredSize: total =>
L10N.getFormatStr(
"charts.totalTransferredSize.kB",
getSizeWithDecimals(total / 1000)
),
time: total => {
const seconds = total / 1000;
const string = getTimeWithDecimals(seconds);
return PluralForm.get(
seconds,
L10N.getStr("charts.totalSeconds")
).replace("#1", string);
},
nonBlockingTime: total => {
const seconds = total / 1000;
const string = getTimeWithDecimals(seconds);
return PluralForm.get(
seconds,
L10N.getStr("charts.totalSecondsNonBlocking")
).replace("#1", string);
},
},
sorted: true,
});
chart.on("click", ({ label }) => {
// Reset FilterButtons and enable one filter exclusively
this.props.closeStatistics();
this.props.enableRequestFilterTypeOnly(label);
});
const container = this.refs[id];
// Nuke all existing charts of the specified type.
while (container.hasChildNodes()) {
container.firstChild.remove();
}
container.appendChild(chart.node);
}
sanitizeChartDataSource(requests, emptyCache) {
const data = FILTER_TAGS.map(type => ({
cached: 0,
count: 0,
label: type,
size: 0,
transferredSize: 0,
time: 0,
nonBlockingTime: 0,
}));
for (const request of requests) {
let type;
if (Filters.html(request)) {
// "html"
type = 0;
} else if (Filters.css(request)) {
// "css"
type = 1;
} else if (Filters.js(request)) {
// "js"
type = 2;
} else if (Filters.fonts(request)) {
// "fonts"
type = 4;
} else if (Filters.images(request)) {
// "images"
type = 5;
} else if (Filters.media(request)) {
// "media"
type = 6;
} else if (Filters.ws(request)) {
// "ws"
type = 7;
} else if (Filters.xhr(request)) {
// Verify XHR last, to categorize other mime types in their own blobs.
// "xhr"
type = 3;
} else {
// "other"
type = 8;
}
if (emptyCache || !this.responseIsFresh(request)) {
data[type].time += request.totalTime || 0;
data[type].size += request.contentSize || 0;
data[type].transferredSize += request.transferredSize || 0;
const nonBlockingTime =
request.eventTimings.totalTime - request.eventTimings.timings.blocked;
data[type].nonBlockingTime += nonBlockingTime || 0;
} else {
data[type].cached++;
}
data[type].count++;
}
return data.filter(e => e.count > 0);
}
/**
* Checks if the "Expiration Calculations" defined in section 13.2.4 of the
* "HTTP/1.1: Caching in HTTP" spec holds true for a collection of headers.
*
* @param object
* An object containing the { responseHeaders, status } properties.
* @return boolean
* True if the response is fresh and loaded from cache.
*/
responseIsFresh({ responseHeaders, status }) {
// Check for a "304 Not Modified" status and response headers availability.
if (status != 304 || !responseHeaders) {
return false;
}
const list = responseHeaders.headers;
const cacheControl = list.find(
e => e.name.toLowerCase() === "cache-control"
);
const expires = list.find(e => e.name.toLowerCase() === "expires");
// Check the "Cache-Control" header for a maximum age value.
if (cacheControl) {
const maxAgeMatch =
cacheControl.value.match(/s-maxage\s*=\s*(\d+)/) ||
cacheControl.value.match(/max-age\s*=\s*(\d+)/);
if (maxAgeMatch && maxAgeMatch.pop() > 0) {
return true;
}
}
// Check the "Expires" header for a valid date.
if (expires && Date.parse(expires.value)) {
return true;
}
return false;
}
onLayoutChange() {
this.setState({
isVerticalSpliter: MediaQueryList.matches,
});
}
render() {
const { closeStatistics } = this.props;
const splitterClassName = ["splitter"];
if (this.state.isVerticalSpliter) {
splitterClassName.push("devtools-side-splitter");
} else {
splitterClassName.push("devtools-horizontal-splitter");
}
return div(
{ className: "statistics-panel" },
button(
{
className: "back-button devtools-button",
"data-text-only": "true",
title: BACK_BUTTON,
onClick: closeStatistics,
},
BACK_BUTTON
),
div(
{ className: "charts-container" },
div({
ref: "primedCacheChart",
className: "charts primed-cache-chart",
}),
div({ className: splitterClassName.join(" ") }),
div({ ref: "emptyCacheChart", className: "charts empty-cache-chart" })
)
);
}
}
module.exports = connect(
state => ({
// `firstDocumentLoadTimestamp` is set on timing markers when we receive
// DOCUMENT_EVENT's dom-complete, which is equivalent to page `load` event.
hasLoad: state.timingMarkers.firstDocumentLoadTimestamp != -1,
requests: [...state.requests.requests],
}),
(dispatch, props) => ({
closeStatistics: () =>
dispatch(Actions.openStatistics(props.connector, false)),
enableRequestFilterTypeOnly: label =>
dispatch(Actions.enableRequestFilterTypeOnly(label)),
})
)(StatisticsPanel);