summaryrefslogtreecommitdiffstats
path: root/devtools/client/netmonitor/src/components/StatisticsPanel.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/netmonitor/src/components/StatisticsPanel.js')
-rw-r--r--devtools/client/netmonitor/src/components/StatisticsPanel.js413
1 files changed, 413 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/src/components/StatisticsPanel.js b/devtools/client/netmonitor/src/components/StatisticsPanel.js
new file mode 100644
index 0000000000..e3d6787819
--- /dev/null
+++ b/devtools/client/netmonitor/src/components/StatisticsPanel.js
@@ -0,0 +1,413 @@
+/* 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);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillMount() {
+ this.mdnLinkContainerNodes = new Map();
+ }
+
+ componentDidMount() {
+ const { requests, connector } = this.props;
+ requests.forEach(request => {
+ fetchNetworkUpdatePacket(connector.requestData, request, [
+ "eventTimings",
+ "responseHeaders",
+ ]);
+ });
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { requests, connector } = nextProps;
+ requests.forEach(request => {
+ fetchNetworkUpdatePacket(connector.requestData, request, [
+ "eventTimings",
+ "responseHeaders",
+ ]);
+ });
+ }
+
+ componentDidUpdate(prevProps) {
+ 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);