From 8927432a93603b351e8a2dcb6f3ed1606a910ad5 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Thu, 7 Mar 2013 11:07:59 -0500 Subject: [PATCH] Add stats to the admin dashboard --- .../admin/helpers/report_helpers.js | 38 +++++++ app/assets/javascripts/admin/models/report.js | 2 +- .../admin/routes/admin_dashboard_route.js | 13 ++- .../admin/templates/dashboard.js.handlebars | 20 +++- .../admin/templates/report.js.handlebars | 9 ++ .../javascripts/admin/views/reports_views.js | 9 ++ .../discourse/helpers/application_helpers.js | 4 +- app/assets/stylesheets/admin/admin_base.scss | 18 +++ app/models/post.rb | 4 + app/models/report.rb | 66 ++++++++++- app/models/topic.rb | 4 + app/models/user.rb | 4 + app/models/user_visit.rb | 4 +- config/locales/client.en.yml | 7 ++ config/locales/server.en.yml | 10 +- spec/models/report_spec.rb | 105 +++++++++++++++++- 16 files changed, 301 insertions(+), 16 deletions(-) create mode 100644 app/assets/javascripts/admin/helpers/report_helpers.js create mode 100644 app/assets/javascripts/admin/templates/report.js.handlebars create mode 100644 app/assets/javascripts/admin/views/reports_views.js diff --git a/app/assets/javascripts/admin/helpers/report_helpers.js b/app/assets/javascripts/admin/helpers/report_helpers.js new file mode 100644 index 00000000000..c651e258d82 --- /dev/null +++ b/app/assets/javascripts/admin/helpers/report_helpers.js @@ -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; + } +}); diff --git a/app/assets/javascripts/admin/models/report.js b/app/assets/javascripts/admin/models/report.js index 317b119ae30..6bff1e35fc4 100644 --- a/app/assets/javascripts/admin/models/report.js +++ b/app/assets/javascripts/admin/models/report.js @@ -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) { diff --git a/app/assets/javascripts/admin/routes/admin_dashboard_route.js b/app/assets/javascripts/admin/routes/admin_dashboard_route.js index 49a745eadb2..da31f82d8f2 100644 --- a/app/assets/javascripts/admin/routes/admin_dashboard_route.js +++ b/app/assets/javascripts/admin/routes/admin_dashboard_route.js @@ -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)); + }); } }); diff --git a/app/assets/javascripts/admin/templates/dashboard.js.handlebars b/app/assets/javascripts/admin/templates/dashboard.js.handlebars index edf480235ea..987f1dc0200 100644 --- a/app/assets/javascripts/admin/templates/dashboard.js.handlebars +++ b/app/assets/javascripts/admin/templates/dashboard.js.handlebars @@ -41,4 +41,22 @@
-{{/if}} \ No newline at end of file +{{/if}} + +
+ + + + + + + + + + + {{ render 'admin_signups' signups }} + {{ render 'admin_visits' visits }} + {{ render 'admin_topics' topics }} + {{ render 'admin_posts' posts }} +
 {{i18n admin.dashboard.reports.today}}{{i18n admin.dashboard.reports.yesterday}}{{i18n admin.dashboard.reports.last_7_days}}{{i18n admin.dashboard.reports.last_30_days}}
+
\ No newline at end of file diff --git a/app/assets/javascripts/admin/templates/report.js.handlebars b/app/assets/javascripts/admin/templates/report.js.handlebars new file mode 100644 index 00000000000..8ac6c4077ac --- /dev/null +++ b/app/assets/javascripts/admin/templates/report.js.handlebars @@ -0,0 +1,9 @@ +{{#if loaded}} + + {{title}} + {{valueAtDaysAgo data 0}} + {{valueAtDaysAgo data 1}} + {{sumLast data 7}} + {{sumLast data 30}} + +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/admin/views/reports_views.js b/app/assets/javascripts/admin/views/reports_views.js new file mode 100644 index 00000000000..c3bea19041e --- /dev/null +++ b/app/assets/javascripts/admin/views/reports_views.js @@ -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); \ No newline at end of file diff --git a/app/assets/javascripts/discourse/helpers/application_helpers.js b/app/assets/javascripts/discourse/helpers/application_helpers.js index 0c51062d285..fcc190c8b41 100644 --- a/app/assets/javascripts/discourse/helpers/application_helpers.js +++ b/app/assets/javascripts/discourse/helpers/application_helpers.js @@ -267,6 +267,4 @@ Handlebars.registerHelper('personalizedName', function(property, options) { return name; } return Em.String.i18n('you'); -}); - - +}); \ No newline at end of file diff --git a/app/assets/stylesheets/admin/admin_base.scss b/app/assets/stylesheets/admin/admin_base.scss index 4831e9e9f45..ce927f4c229 100644 --- a/app/assets/stylesheets/admin/admin_base.scss +++ b/app/assets/stylesheets/admin/admin_base.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/app/models/post.rb b/app/models/post.rb index 82cf60d74e7..6a3b8fff114 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -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 diff --git a/app/models/report.rb b/app/models/report.rb index 82e6ef2ba44..719643865b5 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -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 diff --git a/app/models/topic.rb b/app/models/topic.rb index 4b0f2ce42c6..8f19aeb672f 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index f1e1e035c86..dad849c5f09 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/models/user_visit.rb b/app/models/user_visit.rb index fea4b793be7..1cb87af4128 100644 --- a/app/models/user_visit.rb +++ b/app/models/user_visit.rb @@ -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 diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 102d0970a87..8803f343ee5 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index a3b50b2b666..595b5766e60 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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)" diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index 4c9c0c5d997..1606d37a663 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -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