blob: 468a49dbafd3b1a00704836811d23bdc0111ca99 [file] [log] [blame]
/*
* Copyright 2014 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @fileoverview Code for adding auto-refreshing bar charts and realtime
* annotated time line on the basis of the data in statistics page.
*
* TODO(xqyin): Integrate this into console page.
*
* @author oschaaf@we-amp.com (Otto van der Schaaf)
* @author xqyin@google.com (XiaoQian Yin)
*/
goog.provide('pagespeed.Graphs');
goog.require('goog.array');
goog.require('goog.events');
goog.require('goog.net.EventType');
goog.require('goog.net.XhrIo');
goog.require('goog.string');
// Google Charts API.
// Requires <script src='https://www.google.com/jsapi'></script> loaded in HTML.
google.load('visualization', '1',
{'packages': ['table', 'corechart', 'annotatedtimeline']});
/** @typedef {{name: string, value:string}} */
pagespeed.StatsNode;
/** @typedef {{messages: Array.<pagespeed.StatsNode>, timeReceived: Date}} */
pagespeed.StatsArray;
/**
* @constructor
* @param {goog.testing.net.XhrIo=} opt_xhr Optional mock XmlHttpRequests
* handler for testing.
*/
pagespeed.Graphs = function(opt_xhr) {
/**
* The XmlHttpRequests handler for auto-refresh. We pass a mock XhrIo
* when testing. Default is a normal XhrIo.
* @private {goog.net.XhrIo|goog.testing.net.XhrIo}
*/
this.xhr_ = opt_xhr || new goog.net.XhrIo();
/**
* This array of arrays collects historical data from statistics log file
* for the first refresh and updates from back end to get snapshot of the
* current data. The i-th entry of the array is the statistics corresponds
* to the i-th time stamp, which is an array of special nodes. Each node
* contains certain statistics name and the value. We use the last entry to
* generate pie charts showing the most recent data. The whole array of
* arrays is used to generate line charts showing statistics history. We only
* keep one-day data to avoid infinite growth of this array.
* @private {!Array.<pagespeed.StatsArray>}
*/
this.psolMessages_ = [];
/**
* The option of auto-refresh. If true, the page will automatically refresh
* itself every 5 seconds.
* @private {boolean}
*/
this.autoRefresh_ = false;
// We set the default to false because auto-refresh would impact the user
// experience of annotated time line.
/**
* The flag of whether the first refresh is started. We need to call a
* refresh when loading the page for the initialization. Default is false,
* which means the page hasn't started the first refresh yet.
* @private {boolean}
*/
this.firstRefreshStarted_ = false;
/**
* The flag of whether the second refresh is started. Since we fetch data
* from statistics log file for the first refresh, the data may be stale.
* We need to do the second refresh immediately to make sure the data we are
* showing is up-to-date.
* @private {boolean}
*/
this.secondRefreshStarted_ = false;
/**
* Maps chart titles to the chart object.
* @private {Object}
*/
this.chartCache_ = {};
// Hide all div elements.
// To use AnnotatedTimeLine charts, we must specify the size of the container
// elements explicitly instead of setting the size in the options of charts.
// Since we cannot get the size of an element with 'display:none;', we hide
// all the elements by positioning them off the left hand side.
for (var i in pagespeed.Graphs.DisplayDiv) {
var chartDiv = document.getElementById(pagespeed.Graphs.DisplayDiv[i]);
chartDiv.className = 'pagespeed-hidden-offscreen';
}
// The navigation bar to switch among different display modes.
var navElement = document.createElement('table');
navElement.id = 'nav-bar';
navElement.className = 'pagespeed-sub-tabs';
navElement.innerHTML =
'<tr><td><a id="' + pagespeed.Graphs.DisplayMode.CACHE_APPLIED +
'" href="javascript:void(0);">' +
'Per application cache stats</a> - </td>' +
'<td><a id="' + pagespeed.Graphs.DisplayMode.CACHE_TYPE +
'" href="javascript:void(0);">' +
'Per type cache stats</a> - </td>' +
'<td><a id="' + pagespeed.Graphs.DisplayMode.IPRO +
'" href="javascript:void(0);">' +
'IPRO status</a> - </td>' +
'<td><a id="' + pagespeed.Graphs.DisplayMode.REWRITE_IMAGE +
'" href="javascript:void(0);">' +
'Image rewriting</a> - </td>' +
'<td><a id="' + pagespeed.Graphs.DisplayMode.REALTIME +
'" href="javascript:void(0);">' +
'Realtime</a></td></tr>';
// The UI table of auto-refresh.
var uiTable = document.createElement('div');
uiTable.id = 'ui-div';
uiTable.innerHTML =
'<table id="ui-table" border=1 style="border-collapse: ' +
'collapse;border-color:silver;"><tr valign="center">' +
'<td>Auto refresh (every 5 seconds): <input type="checkbox" ' +
'id="auto-refresh" ' + (this.autoRefresh_ ? 'checked' : '') +
'></td></tr></table>';
document.body.insertBefore(
uiTable,
document.getElementById(pagespeed.Graphs.DisplayDiv.CACHE_APPLIED));
document.body.insertBefore(navElement, document.getElementById('ui-div'));
};
/**
* Show the chosen div element and hide all other elements.
* @param {string} div The chosen div element to display.
*/
pagespeed.Graphs.prototype.show = function(div) {
// Only shows the div chosen by users.
var i;
for (i in pagespeed.Graphs.DisplayDiv) {
var chartDiv = pagespeed.Graphs.DisplayDiv[i];
if (chartDiv == div) {
document.getElementById(chartDiv).className = '';
} else {
document.getElementById(chartDiv).className =
'pagespeed-hidden-offscreen';
}
}
// Underline the current tab.
var currentTab = document.getElementById(div + '_mode');
for (i in pagespeed.Graphs.DisplayMode) {
var link = document.getElementById(pagespeed.Graphs.DisplayMode[i]);
if (link == currentTab) {
link.className = 'pagespeed-underline-link';
} else {
link.className = '';
}
}
location.href = location.href.split('#')[0] + '#' + div;
// TODO(xqyin): Note that there are similar changes under caches page as
// well. We should factor out the logic and let similar codes like this
// being shared by different pages.
};
/**
* Parse the location URL to get its anchor part and show the div element.
*/
pagespeed.Graphs.prototype.parseLocation = function() {
var div = location.hash.substr(1);
if (div == '') {
this.show(pagespeed.Graphs.DisplayDiv.CACHE_APPLIED);
} else if (goog.object.contains(pagespeed.Graphs.DisplayDiv, div)) {
this.show(div);
}
};
/**
* The option of the display mode of the graphs page. Users can switch modes
* by the second level navigation bar shown on the page. The page would show
* the corresponding div elements containing charts.
* @enum {string}
*/
pagespeed.Graphs.DisplayMode = {
CACHE_APPLIED: 'cache_applied_mode',
CACHE_TYPE: 'cache_type_mode',
IPRO: 'ipro_mode',
REWRITE_IMAGE: 'image_rewriting_mode',
REALTIME: 'realtime_mode'
};
/**
* The id of the div element that should be displayed in each mode. Only the
* chosen div element would be shown. Others would be hidden.
* @enum {string}
*/
pagespeed.Graphs.DisplayDiv = {
CACHE_APPLIED: 'cache_applied',
CACHE_TYPE: 'cache_type',
IPRO: 'ipro',
REWRITE_IMAGE: 'image_rewriting',
REALTIME: 'realtime'
};
/**
* Updates the option of auto-refresh.
*/
pagespeed.Graphs.prototype.toggleAutorefresh = function() {
this.autoRefresh_ = !this.autoRefresh_;
};
/**
* Generate a URL to request statistics data over default time period.
* @return {string} The query URL incorporating all time parameters.
*/
pagespeed.Graphs.prototype.createQueryUrl = function() {
var granularityMs = pagespeed.Graphs.FREQUENCY_ * 1000;
var durationMs = pagespeed.Graphs.TIMERANGE_ * granularityMs; // 1 Day.
var endTime = new Date();
var startTime = new Date(endTime - durationMs);
var queryString = '?json';
queryString += '&start_time=' + startTime.getTime();
queryString += '&end_time=' + endTime.getTime();
queryString += '&granularity=' + granularityMs;
return queryString;
};
/**
* Parses historical data sent by sever.
* @param {string} text The statistics dumped in JSON format.
*/
pagespeed.Graphs.prototype.parseHistoricalMessages = function(text) {
var jsonData = JSON.parse(text);
var timestamps = jsonData['timestamps'];
var variables = jsonData['variables'];
for (var i = 0; i < timestamps.length; ++i) {
var messages = [];
for (var name in variables) {
var node = {
name: name,
value: variables[name][i]
};
messages.push(node);
}
var newArray = {
messages: messages,
timeReceived: new Date(timestamps[i])
};
this.psolMessages_.push(newArray);
}
};
/**
* Parses the most recent data sent by sever.
* @param {string} text The statistics dumped in JSON format.
*/
pagespeed.Graphs.prototype.parseSnapshotMessages = function(text) {
var jsonData = JSON.parse(text);
var variables = jsonData['variables'];
var messages = [];
for (var name in variables) {
var node = {
name: name,
value: variables[name]
};
messages.push(node);
}
var newArray = {
messages: messages,
timeReceived: new Date()
};
this.psolMessages_.push(newArray);
};
/**
* Capture the pathname and return the URL for request.
* @return {string} The URL to request the updated data in JSON format.
*/
pagespeed.Graphs.prototype.requestUrl = function() {
var pathName = location.pathname;
// Ignore the trailing '/'.
var n = pathName.lastIndexOf('/', pathName.length - 2);
// e.g. /pagespeed_admin/foo or pagespeed_admin/foo
if (n > 0) {
return pathName.substring(0, n) + '/stats_json';
} else {
// e.g. /pagespeed_admin or pagespeed_admin
return pathName + '/stats_json';
}
};
/**
* Refreshes the page by making requsts to server.
*/
pagespeed.Graphs.prototype.performRefresh = function() {
// TODO(xqyin): Figure out if there is a potential timing issue that the
// xhr.isActive() would be false before the callback starts.
if (!this.xhr_.isActive()) {
// If the first refresh has not started yet, then do the refresh no matter
// what the autoRefresh option is. Because the page needs to send a query
// URL to request historical data to get initialized.
if (!this.firstRefreshStarted_) {
this.firstRefreshStarted_ = true;
this.xhr_.send(this.createQueryUrl());
// The page needs to do the second refresh to finish initialization.
// Otherwise, only refresh when the autoRefresh option is true.
} else if (!this.secondRefreshStarted_ || this.autoRefresh_) {
this.secondRefreshStarted_ = true;
this.xhr_.send(this.requestUrl());
}
}
};
/**
* Parses the response sent by server and draws charts.
*/
pagespeed.Graphs.prototype.parseAjaxResponse = function() {
if (this.xhr_.isSuccess()) {
var text = this.xhr_.getResponseText();
if (!this.secondRefreshStarted_) {
// We collect historical data in the first refresh and wait for the
// second refresh to update data. We won't call functions to draw charts
// until we make data up-to-date.
this.parseHistoricalMessages(text);
// Add the second refresh to the queue with no delay time. So it would be
// implemented immediately after the first refresh completion.
window.setTimeout(goog.bind(this.performRefresh, this), 0);
} else {
this.parseSnapshotMessages(text);
// Only keep one-day statistics.
if (this.psolMessages_.length > pagespeed.Graphs.TIMERANGE_) {
this.psolMessages_.shift();
}
this.drawVisualization();
}
} else {
console.log(this.xhr_.getLastError());
}
};
/**
* Initialization for drawing all the charts.
*/
pagespeed.Graphs.prototype.drawVisualization = function() {
this.drawBarChart('pcache-cohorts-dom', 'Property cache dom cohorts',
pagespeed.Graphs.DisplayDiv.CACHE_APPLIED);
this.drawBarChart('pcache-cohorts-beacon', 'Property cache beacon cohorts',
pagespeed.Graphs.DisplayDiv.CACHE_APPLIED);
this.drawBarChart('rewrite_cached_output', 'Rewrite cached output',
pagespeed.Graphs.DisplayDiv.CACHE_APPLIED);
this.drawBarChart('url_input', 'URL Input',
pagespeed.Graphs.DisplayDiv.CACHE_APPLIED);
this.drawBarChart('cache', 'Cache',
pagespeed.Graphs.DisplayDiv.CACHE_TYPE);
this.drawBarChart('file_cache', 'File Cache',
pagespeed.Graphs.DisplayDiv.CACHE_TYPE);
this.drawBarChart('memcached', 'Memcached',
pagespeed.Graphs.DisplayDiv.CACHE_TYPE);
this.drawBarChart('lru_cache', 'LRU',
pagespeed.Graphs.DisplayDiv.CACHE_TYPE);
this.drawBarChart('shm_cache', 'Shared Memory',
pagespeed.Graphs.DisplayDiv.CACHE_TYPE);
this.drawBarChart('ipro', 'In place resource optimization',
pagespeed.Graphs.DisplayDiv.IPRO);
this.drawBarChart('image_rewrite', 'Image rewrite',
pagespeed.Graphs.DisplayDiv.REWRITE_IMAGE);
this.drawBarChart('image_rewrites_dropped', 'Image rewrites dropped',
pagespeed.Graphs.DisplayDiv.REWRITE_IMAGE);
this.drawHistoryChart('http', 'Http', pagespeed.Graphs.DisplayDiv.REALTIME);
this.drawHistoryChart('file_cache', 'File Cache RT',
pagespeed.Graphs.DisplayDiv.REALTIME);
this.drawHistoryChart('lru_cache', 'LRU Cache RT',
pagespeed.Graphs.DisplayDiv.REALTIME);
this.drawHistoryChart('serf_fetch', 'Serf stats RT',
pagespeed.Graphs.DisplayDiv.REALTIME);
this.drawHistoryChart('rewrite', 'Rewrite stats RT',
pagespeed.Graphs.DisplayDiv.REALTIME);
};
/**
* Screens data to generate charts according to the setting prefix.
* @param {string} prefix The setting prefix to match.
* @param {string} name The name of the statistics.
* @return {boolean} Return true if the data should be used in the chart.
*/
pagespeed.Graphs.screenData = function(prefix, name) {
var use = true;
if (name.indexOf(prefix) != 0) {
use = false;
// We skip here because the statistics below won't be used in any charts.
} else if (name.indexOf('cache_flush_timestamp_ms') >= 0) {
use = false;
} else if (name.indexOf('cache_flush_count') >= 0) {
use = false;
} else if (name.indexOf('cache_time_us') >= 0) {
use = false;
}
return use;
};
/**
* Initialize a chart using Google Charts API.
* @param {string} id The ID of the chart div to create.
* @param {string} title The title of the chart.
* @param {string} chartType The type of chart. AnnotatedTimeLine or BarChart.
* @param {string} targetId The id of the target HTML element.
* @return {Object} The created chart object.
*/
pagespeed.Graphs.prototype.initChart = function(
id, title, chartType, targetId) {
var theChart;
// TODO(oschaaf): Title might not be unique
if (this.chartCache_[title]) {
theChart = this.chartCache_[title];
} else {
// The element identified by the id must exist.
var targetElement = document.getElementById(targetId);
// Remove the status info when it starts to draw charts.
if (targetElement.textContent == 'Loading Charts...') {
targetElement.textContent = '';
}
var dest = document.createElement('div');
if (chartType == 'AnnotatedTimeLine') {
dest.className = 'pagespeed-graphs-chart';
}
dest.id = id;
var chartTitle = document.createElement('p');
chartTitle.textContent = title;
chartTitle.className = 'pagespeed-graphs-title';
targetElement.appendChild(chartTitle);
targetElement.appendChild(dest);
theChart = new google.visualization[chartType](dest);
this.chartCache_[title] = theChart;
}
return theChart;
};
/**
* Draw a bar chart using Google Charts API.
* @param {string} prefix The statistics prefix of data for the chart.
* @param {string} title The title of the chart.
* @param {string} targetId The id of the target HTML element.
*/
pagespeed.Graphs.prototype.drawBarChart = function(prefix, title, targetId) {
var id = 'pagespeed-graphs-' + prefix;
var settingPrefix = prefix + '_';
var theChart = this.initChart(id, title, 'BarChart', targetId);
var dest = document.getElementById(id);
var rows = [];
var data = new google.visualization.DataTable();
var messages = goog.array.clone(
this.psolMessages_[this.psolMessages_.length - 1].messages);
var numBars = 0;
for (var i = 0; i < messages.length; ++i) {
if (!pagespeed.Graphs.screenData(settingPrefix, messages[i].name)) {
continue;
}
++numBars;
// Removes the prefix.
var caption = messages[i].name.substring(settingPrefix.length);
// We use regexp here to replace underscores all at once.
// Using '_' would only replace one underscore at a time.
caption = caption.replace(/_/g, ' ');
rows.push([caption, Number(messages[i].value)]);
}
data.addColumn('string', 'Name');
data.addColumn('number', 'Value');
data.addRows(rows);
var view = new google.visualization.DataView(data);
var getStats = function(dataTable, rowNum) {
var sum = 0;
for (var i = 0; i < dataTable.getNumberOfRows(); ++i) {
sum += dataTable.getValue(i, 1);
}
var value = dataTable.getValue(rowNum, 1);
var percent = value * 100 / ((sum == 0) ? 1 : sum);
return value.toString() + ' (' + percent.toFixed(2).toString() + '%)';
};
view.setColumns(
[0, 1, {'calc': getStats, 'type': 'string', 'role': 'annotation'}]);
var barHeight = 40 * numBars + 10;
dest.style.height = barHeight + 20;
// The options used for drawing google.visualization.barChart graphs.
var barChartOptions = {
'annotations': {
// TODO(jmarantz): Although I kind of prefer the consistency of
// always showing labels outside the bar, it does waste a bit of
// horizontal space. However we have no choice at the moment
// due to a Charts API bug estimating text sizes in the context
// of bars that are not high enough to fit two lines of text.
//
// Once this bug is fixed (best estimate: Oct 2014) we should
// consider setting 'alwaysOutside' to 'false', and then we can
// try changing the chartArea width percentage below from 60% to 80%.
'alwaysOutside': true, // Always put the "value(%)" outside the bar.
'highContrast': true,
'textStyle': {
'fontSize': 12,
'color': 'black'
}
},
'hAxis': {
'direction': 1
},
'vAxis': {
'textPosition': 'out' // position of left-most vertical axis label.
},
'legend': {
'position': 'none'
},
'width': 800,
'height': barHeight,
'chartArea': {
'left': 225,
'top': 0,
'width': '60%', // Tweak this to avoid clipping outside labels.
'height': '80%'
}
};
theChart.draw(view, barChartOptions);
};
/**
* Draw a history chart using Google Charts API.
* @param {string} prefix The statistics prefix of data for the chart.
* @param {string} title The title of the chart.
* @param {string} targetId The id of the target HTML element.
*/
pagespeed.Graphs.prototype.drawHistoryChart = function(
prefix, title, targetId) {
var id = 'pagespeed-graphs-' + prefix;
var settingPrefix = prefix + '_';
var theChart = this.initChart(id, title, 'AnnotatedTimeLine', targetId);
var dest = document.getElementById(id);
var rows = [];
var data = new google.visualization.DataTable();
// The annotated time line for data history.
data.addColumn('datetime', 'Time');
var first = true;
for (var i = 0; i < this.psolMessages_.length; ++i) {
var messages = goog.array.clone(this.psolMessages_[i].messages);
var row = [];
row.push(this.psolMessages_[i].timeReceived);
for (var j = 0; j < messages.length; ++j) {
if (!pagespeed.Graphs.screenData(settingPrefix, messages[j].name)) {
continue;
}
row.push(Number(messages[j].value));
if (first) {
var caption = messages[j].name.substring(settingPrefix.length);
caption = caption.replace(/_/g, ' ');
data.addColumn('number', caption);
}
}
first = false;
rows.push(row);
}
data.addRows(rows);
theChart.draw(data, pagespeed.Graphs.ANNOTATED_TIMELINE_OPTIONS_);
};
/**
* The options used for drawing google.visualization.AnnotatedTimeLine graphs.
* @private {Object}
* @const
*/
pagespeed.Graphs.ANNOTATED_TIMELINE_OPTIONS_ = {
'thickness': 1,
'displayExactValues': true,
'legendPosition': 'newRow'
};
/**
* The frequency of auto-refresh. Default is once per 5 seconds.
* @private {number}
* @const
*/
pagespeed.Graphs.FREQUENCY_ = 5;
/**
* The size limit to the data array. Default is one day.
* @private {number}
* @const
*/
pagespeed.Graphs.TIMERANGE_ = 24 * 60 * 60 /
pagespeed.Graphs.FREQUENCY_;
/**
* The Main entry to start processing.
* @export
*/
pagespeed.Graphs.Start = function() {
var graphsOnload = function() {
var graphsObj = new pagespeed.Graphs();
graphsObj.parseLocation();
for (var i in pagespeed.Graphs.DisplayDiv) {
goog.events.listen(
document.getElementById(pagespeed.Graphs.DisplayMode[i]), 'click',
goog.bind(graphsObj.show, graphsObj, pagespeed.Graphs.DisplayDiv[i]));
}
// IE6/7 don't support this event. Then the back button would not work
// because no requests captured.
goog.events.listen(window, 'hashchange',
goog.bind(graphsObj.parseLocation, graphsObj));
goog.events.listen(document.getElementById('auto-refresh'), 'change',
goog.bind(graphsObj.toggleAutorefresh,
graphsObj));
// We call listen() here so this listener is added to the xhr only once.
// If we call listen() inside performRefresh() method, we are adding a new
// listener to the xhr every time it auto-refreshes, which would cause
// fetchContent() being called multiple times. Users will see an obvious
// delay because we draw the same charts multiple times in one refresh.
goog.events.listen(
graphsObj.xhr_, goog.net.EventType.COMPLETE,
goog.bind(graphsObj.parseAjaxResponse, graphsObj));
setInterval(goog.bind(graphsObj.performRefresh, graphsObj),
pagespeed.Graphs.FREQUENCY_ * 1000);
graphsObj.performRefresh();
};
goog.events.listen(window, 'load', graphsOnload);
};