mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
Add stats to the admin dashboard
This commit is contained in:
38
app/assets/javascripts/admin/helpers/report_helpers.js
Normal file
38
app/assets/javascripts/admin/helpers/report_helpers.js
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ Discourse.Report = Discourse.Model.extend({});
|
|||||||
|
|
||||||
Discourse.Report.reopenClass({
|
Discourse.Report.reopenClass({
|
||||||
find: function(type) {
|
find: function(type) {
|
||||||
var model = Discourse.Report.create();
|
var model = Discourse.Report.create({type: type});
|
||||||
$.ajax("/admin/reports/" + type, {
|
$.ajax("/admin/reports/" + type, {
|
||||||
type: 'GET',
|
type: 'GET',
|
||||||
success: function(json) {
|
success: function(json) {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ Discourse.AdminDashboardRoute = Discourse.Route.extend({
|
|||||||
if( !c.get('versionCheckedAt') || Date.create('12 hours ago') > c.get('versionCheckedAt') ) {
|
if( !c.get('versionCheckedAt') || Date.create('12 hours ago') > c.get('versionCheckedAt') ) {
|
||||||
this.checkVersion(c);
|
this.checkVersion(c);
|
||||||
}
|
}
|
||||||
|
if( !c.get('reportsCheckedAt') || Date.create('1 hour ago') > c.get('reportsCheckedAt') ) {
|
||||||
|
this.fetchReports(c);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
renderTemplate: function() {
|
renderTemplate: function() {
|
||||||
@@ -19,12 +22,20 @@ Discourse.AdminDashboardRoute = Discourse.Route.extend({
|
|||||||
|
|
||||||
checkVersion: function(c) {
|
checkVersion: function(c) {
|
||||||
if( Discourse.SiteSettings.version_checks ) {
|
if( Discourse.SiteSettings.version_checks ) {
|
||||||
|
c.set('versionCheckedAt', new Date());
|
||||||
Discourse.VersionCheck.find().then(function(vc) {
|
Discourse.VersionCheck.find().then(function(vc) {
|
||||||
c.set('versionCheck', vc);
|
c.set('versionCheck', vc);
|
||||||
c.set('versionCheckedAt', new Date());
|
|
||||||
c.set('loading', false);
|
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));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class='clearfix'></div>
|
<div class='clearfix'></div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="dashboard-stats">
|
||||||
|
<table class="table table-condensed table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th> </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>
|
||||||
@@ -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}}
|
||||||
9
app/assets/javascripts/admin/views/reports_views.js
Normal file
9
app/assets/javascripts/admin/views/reports_views.js
Normal 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);
|
||||||
@@ -267,6 +267,4 @@ Handlebars.registerHelper('personalizedName', function(property, options) {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
return Em.String.i18n('you');
|
return Em.String.i18n('you');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -301,3 +301,21 @@ table {
|
|||||||
.flaggers { padding: 0 10px; }
|
.flaggers { padding: 0 10px; }
|
||||||
.last-flagged { 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -450,4 +450,8 @@ class Post < ActiveRecord::Base
|
|||||||
args[:invalidate_oneboxes] = true if invalidate_oneboxes.present?
|
args[:invalidate_oneboxes] = true if invalidate_oneboxes.present?
|
||||||
Jobs.enqueue(:process_post, args)
|
Jobs.enqueue(:process_post, args)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
class Report
|
class Report
|
||||||
|
|
||||||
attr_accessor :type, :data
|
attr_accessor :type, :data, :cache
|
||||||
|
|
||||||
|
def self.cache_expiry
|
||||||
|
3600 # In seconds
|
||||||
|
end
|
||||||
|
|
||||||
def initialize(type)
|
def initialize(type)
|
||||||
@type = type
|
@type = type
|
||||||
@data = nil
|
@data = nil
|
||||||
|
@cache = true
|
||||||
end
|
end
|
||||||
|
|
||||||
def as_json
|
def as_json
|
||||||
@@ -17,21 +22,74 @@ class Report
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.find(type)
|
def self.find(type, opts={})
|
||||||
report_method = :"report_#{type}"
|
report_method = :"report_#{type}"
|
||||||
return nil unless respond_to?(report_method)
|
return nil unless respond_to?(report_method)
|
||||||
|
|
||||||
# Load the report
|
# Load the report
|
||||||
report = Report.new(type)
|
report = Report.new(type)
|
||||||
|
report.cache = false if opts[:cache] == false
|
||||||
send(report_method, report)
|
send(report_method, report)
|
||||||
report
|
report
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.report_visits(report)
|
def self.report_visits(report)
|
||||||
report.data = []
|
report.data = []
|
||||||
UserVisit.by_day.each do |date, count|
|
fetch report do
|
||||||
report.data << {x: date, y: count}
|
UserVisit.by_day(30.days.ago).each do |date, count|
|
||||||
|
report.data << {x: date, y: count}
|
||||||
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -183,6 +183,10 @@ class Topic < ActiveRecord::Base
|
|||||||
where("created_at > ?", time_ago)
|
where("created_at > ?", time_ago)
|
||||||
end
|
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?
|
def private_message?
|
||||||
self.archetype == Archetype.private_message
|
self.archetype == Archetype.private_message
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -458,6 +458,10 @@ class User < ActiveRecord::Base
|
|||||||
Summarize.new(bio_cooked).summary
|
Summarize.new(bio_cooked).summary
|
||||||
end
|
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
|
protected
|
||||||
|
|
||||||
def cook
|
def cook
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ class UserVisit < ActiveRecord::Base
|
|||||||
attr_accessible :visited_at, :user_id
|
attr_accessible :visited_at, :user_id
|
||||||
|
|
||||||
# A list of visits in the last month by day
|
# A list of visits in the last month by day
|
||||||
def self.by_day
|
def self.by_day(since=30.days.ago)
|
||||||
where("visited_at > ?", 1.month.ago).group(:visited_at).order(:visited_at).count
|
where("visited_at > ?", since).group(:visited_at).order(:visited_at).count
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -673,6 +673,13 @@ en:
|
|||||||
latest_version: "Latest version"
|
latest_version: "Latest version"
|
||||||
update_often: 'Please update often!'
|
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:
|
flags:
|
||||||
title: "Flags"
|
title: "Flags"
|
||||||
old: "Old"
|
old: "Old"
|
||||||
|
|||||||
@@ -244,9 +244,13 @@ en:
|
|||||||
|
|
||||||
reports:
|
reports:
|
||||||
visits:
|
visits:
|
||||||
title: "Users Visits by Day"
|
title: "Users Visits"
|
||||||
xaxis: "Day"
|
signups:
|
||||||
yaxis: "Visits"
|
title: "New Users"
|
||||||
|
topics:
|
||||||
|
title: "New Topics"
|
||||||
|
posts:
|
||||||
|
title: "New Posts"
|
||||||
|
|
||||||
site_settings:
|
site_settings:
|
||||||
default_locale: "The default language of this Discourse instance (ISO 639-1 Code)"
|
default_locale: "The default language of this Discourse instance (ISO 639-1 Code)"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe Report do
|
|||||||
|
|
||||||
describe 'visits report' do
|
describe 'visits report' do
|
||||||
|
|
||||||
let(:report) { Report.find('visits') }
|
let(:report) { Report.find('visits', cache: false) }
|
||||||
|
|
||||||
context "no visits" do
|
context "no visits" do
|
||||||
it "returns an empty report" do
|
it "returns an empty report" do
|
||||||
@@ -30,5 +30,108 @@ describe Report do
|
|||||||
|
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user