Add stats to the admin dashboard

This commit is contained in:
Neil Lalonde 2013-03-07 11:07:59 -05:00
parent 24db302e70
commit 8927432a93
16 changed files with 301 additions and 16 deletions

View File

@ -0,0 +1,38 @@
/**
Get the y value of a report data point by its index. Negative indexes start from the end.
@method reportValueY
@for Handlebars
**/
Handlebars.registerHelper('valueAtDaysAgo', function(property, i) {
var data = Ember.Handlebars.get(this, property);
if( data ) {
var wantedDate = Date.create(i + ' days ago').format('{yyyy}-{MM}-{dd}');
var item = data.find( function(d, i, arr) { return d.x == wantedDate; } );
if( item ) {
return item.y;
} else {
return 0;
}
}
});
/**
Sum the given number of data points from the report, starting at the most recent.
@method sumLast
@for Handlebars
**/
Handlebars.registerHelper('sumLast', function(property, numDays) {
var data = Ember.Handlebars.get(this, property);
if( data ) {
var earliestDate = Date.create(numDays + ' days ago');
var sum = 0;
data.each(function(d){
if(Date.create(d.x) >= earliestDate) {
sum += d.y;
}
});
return sum;
}
});

View File

@ -2,7 +2,7 @@ Discourse.Report = Discourse.Model.extend({});
Discourse.Report.reopenClass({
find: function(type) {
var model = Discourse.Report.create();
var model = Discourse.Report.create({type: type});
$.ajax("/admin/reports/" + type, {
type: 'GET',
success: function(json) {

View File

@ -11,6 +11,9 @@ Discourse.AdminDashboardRoute = Discourse.Route.extend({
if( !c.get('versionCheckedAt') || Date.create('12 hours ago') > c.get('versionCheckedAt') ) {
this.checkVersion(c);
}
if( !c.get('reportsCheckedAt') || Date.create('1 hour ago') > c.get('reportsCheckedAt') ) {
this.fetchReports(c);
}
},
renderTemplate: function() {
@ -19,12 +22,20 @@ Discourse.AdminDashboardRoute = Discourse.Route.extend({
checkVersion: function(c) {
if( Discourse.SiteSettings.version_checks ) {
c.set('versionCheckedAt', new Date());
Discourse.VersionCheck.find().then(function(vc) {
c.set('versionCheck', vc);
c.set('versionCheckedAt', new Date());
c.set('loading', false);
});
}
},
fetchReports: function(c) {
// TODO: use one request to get all reports, or maybe one request for all dashboard data including version check.
c.set('reportsCheckedAt', new Date());
['visits', 'signups', 'topics', 'posts'].each(function(reportType){
c.set(reportType, Discourse.Report.find(reportType));
});
}
});

View File

@ -41,4 +41,22 @@
</div>
<div class='clearfix'></div>
{{/if}}
{{/if}}
<div class="dashboard-stats">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>&nbsp;</th>
<th>{{i18n admin.dashboard.reports.today}}</th>
<th>{{i18n admin.dashboard.reports.yesterday}}</th>
<th>{{i18n admin.dashboard.reports.last_7_days}}</th>
<th>{{i18n admin.dashboard.reports.last_30_days}}</th>
</tr>
</thead>
{{ render 'admin_signups' signups }}
{{ render 'admin_visits' visits }}
{{ render 'admin_topics' topics }}
{{ render 'admin_posts' posts }}
</table>
</div>

View File

@ -0,0 +1,9 @@
{{#if loaded}}
<tr>
<td class="title">{{title}}</td>
<td class="value">{{valueAtDaysAgo data 0}}</td>
<td class="value">{{valueAtDaysAgo data 1}}</td>
<td class="value">{{sumLast data 7}}</td>
<td class="value">{{sumLast data 30}}</td>
</tr>
{{/if}}

View File

@ -0,0 +1,9 @@
/**
These views are needed so we can render the same template multiple times on
the admin dashboard.
**/
var opts = { templateName: 'admin/templates/report', tagName: 'tbody' };
Discourse.AdminSignupsView = Discourse.View.extend(opts);
Discourse.AdminVisitsView = Discourse.View.extend(opts);
Discourse.AdminTopicsView = Discourse.View.extend(opts);
Discourse.AdminPostsView = Discourse.View.extend(opts);

View File

@ -267,6 +267,4 @@ Handlebars.registerHelper('personalizedName', function(property, options) {
return name;
}
return Em.String.i18n('you');
});
});

View File

@ -301,3 +301,21 @@ table {
.flaggers { padding: 0 10px; }
.last-flagged { padding: 0 10px; }
}
.dashboard-stats {
margin-top: 10px;
table {
width: 450px;
th {
font-weight: normal;
text-align: center;
}
td.value {
font-weight: bold;
text-align: center;
}
}
}

View File

@ -450,4 +450,8 @@ class Post < ActiveRecord::Base
args[:invalidate_oneboxes] = true if invalidate_oneboxes.present?
Jobs.enqueue(:process_post, args)
end
def self.count_per_day(since=30.days.ago)
where('created_at > ?', since).group('date(created_at)').order('date(created_at)').count
end
end

View File

@ -1,10 +1,15 @@
class Report
attr_accessor :type, :data
attr_accessor :type, :data, :cache
def self.cache_expiry
3600 # In seconds
end
def initialize(type)
@type = type
@data = nil
@cache = true
end
def as_json
@ -17,21 +22,74 @@ class Report
}
end
def self.find(type)
def self.find(type, opts={})
report_method = :"report_#{type}"
return nil unless respond_to?(report_method)
# Load the report
report = Report.new(type)
report.cache = false if opts[:cache] == false
send(report_method, report)
report
end
def self.report_visits(report)
report.data = []
UserVisit.by_day.each do |date, count|
report.data << {x: date, y: count}
fetch report do
UserVisit.by_day(30.days.ago).each do |date, count|
report.data << {x: date, y: count}
end
end
end
def self.report_signups(report)
report.data = []
fetch report do
User.count_by_signup_date(30.days.ago).each do |date, count|
report.data << {x: date, y: count}
end
end
end
def self.report_topics(report)
report.data = []
fetch report do
Topic.count_per_day(30.days.ago).each do |date, count|
report.data << {x: date, y: count}
end
end
end
def self.report_posts(report)
report.data = []
fetch report do
Post.count_per_day(30.days.ago).each do |date, count|
report.data << {x: date, y: count}
end
end
end
private
def self.fetch(report)
unless report.cache and $redis
yield
return
end
data_set = "#{report.type}:data"
if $redis.exists(data_set)
$redis.get(data_set).split('|').each do |pair|
date, count = pair.split(',')
report.data << {x: date, y: count.to_i}
end
else
yield
$redis.setex data_set, cache_expiry, report.data.map { |item| "#{item[:x]},#{item[:y]}" }.join('|')
end
rescue Redis::BaseConnectionError
yield
end
end

View File

@ -183,6 +183,10 @@ class Topic < ActiveRecord::Base
where("created_at > ?", time_ago)
end
def self.count_per_day(since=30.days.ago)
where('created_at > ?', since).group('date(created_at)').order('date(created_at)').count
end
def private_message?
self.archetype == Archetype.private_message
end

View File

@ -458,6 +458,10 @@ class User < ActiveRecord::Base
Summarize.new(bio_cooked).summary
end
def self.count_by_signup_date(since=30.days.ago)
where('created_at > ?', since).group('date(created_at)').order('date(created_at)').count
end
protected
def cook

View File

@ -2,7 +2,7 @@ class UserVisit < ActiveRecord::Base
attr_accessible :visited_at, :user_id
# A list of visits in the last month by day
def self.by_day
where("visited_at > ?", 1.month.ago).group(:visited_at).order(:visited_at).count
def self.by_day(since=30.days.ago)
where("visited_at > ?", since).group(:visited_at).order(:visited_at).count
end
end

View File

@ -673,6 +673,13 @@ en:
latest_version: "Latest version"
update_often: 'Please update often!'
reports:
today: "Today"
yesterday: "Yesterday"
last_7_days: "Last 7 Days"
last_30_days: "Last 30 Days"
all_time: "All Time"
flags:
title: "Flags"
old: "Old"

View File

@ -244,9 +244,13 @@ en:
reports:
visits:
title: "Users Visits by Day"
xaxis: "Day"
yaxis: "Visits"
title: "Users Visits"
signups:
title: "New Users"
topics:
title: "New Topics"
posts:
title: "New Posts"
site_settings:
default_locale: "The default language of this Discourse instance (ISO 639-1 Code)"

View File

@ -5,7 +5,7 @@ describe Report do
describe 'visits report' do
let(:report) { Report.find('visits') }
let(:report) { Report.find('visits', cache: false) }
context "no visits" do
it "returns an empty report" do
@ -30,5 +30,108 @@ describe Report do
end
[:signup, :topic, :post].each do |arg|
describe "#{arg} report" do
pluralized = arg.to_s.pluralize
let(:report) { Report.find(pluralized, cache: false) }
context "no #{pluralized}" do
it 'returns an empty report' do
report.data.should be_blank
end
end
context "with #{pluralized}" do
before do
fabricator = (arg == :signup ? :user : arg)
Fabricate(fabricator, created_at: 2.days.ago)
Fabricate(fabricator, created_at: 1.day.ago)
Fabricate(fabricator, created_at: 1.day.ago)
end
it 'returns correct data' do
report.data[0][:y].should == 1
report.data[1][:y].should == 2
end
end
end
end
describe '#fetch' do
context 'signups' do
let(:report) { Report.find('signups', cache: true) }
context 'no data' do
context 'cache miss' do
before do
$redis.expects(:exists).with('signups:data').returns(false)
end
it 'should cache an empty data set' do
$redis.expects(:setex).with('signups:data', Report.cache_expiry, "")
report.data.should be_blank
end
end
context 'cache hit' do
before do
$redis.expects(:exists).with('signups:data').returns(true)
end
it 'returns the cached empty report' do
User.expects(:count_by_signup_date).never
$redis.expects(:setex).never
$redis.expects(:get).with('signups:data').returns('')
report.data.should be_blank
end
end
end
context 'with data' do
before do
Fabricate(:user, created_at: 2.days.ago)
Fabricate(:user, created_at: 1.day.ago)
Fabricate(:user, created_at: 1.day.ago)
end
context 'cache miss' do
before do
$redis.expects(:exists).with('signups:data').returns(false)
end
it 'should cache the data set' do
$redis.expects(:setex).with do |key, expiry, string|
key == 'signups:data' and
expiry == Report.cache_expiry and
string.include? "#{2.days.ago.to_date.to_s},1" and
string.include? "#{1.day.ago.to_date.to_s},2"
end
report()
end
it 'should return correct data' do
report.data[0][:y].should == 1
report.data[1][:y].should == 2
end
end
context 'cache hit' do
before do
$redis.expects(:exists).with('signups:data').returns(true)
end
it 'returns the cached data' do
User.expects(:count_by_signup_date).never
$redis.expects(:setex).never
$redis.expects(:get).with('signups:data').returns("#{2.days.ago.to_date.to_s},1|#{1.day.ago.to_date.to_s},2")
report.data[0][:y].should == 1
report.data[1][:y].should == 2
end
end
end
end
end
end