From f561799f7439d5fb4039a4773301b6327cb94bcc Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Mon, 22 Feb 2021 16:37:17 -0500 Subject: [PATCH] refactor: abstract out some client side dashboard code into modules, analytics subpages for users, topics, and logins --- public/language/en-GB/admin/menu.json | 5 + public/src/admin/dashboard/logins.js | 14 ++ public/src/admin/dashboard/topics.js | 14 ++ public/src/admin/dashboard/users.js | 14 ++ .../src/admin/modules/dashboard-line-graph.js | 177 ++++++++++++++++++ src/controllers/admin/dashboard.js | 70 +++++++ src/controllers/write/admin.js | 21 +++ src/routes/admin.js | 3 + src/routes/write/admin.js | 2 + src/user/create.js | 2 + src/views/admin/dashboard.tpl | 77 +------- src/views/admin/dashboard/logins.tpl | 6 + src/views/admin/dashboard/topics.tpl | 6 + src/views/admin/dashboard/users.tpl | 6 + src/views/admin/partials/dashboard/graph.tpl | 33 ++++ src/views/admin/partials/dashboard/stats.tpl | 49 +++++ 16 files changed, 424 insertions(+), 75 deletions(-) create mode 100644 public/src/admin/dashboard/logins.js create mode 100644 public/src/admin/dashboard/topics.js create mode 100644 public/src/admin/dashboard/users.js create mode 100644 public/src/admin/modules/dashboard-line-graph.js create mode 100644 src/views/admin/dashboard/logins.tpl create mode 100644 src/views/admin/dashboard/topics.tpl create mode 100644 src/views/admin/dashboard/users.tpl create mode 100644 src/views/admin/partials/dashboard/graph.tpl create mode 100644 src/views/admin/partials/dashboard/stats.tpl diff --git a/public/language/en-GB/admin/menu.json b/public/language/en-GB/admin/menu.json index 01cc355fa1..397cf87a62 100644 --- a/public/language/en-GB/admin/menu.json +++ b/public/language/en-GB/admin/menu.json @@ -1,5 +1,10 @@ { "dashboard": "Dashboard", + + "section-dashboard": "Dashboard", + "dashboard/logins": "Logins", + "dashboard/users": "Users", + "dashboard/topics": "Topics", "section-general": "General", "section-manage": "Manage", diff --git a/public/src/admin/dashboard/logins.js b/public/src/admin/dashboard/logins.js new file mode 100644 index 0000000000..eea048293c --- /dev/null +++ b/public/src/admin/dashboard/logins.js @@ -0,0 +1,14 @@ +'use strict'; + +define('admin/dashboard/logins', ['admin/modules/dashboard-line-graph'], (graph) => { + const ACP = {}; + + ACP.init = () => { + graph.init({ + set: 'logins', + dataset: ajaxify.data.dataset, + }); + }; + + return ACP; +}); diff --git a/public/src/admin/dashboard/topics.js b/public/src/admin/dashboard/topics.js new file mode 100644 index 0000000000..6bf8cc65dc --- /dev/null +++ b/public/src/admin/dashboard/topics.js @@ -0,0 +1,14 @@ +'use strict'; + +define('admin/dashboard/topics', ['admin/modules/dashboard-line-graph'], (graph) => { + const ACP = {}; + + ACP.init = () => { + graph.init({ + set: 'topics', + dataset: ajaxify.data.dataset, + }); + }; + + return ACP; +}); diff --git a/public/src/admin/dashboard/users.js b/public/src/admin/dashboard/users.js new file mode 100644 index 0000000000..7804201526 --- /dev/null +++ b/public/src/admin/dashboard/users.js @@ -0,0 +1,14 @@ +'use strict'; + +define('admin/dashboard/users', ['admin/modules/dashboard-line-graph'], (graph) => { + const ACP = {}; + + ACP.init = () => { + graph.init({ + set: 'registrations', + dataset: ajaxify.data.dataset, + }); + }; + + return ACP; +}); diff --git a/public/src/admin/modules/dashboard-line-graph.js b/public/src/admin/modules/dashboard-line-graph.js new file mode 100644 index 0000000000..5e12067cee --- /dev/null +++ b/public/src/admin/modules/dashboard-line-graph.js @@ -0,0 +1,177 @@ +'use strict'; + +define('admin/modules/dashboard-line-graph', ['Chart', 'translator', 'benchpress', 'api'], function (Chart, translator, Benchpress, api) { + const Graph = { + _current: null, + }; + let isMobile = false; + + Graph.init = ({ set, dataset }) => { + const canvas = document.getElementById('analytics-traffic'); + const canvasCtx = canvas.getContext('2d'); + const trafficLabels = utils.getHoursArray(); + + isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); + if (isMobile) { + Chart.defaults.global.tooltips.enabled = false; + } + + var t = translator.Translator.create(); + t.translateKey(`admin/menu:${ajaxify.data.template.name.replace('admin/', '')}`, []).then((key) => { + const data = { + labels: trafficLabels, + datasets: [ + { + label: key, + backgroundColor: 'rgba(151,187,205,0.2)', + borderColor: 'rgba(151,187,205,1)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointHoverBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: '#fff', + pointHoverBorderColor: 'rgba(151,187,205,1)', + data: dataset, + }, + ], + }; + + canvas.width = $(canvas).parent().width(); + + data.datasets[0].yAxisID = 'left-y-axis'; + + Graph._current = new Chart(canvasCtx, { + type: 'line', + data: data, + options: { + responsive: true, + legend: { + display: true, + }, + scales: { + yAxes: [{ + id: 'left-y-axis', + ticks: { + beginAtZero: true, + precision: 0, + }, + type: 'linear', + position: 'left', + scaleLabel: { + display: true, + labelString: key, + }, + }], + }, + tooltips: { + mode: 'x', + }, + }, + }); + }); + + $('[data-action="updateGraph"]:not([data-units="custom"])').on('click', function () { + var until = new Date(); + var amount = $(this).attr('data-amount'); + if ($(this).attr('data-units') === 'days') { + until.setHours(0, 0, 0, 0); + } + until = until.getTime(); + Graph.update(set, $(this).attr('data-units'), until, amount); + $('[data-action="updateGraph"]').removeClass('active'); + $(this).addClass('active'); + + require(['translator'], function (translator) { + translator.translate('[[admin/dashboard:page-views-custom]]', function (translated) { + $('[data-action="updateGraph"][data-units="custom"]').text(translated); + }); + }); + }); + + $('[data-action="updateGraph"][data-units="custom"]').on('click', function () { + var targetEl = $(this); + + Benchpress.render('admin/partials/pageviews-range-select', {}).then(function (html) { + var modal = bootbox.dialog({ + title: '[[admin/dashboard:page-views-custom]]', + message: html, + buttons: { + submit: { + label: '[[global:search]]', + className: 'btn-primary', + callback: submit, + }, + }, + }).on('shown.bs.modal', function () { + var date = new Date(); + var today = date.toISOString().substr(0, 10); + date.setDate(date.getDate() - 1); + var yesterday = date.toISOString().substr(0, 10); + + modal.find('#startRange').val(targetEl.attr('data-startRange') || yesterday); + modal.find('#endRange').val(targetEl.attr('data-endRange') || today); + }); + + function submit() { + // NEED TO ADD VALIDATION HERE FOR YYYY-MM-DD + var formData = modal.find('form').serializeObject(); + var validRegexp = /\d{4}-\d{2}-\d{2}/; + + // Input validation + if (!formData.startRange && !formData.endRange) { + // No range? Assume last 30 days + Graph.update(set, 'days'); + $('[data-action="updateGraph"]').removeClass('active'); + $('[data-action="updateGraph"][data-units="days"]').addClass('active'); + return; + } else if (!validRegexp.test(formData.startRange) || !validRegexp.test(formData.endRange)) { + // Invalid Input + modal.find('.alert-danger').removeClass('hidden'); + return false; + } + + var until = new Date(formData.endRange); + until.setDate(until.getDate() + 1); + until = until.getTime(); + var amount = (until - new Date(formData.startRange).getTime()) / (1000 * 60 * 60 * 24); + + Graph.update(set, 'days', until, amount); + $('[data-action="updateGraph"]').removeClass('active'); + targetEl.addClass('active'); + + // Update "custom range" label + targetEl.attr('data-startRange', formData.startRange); + targetEl.attr('data-endRange', formData.endRange); + targetEl.html(formData.startRange + ' – ' + formData.endRange); + } + }); + }); + }; + + Graph.update = (set, units, until, amount) => { + if (!Graph._current) { + return; + } + + api.get(`/admin/analytics/${set}`, { units, until, amount }).then((dataset) => { + if (units === 'days') { + Graph._current.data.xLabels = utils.getDaysArray(until, amount); + } else { + Graph._current.data.xLabels = utils.getHoursArray(); + } + + Graph._current.data.datasets[0].data = dataset; + Graph._current.data.labels = Graph._current.data.xLabels; + Graph._current.update(); + + // Update the View as JSON button url + var apiEl = $('#view-as-json'); + var newHref = $.param({ + units: units || 'hours', + until: until, + count: amount, + }); + apiEl.attr('href', `${config.relative_path}/api/v3/admin/analytics/${ajaxify.data.set}?${newHref}`); + }); + }; + + return Graph; +}); diff --git a/src/controllers/admin/dashboard.js b/src/controllers/admin/dashboard.js index 0b8cb89d1c..ca62f07735 100644 --- a/src/controllers/admin/dashboard.js +++ b/src/controllers/admin/dashboard.js @@ -123,10 +123,17 @@ async function getStats() { getStatsForSet('topics:tid', 'topicCount'), ]); results[0].name = '[[admin/dashboard:unique-visitors]]'; + results[1].name = '[[admin/dashboard:logins]]'; + results[1].href = `${nconf.get('relative_path')}/admin/dashboard/logins`; + results[2].name = '[[admin/dashboard:new-users]]'; + results[2].href = `${nconf.get('relative_path')}/admin/dashboard/users`; + results[3].name = '[[admin/dashboard:posts]]'; + results[4].name = '[[admin/dashboard:topics]]'; + results[4].href = `${nconf.get('relative_path')}/admin/dashboard/topics`; ({ results } = await plugins.hooks.fire('filter:admin.getStats', { results, @@ -221,3 +228,66 @@ async function getLastRestart() { lastrestart.timestampISO = utils.toISOString(lastrestart.timestamp); return lastrestart; } + +dashboardController.getLogins = async (req, res) => { + let stats = await getStats(); + const dataset = await analytics.getHourlyStatsForSet('analytics:logins', Date.now(), 24); + stats = stats.filter(stat => stat.name === '[[admin/dashboard:logins]]').map(({ ...stat }) => { + delete stat.href; + return stat; + }); + const summary = { + day: stats[0].today, + week: stats[0].thisweek, + month: stats[0].thismonth, + }; + + res.render('admin/dashboard/logins', { + set: 'logins', + stats, + dataset, + summary, + }); +}; + +dashboardController.getUsers = async (req, res) => { + let stats = await getStats(); + const dataset = await analytics.getHourlyStatsForSet('analytics:registrations', Date.now(), 24); + stats = stats.filter(stat => stat.name === '[[admin/dashboard:new-users]]').map(({ ...stat }) => { + delete stat.href; + return stat; + }); + const summary = { + day: stats[0].today, + week: stats[0].thisweek, + month: stats[0].thismonth, + }; + + res.render('admin/dashboard/users', { + set: 'registrations', + stats, + dataset, + summary, + }); +}; + +dashboardController.getTopics = async (req, res) => { + let stats = await getStats(); + const dataset = await analytics.getHourlyStatsForSet('analytics:topics', Date.now(), 24); + stats = stats.filter(stat => stat.name === '[[admin/dashboard:topics]]').map(({ ...stat }) => { + delete stat.href; + return stat; + }); + const summary = { + day: stats[0].today, + week: stats[0].thisweek, + month: stats[0].thismonth, + }; + + res.render('admin/dashboard/topics', { + set: 'topics', + stats, + dataset, + summary, + }); +}; diff --git a/src/controllers/write/admin.js b/src/controllers/write/admin.js index 84929cc604..b667efcbad 100644 --- a/src/controllers/write/admin.js +++ b/src/controllers/write/admin.js @@ -1,7 +1,9 @@ 'use strict'; +const user = require('../../user'); const meta = require('../../meta'); const privileges = require('../../privileges'); +const analytics = require('../../analytics'); const helpers = require('../helpers'); @@ -17,3 +19,22 @@ Admin.updateSetting = async (req, res) => { await meta.configs.set(req.params.setting, req.body.value); helpers.formatApiResponse(200, res); }; + +Admin.getAnalytics = async (req, res) => { + const ok = await user.isAdministrator(req.uid); + + if (!ok) { + return helpers.formatApiResponse(403, res); + } + + // Default returns views from past 24 hours, by hour + if (!req.query.amount) { + if (req.query.units === 'days') { + req.query.amount = 30; + } else { + req.query.amount = 24; + } + } + const getStats = req.query.units === 'days' ? analytics.getDailyStatsForSet : analytics.getHourlyStatsForSet; + helpers.formatApiResponse(200, res, await getStats(`analytics:${req.params.set}`, parseInt(req.query.until, 10) || Date.now(), req.query.amount)); +}; diff --git a/src/routes/admin.js b/src/routes/admin.js index 02bd2e22d2..fb7a96e370 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -8,6 +8,9 @@ module.exports = function (app, name, middleware, controllers) { helpers.setupAdminPageRoute(app, `/${name}`, middleware, middlewares, controllers.admin.routeIndex); helpers.setupAdminPageRoute(app, `/${name}/dashboard`, middleware, middlewares, controllers.admin.dashboard.get); + helpers.setupAdminPageRoute(app, `/${name}/dashboard/logins`, middleware, middlewares, controllers.admin.dashboard.getLogins); + helpers.setupAdminPageRoute(app, `/${name}/dashboard/users`, middleware, middlewares, controllers.admin.dashboard.getUsers); + helpers.setupAdminPageRoute(app, `/${name}/dashboard/topics`, middleware, middlewares, controllers.admin.dashboard.getTopics); helpers.setupAdminPageRoute(app, `/${name}/manage/categories`, middleware, middlewares, controllers.admin.categories.getAll); helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id`, middleware, middlewares, controllers.admin.categories.get); diff --git a/src/routes/write/admin.js b/src/routes/write/admin.js index bfcfc5ca2b..d70acc6654 100644 --- a/src/routes/write/admin.js +++ b/src/routes/write/admin.js @@ -12,5 +12,7 @@ module.exports = function () { setupApiRoute(router, 'put', '/settings/:setting', [...middlewares, middleware.checkRequired.bind(null, ['value'])], controllers.write.admin.updateSetting); + setupApiRoute(router, 'get', '/analytics/:set', [...middlewares], controllers.write.admin.getAnalytics); + return router; }; diff --git a/src/user/create.js b/src/user/create.js index adb9f6e478..c94003af0c 100644 --- a/src/user/create.js +++ b/src/user/create.js @@ -9,6 +9,7 @@ const slugify = require('../slugify'); const plugins = require('../plugins'); const groups = require('../groups'); const meta = require('../meta'); +const analytics = require('../analytics'); module.exports = function (User) { User.create = async function (data) { @@ -108,6 +109,7 @@ module.exports = function (User) { await Promise.all([ db.incrObjectField('global', 'userCount'), + analytics.increment('registrations'), db.sortedSetAddBulk(bulkAdd), groups.join(groupsToJoin, userData.uid), User.notifications.sendWelcomeNotification(userData.uid), diff --git a/src/views/admin/dashboard.tpl b/src/views/admin/dashboard.tpl index 36180b2e2c..26d64231b5 100644 --- a/src/views/admin/dashboard.tpl +++ b/src/views/admin/dashboard.tpl @@ -1,80 +1,7 @@
-
-
- [[admin/dashboard:forum-traffic]] -
- - -
-
- -
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
[[admin/dashboard:stats.yesterday]][[admin/dashboard:stats.today]][[admin/dashboard:stats.last-week]][[admin/dashboard:stats.this-week]][[admin/dashboard:stats.last-month]][[admin/dashboard:stats.this-month]][[admin/dashboard:stats.all]]
{stats.name}{stats.yesterday}{stats.today}{stats.dayIncrease}%{stats.lastweek}{stats.thisweek}{stats.weekIncrease}%{stats.lastmonth}{stats.thismonth}{stats.monthIncrease}%{stats.alltime}
-
-
+ +
diff --git a/src/views/admin/dashboard/logins.tpl b/src/views/admin/dashboard/logins.tpl new file mode 100644 index 0000000000..64e7c1cc61 --- /dev/null +++ b/src/views/admin/dashboard/logins.tpl @@ -0,0 +1,6 @@ +
+
+ + +
+
\ No newline at end of file diff --git a/src/views/admin/dashboard/topics.tpl b/src/views/admin/dashboard/topics.tpl new file mode 100644 index 0000000000..64e7c1cc61 --- /dev/null +++ b/src/views/admin/dashboard/topics.tpl @@ -0,0 +1,6 @@ +
+
+ + +
+
\ No newline at end of file diff --git a/src/views/admin/dashboard/users.tpl b/src/views/admin/dashboard/users.tpl new file mode 100644 index 0000000000..64e7c1cc61 --- /dev/null +++ b/src/views/admin/dashboard/users.tpl @@ -0,0 +1,6 @@ +
+
+ + +
+
\ No newline at end of file diff --git a/src/views/admin/partials/dashboard/graph.tpl b/src/views/admin/partials/dashboard/graph.tpl new file mode 100644 index 0000000000..87595a3581 --- /dev/null +++ b/src/views/admin/partials/dashboard/graph.tpl @@ -0,0 +1,33 @@ +
+
+ [[admin/dashboard:forum-traffic]] +
+ + +
+
+
+
+ +
+
+
+ +
+
{{{ if summary.week }}}{./summary.week}{{{ else }}}0{{{ end }}}
+ +
+ + +
+
+
\ No newline at end of file diff --git a/src/views/admin/partials/dashboard/stats.tpl b/src/views/admin/partials/dashboard/stats.tpl new file mode 100644 index 0000000000..a77b01d355 --- /dev/null +++ b/src/views/admin/partials/dashboard/stats.tpl @@ -0,0 +1,49 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
[[admin/dashboard:stats.yesterday]][[admin/dashboard:stats.today]][[admin/dashboard:stats.last-week]][[admin/dashboard:stats.this-week]][[admin/dashboard:stats.last-month]][[admin/dashboard:stats.this-month]][[admin/dashboard:stats.all]]
+ + {{{ if ../href }}} + {../name} + {{{ else }}} + {../name} + {{{ end }}} + + {stats.yesterday}{stats.today}{stats.dayIncrease}%{stats.lastweek}{stats.thisweek}{stats.weekIncrease}%{stats.lastmonth}{stats.thismonth}{stats.monthIncrease}%{stats.alltime}
+
+
\ No newline at end of file