dashboard next: activity metrics and new contributors

This commit also introduces a better grouping of data points.
This commit is contained in:
Joffrey JAFFEUX 2018-04-26 14:49:41 +02:00 committed by GitHub
parent b26e27bdab
commit 9fabf2543b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 260 additions and 128 deletions

View File

@ -2,7 +2,7 @@ import { ajax } from 'discourse/lib/ajax';
import computed from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
classNames: ["dashboard-table", "dashboard-inline-table"],
classNames: ["dashboard-table", "dashboard-inline-table", "fixed"],
classNameBindings: ["isLoading"],

View File

@ -1,6 +1,7 @@
import { ajax } from 'discourse/lib/ajax';
import computed from 'ember-addons/ember-computed-decorators';
import loadScript from 'discourse/lib/load-script';
import { ajax } from "discourse/lib/ajax";
import computed from "ember-addons/ember-computed-decorators";
import loadScript from "discourse/lib/load-script";
import Report from "admin/models/report";
export default Ember.Component.extend({
classNames: ["dashboard-mini-chart"],
@ -17,12 +18,26 @@ export default Ember.Component.extend({
didInsertElement() {
this._super();
this._initializeChart();
if (this.get("model")) {
loadScript("/javascripts/Chart.min.js").then(() => {
this._setPropertiesFromModel(this.get("model"));
this._drawChart();
});
}
},
didUpdateAttrs() {
this._super();
this._initializeChart();
loadScript("/javascripts/Chart.min.js").then(() => {
if (this.get("model") && !this.get("values")) {
this._setPropertiesFromModel(this.get("model"));
this._drawChart();
} else if (this.get("dataSource")) {
this._fetchReport();
}
});
},
@computed("dataSourceName")
@ -34,10 +49,17 @@ export default Ember.Component.extend({
@computed("trend")
trendIcon(trend) {
if (trend === "stable") {
return null;
} else {
return `angle-${trend}`;
switch (trend) {
case "trending-up":
return "angle-up";
case "trending-down":
return "angle-down";
case "high-trending-up":
return "angle-double-up";
case "high-trending-down":
return "angle-double-down";
default:
return null;
}
},
@ -46,7 +68,9 @@ export default Ember.Component.extend({
this.set("isLoading", true);
let payload = {data: {}};
let payload = {
data: {}
};
if (this.get("startDate")) {
payload.data.start_date = this.get("startDate").toISOString();
@ -58,7 +82,7 @@ export default Ember.Component.extend({
ajax(this.get("dataSource"), payload)
.then((response) => {
this._setPropertiesFromModel(response.report);
this._setPropertiesFromModel(Report.create(response.report));
})
.finally(() => {
this.set("isLoading", false);
@ -71,17 +95,6 @@ export default Ember.Component.extend({
});
},
_initializeChart() {
loadScript("/javascripts/Chart.min.js").then(() => {
if (this.get("model") && !this.get("values")) {
this._setPropertiesFromModel(this.get("model"));
this._drawChart();
} else if (this.get("dataSource")) {
this._fetchReport();
}
});
},
_drawChart() {
const $chartCanvas = this.$(".chart-canvas");
if (!$chartCanvas.length) return;
@ -91,7 +104,7 @@ export default Ember.Component.extend({
const data = {
labels: this.get("labels"),
datasets: [{
data: this.get("values"),
data: Ember.makeArray(this.get("values")),
backgroundColor: this.get("backgroundColor"),
borderColor: this.get("borderColor")
}]
@ -100,72 +113,64 @@ export default Ember.Component.extend({
this._chart = new window.Chart(context, this._buildChartConfig(data));
},
_setPropertiesFromModel(model) {
_setPropertiesFromModel(report) {
const oneDataPoint = (this.get("startDate") && this.get("endDate")) &&
this.get("startDate").isSame(this.get("endDate"), "day");
this.setProperties({
labels: model.data.map(r => r.x),
values: model.data.map(r => r.y),
oneDataPoint: (this.get("startDate") && this.get("endDate")) &&
this.get("startDate").isSame(this.get("endDate"), 'day'),
total: model.total,
title: model.title,
trend: this._computeTrend(model.total, model.prev30Days)
oneDataPoint,
labels: report.get("data").map(r => r.x),
values: report.get("data").map(r => r.y),
total: report.get("total"),
description: report.get("description"),
title: report.get("title"),
trend: report.get("sevenDayTrend"),
prev30Days: report.get("prev30Days"),
});
},
_buildChartConfig(data) {
const values = this.get("values");
const values = data.datasets[0].data;
const max = Math.max(...values);
const min = Math.min(...values);
const stepSize = Math.max(...[Math.ceil((max - min)/5), 20]);
const startDate = this.get("startDate") || moment();
const endDate = this.get("endDate") || moment();
const datesDifference = startDate.diff(endDate, "days");
let unit = "day";
if (datesDifference >= 366) {
unit = "quarter";
} else if (datesDifference >= 61) {
unit = "month";
} else if (datesDifference >= 14) {
unit = "week";
}
const stepSize = Math.max(...[Math.ceil((max - min) / 5) * 5, 20]);
return {
type: "line",
data,
options: {
legend: { display: false },
legend: {
display: false
},
responsive: true,
layout: { padding: { left: 0, top: 0, right: 0, bottom: 0 } },
maintainAspectRatio: false,
layout: {
padding: {
left: 0,
top: 0,
right: 0,
bottom: 0
}
},
scales: {
yAxes: [
{
display: true,
ticks: { suggestedMin: 0, stepSize, suggestedMax: max + stepSize }
yAxes: [{
display: true,
ticks: {
suggestedMin: 0,
stepSize,
suggestedMax: max + stepSize
}
],
xAxes: [
{
display: true,
type: "time",
time: {
parser: "YYYY-MM-DD",
unit
}
}],
xAxes: [{
display: true,
type: "time",
time: {
parser: "YYYY-MM-DD"
}
],
}],
}
},
};
},
_computeTrend(total, prevTotal) {
const percentChange = ((total - prevTotal) / prevTotal) * 100;
if (percentChange > 50) return "double-up";
if (percentChange > 0) return "up";
if (percentChange === 0) return "stable";
if (percentChange < 50) return "double-down";
if (percentChange < 0) return "down";
},
}
});

View File

@ -1,11 +1,6 @@
import DiscourseURL from "discourse/lib/url";
import computed from "ember-addons/ember-computed-decorators";
import AdminDashboardNext from 'admin/models/admin-dashboard-next';
import Report from 'admin/models/report';
const ATTRIBUTES = [ "disk_space", "updated_at", "last_backup_taken_at"];
const REPORTS = [ "global_reports", "user_reports" ];
export default Ember.Controller.extend({
queryParams: ["period"],
@ -14,20 +9,17 @@ export default Ember.Controller.extend({
dashboardFetchedAt: null,
exceptionController: Ember.inject.controller('exception'),
diskSpace: Ember.computed.alias("model.attributes.disk_space"),
fetchDashboard() {
if (this.get("isLoading")) return;
if (!this.get("dashboardFetchedAt") || moment().subtract(30, "minutes").toDate() > this.get("dashboardFetchedAt")) {
this.set("isLoading", true);
AdminDashboardNext.find().then(d => {
AdminDashboardNext.find().then(adminDashboardNextModel => {
this.set("dashboardFetchedAt", new Date());
const reports = {};
REPORTS.forEach(name => d[name].forEach(r => reports[`${name}_${r.type}`] = Report.create(r)));
this.setProperties(reports);
ATTRIBUTES.forEach(a => this.set(a, d[a]));
this.set("model", adminDashboardNextModel);
}).catch(e => {
this.get("exceptionController").set("thrown", e.jqXHR);
this.replaceRoute("exception");

View File

@ -1,9 +1,13 @@
import { ajax } from 'discourse/lib/ajax';
import { ajax } from "discourse/lib/ajax";
import Report from "admin/models/report";
const ATTRIBUTES = [ "disk_space", "updated_at", "last_backup_taken_at"];
const REPORTS = [ "global_reports", "user_reports" ];
const AdminDashboardNext = Discourse.Model.extend({});
AdminDashboardNext.reopenClass({
/**
Fetch all dashboard data. This can be an expensive request when the cached data
has expired and the server must collect the data again.
@ -11,13 +15,26 @@ AdminDashboardNext.reopenClass({
@method find
@return {jqXHR} a jQuery Promise object
**/
find: function() {
find() {
return ajax("/admin/dashboard-next.json").then(function(json) {
var model = AdminDashboardNext.create(json);
model.set('loaded', true);
var model = AdminDashboardNext.create();
const reports = {};
REPORTS.forEach(name => json[name].forEach(r => {
if (!reports[name]) reports[name] = {};
reports[name][r.type] = Report.create(r);
}));
model.set("reports", reports);
const attributes = {};
ATTRIBUTES.forEach(a => attributes[a] = json[a]);
model.set("attributes", attributes);
model.set("loaded", true);
return model;
});
},
}
});
export default AdminDashboardNext;

View File

@ -60,12 +60,18 @@ const Report = Discourse.Model.extend({
sevenDayTrend() {
const currentPeriod = this.valueFor(1, 7);
const prevPeriod = this.valueFor(8, 14);
if (currentPeriod > prevPeriod) {
const change = ((currentPeriod - prevPeriod) / prevPeriod) * 100;
if (change > 50) {
return "high-trending-up";
} else if (change > 0) {
return "trending-up";
} else if (currentPeriod < prevPeriod) {
return "trending-down";
} else {
} else if (change === 0) {
return "no-change";
} else if (change < -50) {
return "high-trending-down";
} else if (change < 0) {
return "trending-down";
}
},

View File

@ -2,8 +2,10 @@
<div class="chart-title">
<h3>{{title}}</h3>
{{#if help}}
{{d-icon "question-circle" title=help}}
{{#if description}}
<span title={{description}}>
{{d-icon "question-circle"}}
</span>
{{/if}}
</div>
@ -14,7 +16,7 @@
</span>
{{else}}
<div class="chart-trend {{trend}}">
<span>{{number total}}</span>
<span>{{number prev30Days}}</span>
{{#if trendIcon}}
{{d-icon trendIcon}}

View File

@ -1,5 +1,5 @@
{{plugin-outlet name="admin-dashboard-top"}}
{{lastRefreshedAt}}
<div class="community-health section">
<div class="section-title">
<h2>{{i18n "admin.dashboard.community_health"}}</h2>
@ -9,31 +9,61 @@
<div class="section-body">
<div class="charts">
{{dashboard-mini-chart
model=global_reports_signups
model=model.reports.global_reports.signups
dataSourceName="signups"
startDate=startDate
endDate=endDate
help="admin.dashboard.charts.signups.help"}}
endDate=endDate}}
{{dashboard-mini-chart
model=global_reports_topics
model=model.reports.global_reports.topics
dataSourceName="topics"
startDate=startDate
endDate=endDate
help="admin.dashboard.charts.topics.help"}}
endDate=endDate}}
{{dashboard-mini-chart
model=model.reports.global_reports.new_contributors
dataSourceName="new_contributors"
startDate=startDate
endDate=endDate}}
</div>
</div>
</div>
<div class="section-columns">
<div class="section-column">
<div class="dashboard-table">
<div class="table-title">
<h3>{{i18n "admin.dashboard.activity_metrics"}}</h3>
</div>
<div class="table-container">
<table>
<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>
<th>{{i18n 'admin.dashboard.reports.all'}}</th>
</tr>
</thead>
<tbody>
{{admin-report-counts report=model.reports.global_reports.topics}}
{{admin-report-counts report=model.reports.global_reports.signups}}
{{admin-report-counts report=model.reports.global_reports.new_contributors}}
</tbody>
</table>
</div>
</div>
{{dashboard-inline-table
model=user_reports_users_by_type
model=model.reports.user_reports.users_by_type
lastRefreshedAt=lastRefreshedAt
isLoading=isLoading}}
{{dashboard-inline-table
model=user_reports_users_by_trust_level
model=model.reports.user_reports.users_by_trust_level
lastRefreshedAt=lastRefreshedAt
isLoading=isLoading}}
@ -44,7 +74,7 @@
<div class="backups">
<h3 class="durability-title"><a href="/admin/backups">{{i18n "admin.dashboard.backups"}}</a></h3>
<p>
{{disk_space.backups_used}} ({{i18n "admin.dashboard.space_free" size=disk_space.backups_free}})
{{diskSpace.backups_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.backups_free}})
<br />
{{{i18n "admin.dashboard.lastest_backup" date=backupTimestamp}}}
</p>
@ -54,7 +84,7 @@
<div class="uploads">
<h3 class="durability-title">{{i18n "admin.dashboard.uploads"}}</h3>
<p>
{{disk_space.uploads_used}} ({{i18n "admin.dashboard.space_free" size=disk_space.uploads_free}})
{{diskSpace.uploads_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.uploads_free}})
</p>
</div>
</div>

View File

@ -965,13 +965,13 @@ table.api-keys {
display: none;
}
&.trending-up {
&.high-trending-up, &.trending-up {
i.up {
color: $success;
display: inline;
}
}
&.trending-down {
&.high-trending-down, &.trending-down {
i.down {
color: $danger;
display: inline;
@ -986,10 +986,10 @@ table.api-keys {
}
tr.reverse-colors {
td.value.trending-down i.down {
td.value.high-trending-down i.down, td.value.trending-down i.down {
color: $success;
}
td.value.trending-up i.up {
td.value.high-trending-up i.up, td.value.trending-up i.up {
color: $danger;
}
}

View File

@ -43,6 +43,10 @@
.dashboard-table {
margin-bottom: 1em;
&.fixed table {
table-layout: fixed;
}
&.is-loading {
height: 150px;
}
@ -59,7 +63,6 @@
table {
border: 1px solid $primary-low-mid;
table-layout: fixed;
thead {
tr {
@ -67,6 +70,10 @@
th {
border: 1px solid $primary-low-mid;
text-align: center;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
@ -77,6 +84,31 @@
border: 1px solid $primary-low-mid;
text-align: center;
}
td.value {
i {
display: none;
}
&.high-trending-up, &.trending-up {
i.up {
color: $success;
display: inline;
}
}
&.high-trending-down, &.trending-down {
i.down {
color: $danger;
display: inline;
}
}
&.no-change {
i.down {
display: inline;
visibility: hidden;
}
}
}
}
}
}
@ -110,13 +142,13 @@
}
}
&.double-up, &.up {
&.high-trending-up, &.trending-up {
.chart-trend, .data-point {
color: rgb(17, 141, 0);
}
}
&.double-down, &.down {
&.high-trending-down, &.trending-down {
.chart-trend, .data-point {
color: $danger;
}
@ -145,13 +177,14 @@
.chart-container {
position: relative;
padding: 0 1em;
min-height: 200px;
}
.chart-trend {
font-size: $font-up-5;
position: absolute;
right: 1.5em;
top: .5em;
right: 40px;
top: 5px;
display: flex;
justify-content: space-between;
align-items: center;

View File

@ -4,7 +4,8 @@ class AdminDashboardNextData
GLOBAL_REPORTS ||= [
'signups',
'topics',
'trending_search'
'trending_search',
'new_contributors'
]
USER_REPORTS ||= [

View File

@ -20,6 +20,7 @@ class Report
title: I18n.t("reports.#{type}.title"),
xaxis: I18n.t("reports.#{type}.xaxis"),
yaxis: I18n.t("reports.#{type}.yaxis"),
description: I18n.t("reports.#{type}.description"),
data: data,
total: total,
start_date: start_date,
@ -109,6 +110,10 @@ class Report
end
end
def self.report_new_contributors(report)
report_about report, User.real, :count_by_first_post
end
def self.report_profile_views(report)
start_date = report.start_date.to_date
end_date = report.end_date.to_date

View File

@ -21,6 +21,7 @@ class Topic < ActiveRecord::Base
include Searchable
include LimitedEdit
extend Forwardable
include DateGroupable
def_delegator :featured_users, :user_ids, :featured_user_ids
def_delegator :featured_users, :choose, :feature_topic_users
@ -458,9 +459,9 @@ class Topic < ActiveRecord::Base
end
def self.listable_count_per_day(start_date, end_date, category_id = nil)
result = listable_topics.where('created_at >= ? and created_at <= ?', start_date, end_date)
result = listable_topics.smart_group_by_date("topics.created_at", start_date, end_date)
result = result.where(category_id: category_id) if category_id
result.group('date(created_at)').order('date(created_at)').count
result.count
end
def private_message?

View File

@ -19,6 +19,7 @@ class User < ActiveRecord::Base
include Roleable
include HasCustomFields
include SecondFactorManager
include DateGroupable
# TODO: Remove this after 7th Jan 2018
self.ignored_columns = %w{email}
@ -829,13 +830,20 @@ class User < ActiveRecord::Base
end
def self.count_by_signup_date(start_date, end_date, group_id = nil)
result = where('users.created_at >= ? AND users.created_at <= ?', start_date, end_date)
result = smart_group_by_date("users.created_at", start_date, end_date)
if group_id
result = result.joins("INNER JOIN group_users ON group_users.user_id = users.id")
result = result.where("group_users.group_id = ?", group_id)
end
result.group('date(users.created_at)').order('date(users.created_at)').count
result.count
end
def self.count_by_first_post(start_date, end_date)
joins('INNER JOIN user_stats AS us ON us.user_id = users.id')
.smart_group_by_date("us.first_post_created_at", start_date, end_date)
.count
end
def secure_category_ids

View File

@ -2742,12 +2742,7 @@ en:
show_traffic_report: "Show Detailed Traffic Report"
community_health: Community health
whats_new_in_discourse: Whats new in Discourse?
charts:
signups:
help: Users created for this period
topics:
help: Topics created for this period
activity_metrics: Activity Metrics
reports:
today: "Today"

View File

@ -840,6 +840,12 @@ en:
title: "New Users"
xaxis: "Day"
yaxis: "Number of new users"
description: "Users created for this period"
new_contributors:
title: "New Contributors"
xaxis: "Day"
yaxis: "Number of new contributors"
description: "Number of users who made their first contribution"
profile_views:
title: "User Profile Views"
xaxis: "Day"
@ -848,6 +854,7 @@ en:
title: "Topics"
xaxis: "Day"
yaxis: "Number of new topics"
description: "Topics created for this period"
posts:
title: "Posts"
xaxis: "Day"

View File

@ -250,6 +250,36 @@ describe Report do
end
end
describe 'new contributors report' do
let(:report) { Report.find('new_contributors') }
context "no contributors" do
it "returns an empty report" do
expect(report.data).to be_blank
end
end
context "with contributors" do
before do
jeff = Fabricate(:user)
jeff.user_stat = UserStat.new(new_since: 1.hour.ago, first_post_created_at: 1.day.ago)
regis = Fabricate(:user)
regis.user_stat = UserStat.new(new_since: 1.hour.ago, first_post_created_at: 2.days.ago)
hawk = Fabricate(:user)
hawk.user_stat = UserStat.new(new_since: 1.hour.ago, first_post_created_at: 2.days.ago)
end
it "returns a report with data" do
expect(report.data).to be_present
expect(report.data[0][:y]).to eq 2
expect(report.data[1][:y]).to eq 1
end
end
end
describe 'users by types level report' do
let(:report) { Report.find('users_by_type') }