diff --git a/app/controllers/dashboard.js b/app/controllers/dashboard.js index 5a894ea..6054aef 100644 --- a/app/controllers/dashboard.js +++ b/app/controllers/dashboard.js @@ -8,7 +8,7 @@ const DTP_COMPONENT_NAME = 'dashboard'; const express = require('express'); -const { SiteController } = require('../../lib/site-lib'); +const { SiteController, SiteError } = require('../../lib/site-lib'); class DashboardController extends SiteController { @@ -20,7 +20,7 @@ class DashboardController extends SiteController { const { dtp } = this; const { limiter: limiterService, session: sessionService } = dtp.services; - const authRequired = sessionService.authCheckMiddleware({ requireLogin: true }); + const authRequired = sessionService.authCheckMiddleware({ requireLogin: true, requireAdmin: true }); const router = express.Router(); dtp.app.use('/dashboard', router); @@ -30,6 +30,14 @@ class DashboardController extends SiteController { return next(); }); + router.param('linkId', this.populateLinkId.bind(this)); + + router.get('/link/:linkId', + limiterService.create(limiterService.config.dashboard.getLinkView), + authRequired, + this.getLinkView.bind(this), + ); + router.get('/', limiterService.create(limiterService.config.dashboard.getDashboardView), authRequired, @@ -37,13 +45,45 @@ class DashboardController extends SiteController { ); } + async populateLinkId (req, res, next, linkId) { + const { link: linkService } = this.dtp.services; + try { + res.locals.link = await linkService.getById(linkId); + if (!res.locals.link) { + return next(new SiteError(404, 'Link not found')); + } + return next(); + } catch (error) { + this.log.error('failed to populate linkId', { linkId, error }); + return next(error); + } + } + + async getLinkView (req, res, next) { + const { dashboard: dashboardService, link: linkService } = this.dtp.services; + try { + res.locals.linkVisitStats = await dashboardService.getLinkVisitStats(res.locals.link); + res.locals.linkCountryStats = await dashboardService.getLinkCountryStats(res.locals.link); + res.locals.linkCityStats = await dashboardService.getLinkCityStats(res.locals.link); + + res.locals.userLinks = await linkService.getForUser(req.user); + + res.render('dashboard/link-analyzer'); + } catch (error) { + this.log.error('failed to display dashboard', { userId: req.user._id, error }); + return next(error); + } + } + async getDashboardView (req, res, next) { const { dashboard: dashboardService, link: linkService } = this.dtp.services; try { res.locals.userVisitStats = await dashboardService.getUserVisitStats(req.user); res.locals.userCountryStats = await dashboardService.getUserCountryStats(req.user); res.locals.userCityStats = await dashboardService.getUserCityStats(req.user); + res.locals.userLinks = await linkService.getForUser(req.user); + res.render('dashboard/view'); } catch (error) { this.log.error('failed to display dashboard', { userId: req.user._id, error }); diff --git a/app/services/dashboard.js b/app/services/dashboard.js index 64ed80f..0879616 100644 --- a/app/services/dashboard.js +++ b/app/services/dashboard.js @@ -12,6 +12,7 @@ const LinkVisit = mongoose.model('LinkVisit'); const moment = require('moment'); const { SiteService } = require('../../lib/site-lib'); +const { link } = require('../../config/limiter'); class DashboardService extends SiteService { @@ -21,6 +22,11 @@ class DashboardService extends SiteService { super(dtp, module.exports); } + /* + * + * USER VISIT STATS + * + */ async getUserVisitStats (user) { const { cache: cacheService } = this.dtp.services; let stats; @@ -33,7 +39,7 @@ class DashboardService extends SiteService { } } - this.log.info('getUserStats', 'generating user visit stats report', { userId: user._id }); + this.log.info('getUserVisitStats', 'generating user visit stats report', { userId: user._id }); const END_DATE = new Date(); const START_DATE = moment(END_DATE).subtract(7, 'day').toDate(); @@ -81,10 +87,74 @@ class DashboardService extends SiteService { }, ]); - stats = await Link.populate(stats, [ + const response = { + start: START_DATE, + end: END_DATE, + stats, + }; + await cacheService.setObjectEx(cacheKey, 60 * 5, response); + return response; + } + + /* + * + * LINK VISIT STATS + * + */ + async getLinkVisitStats (link) { + const { cache: cacheService } = this.dtp.services; + let stats; + + const cacheKey = `stats:link:${link._id}:visit`; + if (DashboardService.CACHE_ENABLED) { + stats = await cacheService.getObject(cacheKey); + if (stats) { + return stats; + } + } + + this.log.info('getLinkVisitStats', 'generating link visit stats report', { linkId: link._id }); + + const END_DATE = new Date(); + const START_DATE = moment(END_DATE).subtract(7, 'day').toDate(); + + stats = await LinkVisit.aggregate([ + { + $match: { + link: link._id, + $and: [ + { created: { $gt: START_DATE } }, + { created: { $lt: END_DATE } }, + ], + }, + }, + { + $group: { + _id: { + year: { $year: '$created' }, + month: { $month: '$created' }, + day: { $dayOfMonth: '$created' }, + hour: { $hour: '$created' }, + }, + count: { $sum: 1 }, + }, + }, { - path: 'link', - model: 'Link', + $project: { + _id: false, + date: { + $dateFromParts: { + year: '$_id.year', + month: '$_id.month', + day: '$_id.day', + hour: '$_id.hour', + }, + }, + count: '$count', + }, + }, + { + $sort: { date: 1 }, }, ]); @@ -97,6 +167,11 @@ class DashboardService extends SiteService { return response; } + /* + * + * USER COUNTRY STATS + * + */ async getUserCountryStats (user) { const { cache: cacheService } = this.dtp.services; let stats; @@ -150,10 +225,67 @@ class DashboardService extends SiteService { }, ]); - stats = await Link.populate(stats, [ + const response = { + start: START_DATE, + end: END_DATE, + stats, + }; + await cacheService.setObjectEx(cacheKey, 60 * 5, response); + return response; + } + + /* + * + * LINK COUNTRY STATS + * + */ + async getLinkCountryStats (link) { + const { cache: cacheService } = this.dtp.services; + let stats; + + const cacheKey = `stats:link:${link._id}:country`; + if (DashboardService.CACHE_ENABLED) { + stats = await cacheService.getObject(cacheKey); + if (stats) { + return stats; + } + } + + this.log.info('getLinkCountryStats', 'generating link country stats report', { linkId: link._id }); + + const END_DATE = new Date(); + const START_DATE = moment(END_DATE).subtract(7, 'day').toDate(); + + stats = await LinkVisit.aggregate([ { - path: 'link', - model: 'Link', + $match: { + link: link._id, + $and: [ + { created: { $gt: START_DATE } }, + { created: { $lt: END_DATE } }, + ], + }, + }, + { + $group: { + _id: { + country: '$geoip.country', + }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: false, + country: '$_id.country', + count: '$count', + }, + }, + { + $sort: { count: -1, country: 1 }, + }, + { + $limit: 10, }, ]); @@ -166,6 +298,11 @@ class DashboardService extends SiteService { return response; } + /* + * + * USER CITY STATS + * + */ async getUserCityStats (user) { const { cache: cacheService } = this.dtp.services; let stats; @@ -223,10 +360,71 @@ class DashboardService extends SiteService { }, ]); - stats = await Link.populate(stats, [ + const response = { + start: START_DATE, + end: END_DATE, + stats, + }; + await cacheService.setObjectEx(cacheKey, 60 * 5, response); + return response; + } + + /* + * + * LINK CITY STATS + * + */ + async getLinkCityStats (link) { + const { cache: cacheService } = this.dtp.services; + let stats; + + const cacheKey = `stats:link:${link._id}:city`; + if (DashboardService.CACHE_ENABLED) { + stats = await cacheService.getObject(cacheKey); + if (stats) { + return stats; + } + } + + this.log.info('getLinkCityStats', 'generating link city stats report', { linkId: link._id }); + + const END_DATE = new Date(); + const START_DATE = moment(END_DATE).subtract(7, 'day').toDate(); + + stats = await LinkVisit.aggregate([ { - path: 'link', - model: 'Link', + $match: { + link: link._id, + $and: [ + { created: { $gt: START_DATE } }, + { created: { $lt: END_DATE } }, + ], + }, + }, + { + $group: { + _id: { + country: '$geoip.country', + region: '$geoip.region', + city: '$geoip.city', + }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: false, + country: '$_id.country', + region: '$_id.region', + city: '$_id.city', + count: '$count', + }, + }, + { + $sort: { count: -1, city: 1, region: 1, country: 1 }, + }, + { + $limit: 10, }, ]); diff --git a/app/views/dashboard/link-analyzer.pug b/app/views/dashboard/link-analyzer.pug new file mode 100644 index 0000000..6298153 --- /dev/null +++ b/app/views/dashboard/link-analyzer.pug @@ -0,0 +1,67 @@ +extends ../layouts/main +block content + + section.uk-section.uk-section-default.links-dashboard + .uk-container.uk-container-expand + + .uk-margin + .uk-card.uk-card-secondary.uk-card-small.uk-card-body + div(uk-grid).uk-grid-small + .uk-width-expand + h4.uk-card-title #{link.label} #[small.uk-text-muted past 7 days] + .uk-width-auto + a(href="/dashboard").uk-button.dtp-button-secondary.uk-button-small + +renderButtonIcon('fa-back', 'Back to Dashboard') + + canvas(id="link-visits").visit-graph + + div(uk-grid).uk-flex-between.uk-text-small + .uk-width-auto + .uk-margin + div= moment(linkVisitStats.start).format('MMM DD, YYYY h:mm:ss a') + .uk-width-auto + .uk-margin + div= moment(linkVisitStats.end).format('MMM DD, YYYY h:mm:ss a') + + .uk-margin + div(uk-grid).uk-grid-small + div(class="uk-width-1-1 uk-width-1-2@m") + .uk-margin + .uk-card.uk-card-secondary.uk-card-small.uk-card-body + h5.uk-card-title Top Countries + table.uk-table.uk-table-small + thead + tr + th Country + th Visits + tbody + each entry in linkCountryStats.stats + tr + td.uk-table-expand= entry.country ? entry.country : '--' + td= entry.count + div(class="uk-width-1-1 uk-width-1-2@m") + .uk-margin + .uk-card.uk-card-secondary.uk-card-small.uk-card-body + h5.uk-card-title Top Cities + table.uk-table.uk-table-small + thead + tr + th City + th(class="uk-visible@m") State + th Country + th Visits + tbody + each entry in linkCityStats.stats + tr + td= entry.city ? entry.city : '--' + td(class="uk-visible@m")= entry.region ? entry.region : '--' + td= entry.country ? entry.country : '--' + td= entry.count + +block viewjs + script(src="/chart.js/chart.min.js") + script(src="/chartjs-adapter-moment/chartjs-adapter-moment.min.js") + script. + window.addEventListener('dtp-load', ( ) => { + dtp.app.renderProfileStats('canvas#link-visits', !{JSON.stringify(linkVisitStats.stats)}); + }); \ No newline at end of file diff --git a/app/views/dashboard/view.pug b/app/views/dashboard/view.pug index 548f75e..b9f53ca 100644 --- a/app/views/dashboard/view.pug +++ b/app/views/dashboard/view.pug @@ -1,7 +1,7 @@ extends ../layouts/main block content - section.uk-section.uk-section-default + section.uk-section.uk-section-default.links-dashboard .uk-container.uk-container-expand .uk-margin @@ -17,16 +17,14 @@ block content .uk-margin .uk-card.uk-card-secondary.uk-card-small.uk-card-body h4.uk-card-title Link Visits #[small.uk-text-muted past 7 days] - div(uk-grid).uk-grid-small - div(class="uk-width-1-1 uk-width-expand@m") - canvas(id="profile-stats", width="960", height="240") - div(class="uk-width-1-1 uk-width-auto@m") + canvas(id="profile-stats").visit-graph + div(uk-grid).uk-flex-between.uk-text-small + .uk-width-auto .uk-margin - .uk-text-small start - .uk-text-bold= moment(userVisitStats.start).format('MMM DD, YYYY h:mm:ss a') + div= moment(userVisitStats.start).format('MMM DD, YYYY h:mm:ss a') + .uk-width-auto .uk-margin - .uk-text-small end - .uk-text-bold= moment(userVisitStats.end).format('MMM DD, YYYY h:mm:ss a') + div= moment(userVisitStats.end).format('MMM DD, YYYY h:mm:ss a') .uk-margin .uk-card.uk-card-secondary.uk-card-small @@ -37,13 +35,13 @@ block content each link in userLinks li div(uk-grid).uk-grid-small.uk-flex-middle - .uk-width-2-3 + div(class="uk-width-3-5 uk-width-2-3@m") .uk-text-lead a(href=`/dashboard/link/${link._id}`).uk-text-truncate= link.label - .uk-width-1-6 + div(class="uk-width-1-5 uk-width-1-6@m") .uk-text-small.uk-text-muted unique div= formatCount(link.stats.uniqueVisitCount) - .uk-width-1-6 + div(class="uk-width-1-5 uk-width-1-6@m") .uk-text-small.uk-text-muted total div= formatCount(link.stats.totalVisitCount) diff --git a/app/views/index-logged-in.pug b/app/views/index-logged-in.pug index 7f2507d..63a8d95 100644 --- a/app/views/index-logged-in.pug +++ b/app/views/index-logged-in.pug @@ -10,9 +10,10 @@ block content div(uk-grid).uk-grid-small .uk-width-expand h3.uk-heading-bullet.uk-margin-small Your links - .uk-width-auto - a(href='/dashboard').uk-button.dtp-button-default.uk-button-small - +renderButtonIcon('fa-tachometer-alt', 'Dashboard') + if user && user.flags.isAdmin + .uk-width-auto + a(href='/dashboard').uk-button.dtp-button-default.uk-button-small + +renderButtonIcon('fa-tachometer-alt', 'Dashboard') .uk-width-auto button(type="button", uk-toggle={ target: '#link-editor' }).uk-button.dtp-button-primary.uk-button-small +renderButtonIcon('fa-plus', 'Add Link') diff --git a/client/js/site-app.js b/client/js/site-app.js index 0f6a5bb..48ca4c5 100644 --- a/client/js/site-app.js +++ b/client/js/site-app.js @@ -626,6 +626,21 @@ export default class DtpSiteApp extends DtpApp { position: 'bottom', }, }, + maintainAspectRatio: true, + aspectRatio: 16.0 / 9.0, + onResize: (chart, event) => { + if (event.width >= 960) { + chart.config.options.aspectRatio = 16.0 / 5.0; + } + else if (event.width >= 640) { + chart.config.options.aspectRatio = 16.0 / 9.0; + } else if (event.width >= 480) { + chart.config.options.aspectRatio = 16.0 / 12.0; + } else { + chart.config.options.aspectRatio = 16.0 / 16.0; + } + this.log.debug('renderProfileStats', 'chart resizing', { aspect: chart.config.options.aspectRatio }); + }, }, }); } catch (error) { diff --git a/client/less/site/dashboard.less b/client/less/site/dashboard.less index dd75332..e7866e2 100644 --- a/client/less/site/dashboard.less +++ b/client/less/site/dashboard.less @@ -1,81 +1,7 @@ -.dtp-dashboard-cluster { +.links-dashboard { - fieldset { - border-color: #9e9e9e; - color: #c8c8c8; - - legend { - font-family: 'Courier New', Courier, monospace; - font-size: 11px; - color: #9e9e9e; - } - } - - .dtp-cpu-graph { - background: none; - - &.cpu-overload { - background: #4e0000; - } - } - - .dtp-stat-cell { - line-height: 1; - } - - .dtp-stats-bar { - width: 100%; - height: 16px; - margin: 0; - padding: 0; - - .dtp-cpu-stat-bar { - display: inline-block; - height: 16px; - text-align: center; - font-size: 10px; - overflow: hidden; - - &.dtp-cpu-user { - background-color: #008000; - } - &.dtp-cpu-nice { - background-color: #808080; - } - &.dtp-cpu-sys { - background-color: #808000; - } - &.dtp-cpu-idle { - background-color: #484848; - } - &.dtp-cpu-irq { - background-color: #800000; - } - } - - .dtp-mem-stat-bar { - display: inline-block; - height: 16px; - text-align: center; - font-size: 10px; - overflow: hidden; - - &.dtp-mem-used { - background-color: #008000; - } - &.dtp-mem-available { - background-color: #404040; - } - - &.dtp-mem-cached { - background-color: #008000; - } - &.dtp-mem-buffers { - background-color: #004000; - } - &.dtp-mem-slab { - background-color: #808000; - } - } + canvas.visit-graph { + width: 960px; + height: 360px; } } \ No newline at end of file diff --git a/config/limiter.js b/config/limiter.js index 4ba7b60..b9dde9f 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -50,10 +50,15 @@ module.exports = { * DashboardController */ dashboard: { + getLinkView: { + total: 20, + expire: ONE_MINUTE, + message: 'You are loading the link analyzer too quickly', + }, getDashboardView: { - total: 15, + total: 20, expire: ONE_MINUTE, - message: 'You are loading the publisher dashboard too quickly', + message: 'You are loading the analytics dashboard too quickly', }, },