diff --git a/public/src/admin/general/dashboard.js b/public/src/admin/general/dashboard.js index 91a40387b3..930e996cdc 100644 --- a/public/src/admin/general/dashboard.js +++ b/public/src/admin/general/dashboard.js @@ -145,32 +145,6 @@ define('admin/general/dashboard', ['semver'], function(semver) { return (usePound?"#":"") + (g | (b << 8) | (r << 16)).toString(16); } - function getHoursArray() { - var currentHour = new Date().getHours(), - labels = []; - - for (var i = currentHour, ii = currentHour - 24; i > ii; i--) { - var hour = i < 0 ? 24 + i : i; - labels.push(hour + ':00'); - } - - return labels.reverse(); - } - - function getDaysArray(from) { - var currentDay = new Date(from || Date.now()).getTime(), - months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], - labels = [], - tmpDate; - - for(var x=29;x>=0;x--) { - tmpDate = new Date(currentDay - (1000*60*60*24*x)); - labels.push(months[tmpDate.getMonth()] + ' ' + tmpDate.getDate()); - } - - return labels; - } - function setupGraphs() { var trafficCanvas = document.getElementById('analytics-traffic'), registeredCanvas = document.getElementById('analytics-registered'), @@ -180,7 +154,7 @@ define('admin/general/dashboard', ['semver'], function(semver) { registeredCtx = registeredCanvas.getContext('2d'), presenceCtx = presenceCanvas.getContext('2d'), topicsCtx = topicsCanvas.getContext('2d'), - trafficLabels = getHoursArray(); + trafficLabels = utils.getHoursArray(); if (isMobile) { Chart.defaults.global.showTooltips = false; @@ -325,9 +299,9 @@ define('admin/general/dashboard', ['semver'], function(semver) { } if (units === 'days') { - graphs.traffic.scale.xLabels = getDaysArray(until); + graphs.traffic.scale.xLabels = utils.getDaysArray(until); } else { - graphs.traffic.scale.xLabels = getHoursArray(); + graphs.traffic.scale.xLabels = utils.getHoursArray(); $('#pageViewsThisMonth').html(data.monthlyPageViews.thisMonth); $('#pageViewsLastMonth').html(data.monthlyPageViews.lastMonth); diff --git a/public/src/admin/manage/category.js b/public/src/admin/manage/category.js index ca3d9982c6..b4eb876591 100644 --- a/public/src/admin/manage/category.js +++ b/public/src/admin/manage/category.js @@ -1,5 +1,5 @@ "use strict"; -/*global define, app, socket, ajaxify, RELATIVE_PATH, bootbox, templates */ +/*global define, app, socket, ajaxify, RELATIVE_PATH, bootbox, templates, Chart */ define('admin/manage/category', [ 'uploader', @@ -145,6 +145,12 @@ define('admin/manage/category', [ }); Category.setupPrivilegeTable(); + + if (window.location.hash === '#analytics') { + Category.setupGraphs(); + } else { + $('a[href="#analytics"]').on('shown.bs.tab', Category.setupGraphs); + } }; Category.setupPrivilegeTable = function() { @@ -345,5 +351,106 @@ define('admin/manage/category', [ }); }; + Category.setupGraphs = function() { + var hourlyCanvas = document.getElementById('pageviews:hourly'), + dailyCanvas = document.getElementById('pageviews:daily'), + topicsCanvas = document.getElementById('topics:daily'), + postsCanvas = document.getElementById('posts:daily'), + hourlyLabels = utils.getHoursArray().map(function(text, idx) { + return idx % 3 ? '' : text; + }), + dailyLabels = utils.getDaysArray().map(function(text, idx) { + return idx % 3 ? '' : text; + }); + + if (utils.isMobile()) { + Chart.defaults.global.showTooltips = false; + } + + var data = { + 'pageviews:hourly': { + labels: hourlyLabels, + datasets: [ + { + label: "", + fillColor: "rgba(220,220,220,0.2)", + strokeColor: "rgba(220,220,220,1)", + pointColor: "rgba(220,220,220,1)", + pointStrokeColor: "#fff", + pointHighlightFill: "#fff", + pointHighlightStroke: "rgba(220,220,220,1)", + data: ajaxify.data.analytics['pageviews:hourly'] + } + ] + }, + 'pageviews:daily': { + labels: dailyLabels, + datasets: [ + { + label: "", + fillColor: "rgba(151,187,205,0.2)", + strokeColor: "rgba(151,187,205,1)", + pointColor: "rgba(151,187,205,1)", + pointStrokeColor: "#fff", + pointHighlightFill: "#fff", + pointHighlightStroke: "rgba(151,187,205,1)", + data: ajaxify.data.analytics['pageviews:daily'] + } + ] + }, + 'topics:daily': { + labels: dailyLabels.slice(-7), + datasets: [ + { + label: "", + fillColor: "rgba(151,187,205,0.2)", + strokeColor: "rgba(151,187,205,1)", + pointColor: "rgba(151,187,205,1)", + pointStrokeColor: "#fff", + pointHighlightFill: "#fff", + pointHighlightStroke: "rgba(151,187,205,1)", + data: ajaxify.data.analytics['topics:daily'] + } + ] + }, + 'posts:daily': { + labels: dailyLabels.slice(-7), + datasets: [ + { + label: "", + fillColor: "rgba(151,187,205,0.2)", + strokeColor: "rgba(151,187,205,1)", + pointColor: "rgba(151,187,205,1)", + pointStrokeColor: "#fff", + pointHighlightFill: "#fff", + pointHighlightStroke: "rgba(151,187,205,1)", + data: ajaxify.data.analytics['posts:daily'] + } + ] + }, + }; + + hourlyCanvas.width = $(hourlyCanvas).parent().width(); + dailyCanvas.width = $(dailyCanvas).parent().width(); + topicsCanvas.width = $(topicsCanvas).parent().width(); + postsCanvas.width = $(postsCanvas).parent().width(); + new Chart(hourlyCanvas.getContext('2d')).Line(data['pageviews:hourly'], { + responsive: true, + animation: false + }); + new Chart(dailyCanvas.getContext('2d')).Line(data['pageviews:daily'], { + responsive: true, + animation: false + }); + new Chart(topicsCanvas.getContext('2d')).Line(data['topics:daily'], { + responsive: true, + animation: false + }); + new Chart(postsCanvas.getContext('2d')).Line(data['posts:daily'], { + responsive: true, + animation: false + }); + }; + return Category; }); \ No newline at end of file diff --git a/public/src/utils.js b/public/src/utils.js index a5c7827635..2c821c1701 100644 --- a/public/src/utils.js +++ b/public/src/utils.js @@ -259,6 +259,39 @@ } }, + isMobile: function() { + var env = utils.findBootstrapEnvironment(); + return ['xs', 'sm'].some(function(targetEnv) { + return targetEnv === env; + }); + }, + + getHoursArray: function() { + var currentHour = new Date().getHours(), + labels = []; + + for (var i = currentHour, ii = currentHour - 24; i > ii; i--) { + var hour = i < 0 ? 24 + i : i; + labels.push(hour + ':00'); + } + + return labels.reverse(); + }, + + getDaysArray: function(from) { + var currentDay = new Date(from || Date.now()).getTime(), + months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + labels = [], + tmpDate; + + for(var x=29;x>=0;x--) { + tmpDate = new Date(currentDay - (1000*60*60*24*x)); + labels.push(months[tmpDate.getMonth()] + ' ' + tmpDate.getDate()); + } + + return labels; + }, + // get all the url params in a single key/value hash params: function(options) { var a, hash = {}, params; diff --git a/src/analytics.js b/src/analytics.js index 5d23937ad2..67b82c8d5e 100644 --- a/src/analytics.js +++ b/src/analytics.js @@ -1,23 +1,37 @@ 'use strict'; var cronJob = require('cron').CronJob; +var async = require('async'); + var db = require('./database'); (function(Analytics) { + var counters = {}; var pageViews = 0; var uniqueIPCount = 0; var uniquevisitors = 0; + var isCategory = /^(?:\/api)?\/category\/(\d+)/; + new cronJob('*/10 * * * *', function() { Analytics.writeData(); }, null, true); - Analytics.pageView = function(ip) { + Analytics.increment = function(keys) { + keys = Array.isArray(keys) ? keys : [keys]; + + keys.forEach(function(key) { + counters[key] = counters[key] || 0; + ++counters[key]; + }); + }; + + Analytics.pageView = function(payload) { ++pageViews; - if (ip) { - db.sortedSetScore('ip:recent', ip, function(err, score) { + if (payload.ip) { + db.sortedSetScore('ip:recent', payload.ip, function(err, score) { if (err) { return; } @@ -28,40 +42,116 @@ var db = require('./database'); today.setHours(today.getHours(), 0, 0, 0); if (!score || score < today.getTime()) { ++uniquevisitors; - db.sortedSetAdd('ip:recent', Date.now(), ip); + db.sortedSetAdd('ip:recent', Date.now(), payload.ip); } }); } + + if (payload.path) { + var categoryMatch = payload.path.match(isCategory), + cid = categoryMatch ? parseInt(categoryMatch[1], 10) : null; + + if (cid) { + Analytics.increment(['pageviews:byCid:' + cid]); + } + } }; Analytics.writeData = function() { + var today = new Date(); + var month = new Date(); + var dbQueue = []; - var today; - if (pageViews > 0 || uniquevisitors > 0) { - today = new Date(); - today.setHours(today.getHours(), 0, 0, 0); - } + today.setHours(today.getHours(), 0, 0, 0); + month.setMonth(month.getMonth(), 1); + month.setHours(0, 0, 0, 0); if (pageViews > 0) { - db.sortedSetIncrBy('analytics:pageviews', pageViews, today.getTime()); - var month = new Date(); - month.setMonth(month.getMonth(), 1); - month.setHours(0, 0, 0, 0); - db.sortedSetIncrBy('analytics:pageviews:month', pageViews, month.getTime()); + dbQueue.push(async.apply(db.sortedSetIncrBy, 'analytics:pageviews', pageViews, today.getTime())); + dbQueue.push(async.apply(db.sortedSetIncrBy, 'analytics:pageviews:month', pageViews, month.getTime())); pageViews = 0; } if (uniquevisitors > 0) { - db.sortedSetIncrBy('analytics:uniquevisitors', uniquevisitors, today.getTime()); + dbQueue.push(async.apply(db.sortedSetIncrBy, 'analytics:uniquevisitors', uniquevisitors, today.getTime())); uniquevisitors = 0; } if (uniqueIPCount > 0) { - db.incrObjectFieldBy('global', 'uniqueIPCount', uniqueIPCount); + dbQueue.push(async.apply(db.incrObjectFieldBy, 'global', 'uniqueIPCount', uniqueIPCount)); uniqueIPCount = 0; } + if (Object.keys(counters).length > 0) { + for(var key in counters) { + console.log('flushing', key, 'with a value of', counters[key]); + dbQueue.push(async.apply(db.sortedSetIncrBy, 'analytics:' + key, counters[key], today.getTime())); + delete counters[key]; + } + } + async.parallel(dbQueue, function(err) { + if (err) { + winston.error('[analytics] Encountered error while writing analytics to data store: ' + err.message); + } + }); + }; + + Analytics.getHourlyStatsForSet = function(set, hour, numHours, callback) { + var terms = {}, + hoursArr = []; + + hour = new Date(hour); + hour.setHours(hour.getHours(), 0, 0, 0); + + for (var i = 0, ii = numHours; i < ii; i++) { + hoursArr.push(hour.getTime()); + hour.setHours(hour.getHours() - 1, 0, 0, 0); + } + + db.sortedSetScores(set, hoursArr, function(err, counts) { + if (err) { + return callback(err); + } + + hoursArr.forEach(function(term, index) { + terms[term] = parseInt(counts[index], 10) || 0; + }); + + var termsArr = []; + + hoursArr.reverse(); + hoursArr.forEach(function(hour) { + termsArr.push(terms[hour]); + }); + + callback(null, termsArr); + }); + }; + + Analytics.getDailyStatsForSet = function(set, day, numDays, callback) { + var daysArr = []; + + day = new Date(day); + day.setDate(day.getDate()+1); // set the date to tomorrow, because getHourlyStatsForSet steps *backwards* 24 hours to sum up the values + day.setHours(0, 0, 0, 0); + + async.whilst(function() { + return numDays--; + }, function(next) { + Analytics.getHourlyStatsForSet(set, day.getTime()-(1000*60*60*24*numDays), 24, function(err, day) { + if (err) { + return next(err); + } + + daysArr.push(day.reduce(function(cur, next) { + return cur+next; + })); + next(); + }); + }, function(err) { + callback(err, daysArr); + }); }; Analytics.getUnwrittenPageviews = function() { @@ -86,4 +176,13 @@ var db = require('./database'); }); }; + Analytics.getCategoryAnalytics = function(cid, callback) { + async.parallel({ + 'pageviews:hourly': async.apply(Analytics.getHourlyStatsForSet, 'analytics:pageviews:byCid:' + cid, Date.now(), 24), + 'pageviews:daily': async.apply(Analytics.getDailyStatsForSet, 'analytics:pageviews:byCid:' + cid, Date.now(), 30), + 'topics:daily': async.apply(Analytics.getDailyStatsForSet, 'analytics:topics:byCid:' + cid, Date.now(), 7), + 'posts:daily': async.apply(Analytics.getDailyStatsForSet, 'analytics:posts:byCid:' + cid, Date.now(), 7), + }, callback); + }; + }(exports)); \ No newline at end of file diff --git a/src/controllers/admin/categories.js b/src/controllers/admin/categories.js index 99bda3285b..704dc21a2c 100644 --- a/src/controllers/admin/categories.js +++ b/src/controllers/admin/categories.js @@ -4,6 +4,7 @@ var async = require('async'), categories = require('../../categories'), privileges = require('../../privileges'), + analytics = require('../../analytics'), plugins = require('../../plugins'); @@ -12,20 +13,22 @@ var categoriesController = {}; categoriesController.get = function(req, res, next) { async.parallel({ category: async.apply(categories.getCategories, [req.params.category_id], req.user.uid), - privileges: async.apply(privileges.categories.list, req.params.category_id) + privileges: async.apply(privileges.categories.list, req.params.category_id), + analytics: async.apply(analytics.getCategoryAnalytics, req.params.category_id) }, function(err, data) { if (err) { return next(err); } - plugins.fireHook('filter:admin.category.get', {req: req, res: res, category: data.category[0], privileges: data.privileges}, function(err, data) { + plugins.fireHook('filter:admin.category.get', { req: req, res: res, category: data.category[0], privileges: data.privileges, analytics: data.analytics }, function(err, data) { if (err) { return next(err); } res.render('admin/manage/category', { category: data.category, - privileges: data.privileges + privileges: data.privileges, + analytics: data.analytics }); }); }); diff --git a/src/middleware/middleware.js b/src/middleware/middleware.js index fb36635a38..d5e1d93388 100644 --- a/src/middleware/middleware.js +++ b/src/middleware/middleware.js @@ -48,7 +48,11 @@ middleware.applyCSRF = csrf(); middleware.ensureLoggedIn = ensureLoggedIn.ensureLoggedIn(nconf.get('relative_path') + '/login'); middleware.pageView = function(req, res, next) { - analytics.pageView(req.ip); + analytics.pageView({ + ip: req.ip, + path: req.path, + uid: req.hasOwnProperty('user') && req.user.hasOwnProperty('uid') ? parseInt(req.user.uid, 10) : 0 + }); plugins.fireHook('action:middleware.pageView', {req: req}); diff --git a/src/socket.io/admin.js b/src/socket.io/admin.js index ec863e4c39..853d3f1536 100644 --- a/src/socket.io/admin.js +++ b/src/socket.io/admin.js @@ -217,16 +217,16 @@ SocketAdmin.analytics.get = function(socket, data, callback) { async.parallel({ uniqueVisitors: function(next) { if (data.units === 'days') { - getDailyStatsForSet('analytics:uniquevisitors', data.until || Date.now(), data.amount, next); + analytics.getDailyStatsForSet('analytics:uniquevisitors', data.until || Date.now(), data.amount, next); } else { - getHourlyStatsForSet('analytics:uniquevisitors', data.until || Date.now(), data.amount, next); + analytics.getHourlyStatsForSet('analytics:uniquevisitors', data.until || Date.now(), data.amount, next); } }, pageviews: function(next) { if (data.units === 'days') { - getDailyStatsForSet('analytics:pageviews', data.until || Date.now(), data.amount, next); + analytics.getDailyStatsForSet('analytics:pageviews', data.until || Date.now(), data.amount, next); } else { - getHourlyStatsForSet('analytics:pageviews', data.until || Date.now(), data.amount, next); + analytics.getHourlyStatsForSet('analytics:pageviews', data.until || Date.now(), data.amount, next); } }, monthlyPageViews: function(next) { @@ -251,62 +251,6 @@ SocketAdmin.logs.clear = function(socket, data, callback) { meta.logs.clear(callback); }; -function getHourlyStatsForSet(set, hour, numHours, callback) { - var terms = {}, - hoursArr = []; - - hour = new Date(hour); - hour.setHours(hour.getHours(), 0, 0, 0); - - for (var i = 0, ii = numHours; i < ii; i++) { - hoursArr.push(hour.getTime()); - hour.setHours(hour.getHours() - 1, 0, 0, 0); - } - - db.sortedSetScores(set, hoursArr, function(err, counts) { - if (err) { - return callback(err); - } - - hoursArr.forEach(function(term, index) { - terms[term] = parseInt(counts[index], 10) || 0; - }); - - var termsArr = []; - - hoursArr.reverse(); - hoursArr.forEach(function(hour) { - termsArr.push(terms[hour]); - }); - - callback(null, termsArr); - }); -} - -function getDailyStatsForSet(set, day, numDays, callback) { - var daysArr = []; - - day = new Date(day); - day.setHours(0, 0, 0, 0); - - async.whilst(function() { - return numDays--; - }, function(next) { - getHourlyStatsForSet(set, day.getTime()-(1000*60*60*24*numDays), 24, function(err, day) { - if (err) { - return next(err); - } - - daysArr.push(day.reduce(function(cur, next) { - return cur+next; - })); - next(); - }); - }, function(err) { - callback(err, daysArr); - }); -} - SocketAdmin.getMoreEvents = function(socket, next, callback) { var start = parseInt(next, 10); if (start < 0) { diff --git a/src/topics/create.js b/src/topics/create.js index 931e862b13..6c7c4bccf1 100644 --- a/src/topics/create.js +++ b/src/topics/create.js @@ -6,6 +6,7 @@ var async = require('async'), db = require('../database'), utils = require('../../public/src/utils'), plugins = require('../plugins'), + analytics = require('../analytics'), user = require('../user'), meta = require('../meta'), posts = require('../posts'), @@ -15,7 +16,7 @@ var async = require('async'), module.exports = function(Topics) { Topics.create = function(data, callback) { - // This is an interal method, consider using Topics.post instead + // This is an internal method, consider using Topics.post instead var timestamp = data.timestamp || Date.now(); var topicData; @@ -171,6 +172,7 @@ module.exports = function(Topics) { data.topicData.mainPost = data.postData; data.postData.index = 0; + analytics.increment(['topics', 'topics:byCid:' + data.topicData.cid]); plugins.fireHook('action:topic.post', data.topicData); if (parseInt(uid, 10)) { @@ -256,6 +258,7 @@ module.exports = function(Topics) { } Topics.notifyFollowers(postData, uid); + analytics.increment(['posts', 'posts:byCid:' + cid]); plugins.fireHook('action:topic.reply', postData); next(null, postData); diff --git a/src/views/admin/manage/category.tpl b/src/views/admin/manage/category.tpl index 4d421757fe..1ca571010d 100644 --- a/src/views/admin/manage/category.tpl +++ b/src/views/admin/manage/category.tpl @@ -3,6 +3,7 @@
+ Figure 1 – Hourly page views for this category +
++ Figure 2 – Daily page views for this category +
++ Figure 3 – Daily topics created in this category +
++ Figure 4 – Daily posts made in this category +
+