Browse Source

Dashboard link analytics

pull/1/head
Rob Colbert 3 years ago
parent
commit
d3e2b5609d
  1. 44
      app/controllers/dashboard.js
  2. 218
      app/services/dashboard.js
  3. 67
      app/views/dashboard/link-analyzer.pug
  4. 22
      app/views/dashboard/view.pug
  5. 1
      app/views/index-logged-in.pug
  6. 15
      client/js/site-app.js
  7. 82
      client/less/site/dashboard.less
  8. 9
      config/limiter.js

44
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 });

218
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,
},
]);

67
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)});
});

22
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)

1
app/views/index-logged-in.pug

@ -10,6 +10,7 @@ block content
div(uk-grid).uk-grid-small
.uk-width-expand
h3.uk-heading-bullet.uk-margin-small Your links
if user && user.flags.isAdmin
.uk-width-auto
a(href='/dashboard').uk-button.dtp-button-default.uk-button-small
+renderButtonIcon('fa-tachometer-alt', 'Dashboard')

15
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) {

82
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;
}
}

9
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',
},
},

Loading…
Cancel
Save