dashboard next: caching, mobile support and new charts
This commit is contained in:
parent
c718c59b5d
commit
980972182f
|
@ -1,65 +1,23 @@
|
|||
import { ajax } from 'discourse/lib/ajax';
|
||||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
import Report from "admin/models/report";
|
||||
import AsyncReport from "admin/mixins/async-report";
|
||||
|
||||
export default Ember.Component.extend({
|
||||
export default Ember.Component.extend(AsyncReport, {
|
||||
classNames: ["dashboard-table", "dashboard-inline-table", "fixed"],
|
||||
|
||||
classNameBindings: ["isLoading"],
|
||||
|
||||
total: null,
|
||||
labels: null,
|
||||
title: null,
|
||||
chartData: null,
|
||||
isLoading: false,
|
||||
isLoading: true,
|
||||
help: null,
|
||||
helpPage: null,
|
||||
model: null,
|
||||
|
||||
didInsertElement() {
|
||||
this._super();
|
||||
|
||||
if (this.get("dataSourceName")){
|
||||
this._fetchReport();
|
||||
} else if (this.get("model")) {
|
||||
this._setPropertiesFromModel(this.get("model"));
|
||||
}
|
||||
},
|
||||
|
||||
didUpdateAttrs() {
|
||||
this._super();
|
||||
|
||||
if (this.get("model")) {
|
||||
this._setPropertiesFromModel(this.get("model"));
|
||||
}
|
||||
},
|
||||
|
||||
@computed("dataSourceName")
|
||||
dataSource(dataSourceName) {
|
||||
return `/admin/reports/${dataSourceName}`;
|
||||
},
|
||||
|
||||
_fetchReport() {
|
||||
if (this.get("isLoading")) return;
|
||||
|
||||
fetchReport() {
|
||||
this.set("isLoading", true);
|
||||
|
||||
ajax(this.get("dataSource"))
|
||||
.then((response) => {
|
||||
this._setPropertiesFromModel(response.report);
|
||||
this._setPropertiesFromReport(Report.create(response.report));
|
||||
}).finally(() => {
|
||||
if (!Ember.isEmpty(this.get("report.data"))) {
|
||||
this.set("isLoading", false);
|
||||
});
|
||||
},
|
||||
|
||||
_setPropertiesFromModel(model) {
|
||||
const data = model.data.sort((a, b) => a.x >= b.x);
|
||||
|
||||
this.setProperties({
|
||||
labels: data.map(r => r.x),
|
||||
dataset: data.map(r => r.y),
|
||||
total: model.total,
|
||||
title: model.title,
|
||||
chartData: data
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,43 +1,23 @@
|
|||
import { ajax } from "discourse/lib/ajax";
|
||||
import computed from "ember-addons/ember-computed-decorators";
|
||||
import loadScript from "discourse/lib/load-script";
|
||||
import AsyncReport from "admin/mixins/async-report";
|
||||
import Report from "admin/models/report";
|
||||
import { number } from 'discourse/lib/formatter';
|
||||
|
||||
export default Ember.Component.extend({
|
||||
export default Ember.Component.extend(AsyncReport, {
|
||||
classNames: ["dashboard-mini-chart"],
|
||||
|
||||
classNameBindings: ["trend", "oneDataPoint", "isLoading"],
|
||||
|
||||
isLoading: false,
|
||||
total: null,
|
||||
trend: null,
|
||||
title: null,
|
||||
classNameBindings: ["thirtyDayTrend", "oneDataPoint"],
|
||||
isLoading: true,
|
||||
thirtyDayTrend: Ember.computed.alias("report.thirtyDayTrend"),
|
||||
oneDataPoint: false,
|
||||
backgroundColor: "rgba(200,220,240,0.3)",
|
||||
borderColor: "#08C",
|
||||
average: false,
|
||||
|
||||
didInsertElement() {
|
||||
willDestroyEelement() {
|
||||
this._super();
|
||||
|
||||
if (this.get("model")) {
|
||||
loadScript("/javascripts/Chart.min.js").then(() => {
|
||||
this._setPropertiesFromModel(this.get("model"));
|
||||
this._drawChart();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
didUpdateAttrs() {
|
||||
this._super();
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
this.messageBus.unsubscribe(this.get("dataSource"));
|
||||
},
|
||||
|
||||
@computed("dataSourceName")
|
||||
|
@ -47,9 +27,9 @@ export default Ember.Component.extend({
|
|||
}
|
||||
},
|
||||
|
||||
@computed("trend")
|
||||
trendIcon(trend) {
|
||||
switch (trend) {
|
||||
@computed("thirtyDayTrend")
|
||||
trendIcon(thirtyDayTrend) {
|
||||
switch (thirtyDayTrend) {
|
||||
case "trending-up":
|
||||
return "angle-up";
|
||||
case "trending-down":
|
||||
|
@ -63,42 +43,48 @@ export default Ember.Component.extend({
|
|||
}
|
||||
},
|
||||
|
||||
_fetchReport() {
|
||||
if (this.get("isLoading")) return;
|
||||
|
||||
fetchReport() {
|
||||
this.set("isLoading", true);
|
||||
|
||||
let payload = {
|
||||
data: {}
|
||||
data: { async: true }
|
||||
};
|
||||
|
||||
if (this.get("startDate")) {
|
||||
payload.data.start_date = this.get("startDate").toISOString();
|
||||
payload.data.start_date = this.get("startDate").format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ');
|
||||
}
|
||||
|
||||
if (this.get("endDate")) {
|
||||
payload.data.end_date = this.get("endDate").toISOString();
|
||||
payload.data.end_date = this.get("endDate").format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ');
|
||||
}
|
||||
|
||||
ajax(this.get("dataSource"), payload)
|
||||
.then((response) => {
|
||||
this._setPropertiesFromModel(Report.create(response.report));
|
||||
// if (!Ember.isEmpty(response.report.data)) {
|
||||
this._setPropertiesFromReport(Report.create(response.report));
|
||||
// }
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.get("oneDataPoint")) {
|
||||
this.set("isLoading", false);
|
||||
|
||||
Ember.run.schedule("afterRender", () => {
|
||||
if (!this.get("oneDataPoint")) {
|
||||
this._drawChart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Ember.isEmpty(this.get("report.data"))) {
|
||||
this.set("isLoading", false);
|
||||
this.renderReport();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
_drawChart() {
|
||||
const $chartCanvas = this.$(".chart-canvas");
|
||||
if (!$chartCanvas.length) return;
|
||||
renderReport() {
|
||||
if (!this.element || this.isDestroying || this.isDestroyed) { return; }
|
||||
if (this.get("oneDataPoint")) return;
|
||||
|
||||
Ember.run.schedule("afterRender", () => {
|
||||
const $chartCanvas = this.$(".chart-canvas");
|
||||
|
||||
if (!$chartCanvas.length) return;
|
||||
const context = $chartCanvas[0].getContext("2d");
|
||||
|
||||
const data = {
|
||||
|
@ -111,31 +97,19 @@ export default Ember.Component.extend({
|
|||
};
|
||||
|
||||
this._chart = new window.Chart(context, this._buildChartConfig(data));
|
||||
},
|
||||
|
||||
_setPropertiesFromModel(report) {
|
||||
const oneDataPoint = (this.get("startDate") && this.get("endDate")) &&
|
||||
this.get("startDate").isSame(this.get("endDate"), "day");
|
||||
|
||||
this.setProperties({
|
||||
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"),
|
||||
});
|
||||
},
|
||||
|
||||
_setPropertiesFromReport(report) {
|
||||
const oneDataPoint = (this.get("startDate") && this.get("endDate")) &&
|
||||
this.get("startDate").isSame(this.get("endDate"), "day");
|
||||
|
||||
report.set("average", this.get("average"));
|
||||
|
||||
this.setProperties({ oneDataPoint, report });
|
||||
},
|
||||
|
||||
_buildChartConfig(data) {
|
||||
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) * 5, 20]);
|
||||
|
||||
return {
|
||||
type: "line",
|
||||
data,
|
||||
|
@ -144,7 +118,6 @@ export default Ember.Component.extend({
|
|||
display: false
|
||||
},
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
|
@ -156,11 +129,7 @@ export default Ember.Component.extend({
|
|||
scales: {
|
||||
yAxes: [{
|
||||
display: true,
|
||||
ticks: {
|
||||
suggestedMin: 0,
|
||||
stepSize,
|
||||
suggestedMax: max + stepSize
|
||||
}
|
||||
ticks: { callback: (label) => number(label) }
|
||||
}],
|
||||
xAxes: [{
|
||||
display: true,
|
||||
|
|
|
@ -1,17 +1,8 @@
|
|||
import DashboardTable from "admin/components/dashboard-table";
|
||||
import { number } from 'discourse/lib/formatter';
|
||||
import AsyncReport from "admin/mixins/async-report";
|
||||
|
||||
export default DashboardTable.extend({
|
||||
export default DashboardTable.extend(AsyncReport, {
|
||||
layoutName: "admin/templates/components/dashboard-table",
|
||||
|
||||
classNames: ["dashboard-table", "dashboard-table-trending-search"],
|
||||
|
||||
transformModel(model) {
|
||||
return {
|
||||
labels: model.labels,
|
||||
values: model.data.map(data => {
|
||||
return [data[0], number(data[1]), number(data[2])];
|
||||
})
|
||||
};
|
||||
},
|
||||
classNames: ["dashboard-table", "dashboard-table-trending-search"]
|
||||
});
|
||||
|
|
|
@ -1,83 +1,50 @@
|
|||
import { ajax } from 'discourse/lib/ajax';
|
||||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import Report from "admin/models/report";
|
||||
import AsyncReport from "admin/mixins/async-report";
|
||||
import computed from "ember-addons/ember-computed-decorators";
|
||||
import { number } from 'discourse/lib/formatter';
|
||||
|
||||
export default Ember.Component.extend({
|
||||
export default Ember.Component.extend(AsyncReport, {
|
||||
classNames: ["dashboard-table"],
|
||||
|
||||
classNameBindings: ["isLoading"],
|
||||
|
||||
total: null,
|
||||
labels: null,
|
||||
title: null,
|
||||
chartData: null,
|
||||
isLoading: false,
|
||||
help: null,
|
||||
helpPage: null,
|
||||
model: null,
|
||||
|
||||
transformModel(model) {
|
||||
const data = model.data.sort((a, b) => a.x >= b.x);
|
||||
|
||||
return {
|
||||
labels: model.labels,
|
||||
values: data
|
||||
};
|
||||
@computed("report")
|
||||
values(report) {
|
||||
if (!report) return;
|
||||
return Ember.makeArray(report.data)
|
||||
.sort((a, b) => a.x >= b.x)
|
||||
.map(x => {
|
||||
return [ x[0], number(x[1]), number(x[2]) ];
|
||||
});
|
||||
},
|
||||
|
||||
didInsertElement() {
|
||||
this._super();
|
||||
this._initializeTable();
|
||||
@computed("report")
|
||||
labels(report) {
|
||||
if (!report) return;
|
||||
return Ember.makeArray(report.labels);
|
||||
},
|
||||
|
||||
didUpdateAttrs() {
|
||||
this._super();
|
||||
this._initializeTable();
|
||||
},
|
||||
|
||||
@computed("dataSourceName")
|
||||
dataSource(dataSourceName) {
|
||||
return `/admin/reports/${dataSourceName}`;
|
||||
},
|
||||
|
||||
_initializeTable() {
|
||||
if (this.get("model") && !this.get("values")) {
|
||||
this._setPropertiesFromModel(this.get("model"));
|
||||
} else if (this.get("dataSource")) {
|
||||
this._fetchReport();
|
||||
}
|
||||
},
|
||||
|
||||
_fetchReport() {
|
||||
if (this.get("isLoading")) return;
|
||||
|
||||
fetchReport() {
|
||||
this.set("isLoading", true);
|
||||
|
||||
let payload = {data: {}};
|
||||
let payload = { data: { async: true } };
|
||||
|
||||
if (this.get("startDate")) {
|
||||
payload.data.start_date = this.get("startDate").toISOString();
|
||||
payload.data.start_date = this.get("startDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ");
|
||||
}
|
||||
|
||||
if (this.get("endDate")) {
|
||||
payload.data.end_date = this.get("endDate").toISOString();
|
||||
payload.data.end_date = this.get("endDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ");
|
||||
}
|
||||
|
||||
ajax(this.get("dataSource"), payload)
|
||||
.then((response) => {
|
||||
this._setPropertiesFromModel(response.report);
|
||||
this._setPropertiesFromReport(Report.create(response.report));
|
||||
}).finally(() => {
|
||||
if (!Ember.isEmpty(this.get("report.data"))) {
|
||||
this.set("isLoading", false);
|
||||
});
|
||||
},
|
||||
|
||||
_setPropertiesFromModel(model) {
|
||||
const { labels, values } = this.transformModel(model);
|
||||
|
||||
this.setProperties({
|
||||
labels,
|
||||
values,
|
||||
total: model.total,
|
||||
title: model.title
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import DiscourseURL from "discourse/lib/url";
|
||||
import computed from "ember-addons/ember-computed-decorators";
|
||||
import AdminDashboardNext from 'admin/models/admin-dashboard-next';
|
||||
import AdminDashboardNext from "admin/models/admin-dashboard-next";
|
||||
import Report from "admin/models/report";
|
||||
|
||||
export default Ember.Controller.extend({
|
||||
queryParams: ["period"],
|
||||
period: "all",
|
||||
isLoading: false,
|
||||
dashboardFetchedAt: null,
|
||||
exceptionController: Ember.inject.controller('exception'),
|
||||
|
||||
exceptionController: Ember.inject.controller("exception"),
|
||||
diskSpace: Ember.computed.alias("model.attributes.disk_space"),
|
||||
|
||||
fetchDashboard() {
|
||||
|
@ -18,8 +18,11 @@ export default Ember.Controller.extend({
|
|||
this.set("isLoading", true);
|
||||
|
||||
AdminDashboardNext.find().then(adminDashboardNextModel => {
|
||||
this.set("dashboardFetchedAt", new Date());
|
||||
this.set("model", adminDashboardNextModel);
|
||||
this.setProperties({
|
||||
dashboardFetchedAt: new Date(),
|
||||
model: adminDashboardNextModel,
|
||||
reports: adminDashboardNextModel.reports.map(x => Report.create(x))
|
||||
});
|
||||
}).catch(e => {
|
||||
this.get("exceptionController").set("thrown", e.jqXHR);
|
||||
this.replaceRoute("exception");
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
import Report from "admin/models/report";
|
||||
|
||||
export default Ember.Mixin.create({
|
||||
classNameBindings: ["isLoading"],
|
||||
|
||||
report: null,
|
||||
|
||||
init() {
|
||||
this._super();
|
||||
|
||||
this.messageBus.subscribe(this.get("dataSource"), report => {
|
||||
const formatDate = (date) => moment(date).format("YYYYMMDD");
|
||||
|
||||
// this check is done to avoid loading a chart after period has changed
|
||||
if (
|
||||
(this.get("startDate") && formatDate(report.start_date) === formatDate(this.get("startDate"))) &&
|
||||
(this.get("endDate") && formatDate(report.end_date) === formatDate(this.get("endDate")))
|
||||
) {
|
||||
this._setPropertiesFromReport(Report.create(report));
|
||||
this.set("isLoading", false);
|
||||
this.renderReport();
|
||||
} else {
|
||||
this._setPropertiesFromReport(Report.create(report));
|
||||
this.set("isLoading", false);
|
||||
this.renderReport();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
didInsertElement() {
|
||||
this._super();
|
||||
|
||||
Ember.run.later(this, function() {
|
||||
this.fetchReport();
|
||||
}, 500);
|
||||
},
|
||||
|
||||
didUpdateAttrs() {
|
||||
this._super();
|
||||
|
||||
this.fetchReport();
|
||||
},
|
||||
|
||||
renderReport() {},
|
||||
|
||||
@computed("dataSourceName")
|
||||
dataSource(dataSourceName) {
|
||||
return `/admin/reports/${dataSourceName}`;
|
||||
},
|
||||
|
||||
@computed("report")
|
||||
labels(report) {
|
||||
if (!report) return;
|
||||
return Ember.makeArray(report.data).map(r => r.x);
|
||||
},
|
||||
|
||||
@computed("report")
|
||||
values(report) {
|
||||
if (!report) return;
|
||||
return Ember.makeArray(report.data).map(r => r.y);
|
||||
},
|
||||
|
||||
_setPropertiesFromReport(report) {
|
||||
if (!this.element || this.isDestroying || this.isDestroyed) { return; }
|
||||
this.setProperties({ report });
|
||||
}
|
||||
});
|
|
@ -1,9 +1,6 @@
|
|||
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 ATTRIBUTES = [ "disk_space", "updated_at", "last_backup_taken_at" ];
|
||||
|
||||
const AdminDashboardNext = Discourse.Model.extend({});
|
||||
|
||||
|
@ -19,12 +16,7 @@ AdminDashboardNext.reopenClass({
|
|||
return ajax("/admin/dashboard-next.json").then(function(json) {
|
||||
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);
|
||||
model.set("reports", json.reports);
|
||||
|
||||
const attributes = {};
|
||||
ATTRIBUTES.forEach(a => attributes[a] = json[a]);
|
||||
|
|
|
@ -5,6 +5,8 @@ import { fillMissingDates } from 'discourse/lib/utilities';
|
|||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
|
||||
const Report = Discourse.Model.extend({
|
||||
average: false,
|
||||
|
||||
reportUrl: fmt("type", "/admin/reports/%@"),
|
||||
|
||||
valueAt(numDaysAgo) {
|
||||
|
@ -35,30 +37,43 @@ const Report = Discourse.Model.extend({
|
|||
}
|
||||
},
|
||||
|
||||
todayCount: function() { return this.valueAt(0); }.property("data"),
|
||||
yesterdayCount: function() { return this.valueAt(1); }.property("data"),
|
||||
sevenDaysAgoCount: function() { return this.valueAt(7); }.property("data"),
|
||||
thirtyDaysAgoCount: function() { return this.valueAt(30); }.property("data"),
|
||||
todayCount: function() { return this.valueAt(0); }.property("data", "average"),
|
||||
yesterdayCount: function() { return this.valueAt(1); }.property("data", "average"),
|
||||
sevenDaysAgoCount: function() { return this.valueAt(7); }.property("data", "average"),
|
||||
thirtyDaysAgoCount: function() { return this.valueAt(30); }.property("data", "average"),
|
||||
lastSevenDaysCount: function() {
|
||||
return this.averageCount(7, this.valueFor(1, 7));
|
||||
}.property("data", "average"),
|
||||
lastThirtyDaysCount: function() {
|
||||
return this.averageCount(30, this.valueFor(1, 30));
|
||||
}.property("data", "average"),
|
||||
|
||||
lastSevenDaysCount: function() { return this.valueFor(1, 7); }.property("data"),
|
||||
lastThirtyDaysCount: function() { return this.valueFor(1, 30); }.property("data"),
|
||||
averageCount(count, value) {
|
||||
return this.get("average") ? value / count : value;
|
||||
},
|
||||
|
||||
@computed('data')
|
||||
yesterdayTrend() {
|
||||
const yesterdayVal = this.valueAt(1);
|
||||
@computed('yesterdayCount')
|
||||
yesterdayTrend(yesterdayCount) {
|
||||
const yesterdayVal = yesterdayCount;
|
||||
const twoDaysAgoVal = this.valueAt(2);
|
||||
if (yesterdayVal > twoDaysAgoVal) {
|
||||
const change = ((yesterdayVal - twoDaysAgoVal) / yesterdayVal) * 100;
|
||||
|
||||
if (change > 50) {
|
||||
return "high-trending-up";
|
||||
} else if (change > 0) {
|
||||
return "trending-up";
|
||||
} else if (yesterdayVal < twoDaysAgoVal) {
|
||||
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";
|
||||
}
|
||||
},
|
||||
|
||||
@computed('data')
|
||||
sevenDayTrend() {
|
||||
const currentPeriod = this.valueFor(1, 7);
|
||||
@computed('lastSevenDaysCount')
|
||||
sevenDayTrend(lastSevenDaysCount) {
|
||||
const currentPeriod = lastSevenDaysCount;
|
||||
const prevPeriod = this.valueFor(8, 14);
|
||||
const change = ((currentPeriod - prevPeriod) / prevPeriod) * 100;
|
||||
|
||||
|
@ -75,17 +90,22 @@ const Report = Discourse.Model.extend({
|
|||
}
|
||||
},
|
||||
|
||||
@computed('prev30Days', 'data')
|
||||
thirtyDayTrend(prev30Days) {
|
||||
if (prev30Days) {
|
||||
const currentPeriod = this.valueFor(1, 30);
|
||||
if (currentPeriod > this.get("prev30Days")) {
|
||||
@computed('prev30Days', 'lastThirtyDaysCount')
|
||||
thirtyDayTrend(prev30Days, lastThirtyDaysCount) {
|
||||
const currentPeriod = lastThirtyDaysCount;
|
||||
const change = ((currentPeriod - prev30Days) / currentPeriod) * 100;
|
||||
|
||||
if (change > 50) {
|
||||
return "high-trending-up";
|
||||
} else if (change > 0) {
|
||||
return "trending-up";
|
||||
} else if (currentPeriod < prev30Days) {
|
||||
} else if (change === 0) {
|
||||
return "no-change";
|
||||
} else if (change < -50) {
|
||||
return "high-trending-down";
|
||||
} else if (change < 0) {
|
||||
return "trending-down";
|
||||
}
|
||||
}
|
||||
return "no-change";
|
||||
},
|
||||
|
||||
@computed('type')
|
||||
|
@ -126,19 +146,19 @@ const Report = Discourse.Model.extend({
|
|||
return title;
|
||||
},
|
||||
|
||||
@computed('data')
|
||||
yesterdayCountTitle() {
|
||||
return this.changeTitle(this.valueAt(1), this.valueAt(2), "two days ago");
|
||||
@computed('yesterdayCount')
|
||||
yesterdayCountTitle(yesterdayCount) {
|
||||
return this.changeTitle(yesterdayCount, this.valueAt(2), "two days ago");
|
||||
},
|
||||
|
||||
@computed('data')
|
||||
sevenDayCountTitle() {
|
||||
return this.changeTitle(this.valueFor(1, 7), this.valueFor(8, 14), "two weeks ago");
|
||||
@computed('lastSevenDaysCount')
|
||||
sevenDayCountTitle(lastSevenDaysCount) {
|
||||
return this.changeTitle(lastSevenDaysCount, this.valueFor(8, 14), "two weeks ago");
|
||||
},
|
||||
|
||||
@computed('prev30Days', 'data')
|
||||
thirtyDayCountTitle(prev30Days) {
|
||||
return this.changeTitle(this.valueFor(1, 30), prev30Days, "in the previous 30 day period");
|
||||
@computed('prev30Days', 'lastThirtyDaysCount')
|
||||
thirtyDayCountTitle(prev30Days, lastThirtyDaysCount) {
|
||||
return this.changeTitle(lastThirtyDaysCount, prev30Days, "in the previous 30 day period");
|
||||
},
|
||||
|
||||
@computed('data')
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
import loadScript from "discourse/lib/load-script";
|
||||
|
||||
export default Discourse.Route.extend({
|
||||
activate() {
|
||||
loadScript("/javascripts/Chart.min.js").then(() => {
|
||||
this.controllerFor('admin-dashboard-next').fetchDashboard();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{{#conditional-loading-spinner condition=isLoading}}
|
||||
{{#conditional-loading-section isLoading=isLoading title=report.title}}
|
||||
<div class="table-title">
|
||||
<h3>{{title}}</h3>
|
||||
<h3>{{report.title}}</h3>
|
||||
|
||||
{{#if help}}
|
||||
<a href="{{helpPage}}">{{i18n help}}</a>
|
||||
|
@ -18,11 +18,11 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{{#each dataset as |data|}}
|
||||
<td>{{number data}}</td>
|
||||
{{#each values as |value|}}
|
||||
<td>{{number value}}</td>
|
||||
{{/each}}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{/conditional-loading-spinner}}
|
||||
{{/conditional-loading-section}}
|
||||
|
|
|
@ -1,28 +1,31 @@
|
|||
{{#conditional-loading-spinner condition=isLoading}}
|
||||
{{#conditional-loading-section isLoading=isLoading title=report.title}}
|
||||
<div class="chart-title">
|
||||
<h3>{{title}}</h3>
|
||||
<h3 title={{report.description}}>
|
||||
{{report.title}}
|
||||
|
||||
{{#if description}}
|
||||
<span title={{description}}>
|
||||
{{#if report.description}}
|
||||
{{d-icon "question-circle"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</h3>
|
||||
|
||||
<div class="chart-container">
|
||||
{{#if oneDataPoint}}
|
||||
<span class="data-point">
|
||||
{{number chartData.lastObject.y}}
|
||||
</span>
|
||||
{{else}}
|
||||
<div class="chart-trend {{trend}}">
|
||||
<span>{{number prev30Days}}</span>
|
||||
<span title="{{report.thirtyDayCountTitle}}">
|
||||
{{number report.lastThirtyDaysCount}}
|
||||
</span>
|
||||
|
||||
{{#if trendIcon}}
|
||||
{{d-icon trendIcon}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
{{#if oneDataPoint}}
|
||||
<span class="data-point">
|
||||
{{number values.lastObject.y}}
|
||||
</span>
|
||||
{{else}}
|
||||
<canvas class="chart-canvas"></canvas>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/conditional-loading-spinner}}
|
||||
{{/conditional-loading-section}}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{{#conditional-loading-spinner condition=isLoading}}
|
||||
{{#conditional-loading-section isLoading=isLoading title=report.title}}
|
||||
<div class="table-title">
|
||||
<h3>{{title}}</h3>
|
||||
<h3>{{report.title}}</h3>
|
||||
|
||||
{{#if help}}
|
||||
<a href="{{helpPage}}">{{i18n help}}</a>
|
||||
|
@ -28,4 +28,4 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{/conditional-loading-spinner}}
|
||||
{{/conditional-loading-section}}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{{plugin-outlet name="admin-dashboard-top"}}
|
||||
{{plugin-outlet name="admin-dashboard-top"}}
|
||||
|
||||
<div class="community-health section">
|
||||
<div class="section-title">
|
||||
|
@ -9,22 +9,35 @@
|
|||
<div class="section-body">
|
||||
<div class="charts">
|
||||
{{dashboard-mini-chart
|
||||
model=model.reports.global_reports.signups
|
||||
dataSourceName="signups"
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
|
||||
{{dashboard-mini-chart
|
||||
model=model.reports.global_reports.topics
|
||||
dataSourceName="topics"
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
|
||||
{{dashboard-mini-chart
|
||||
model=model.reports.global_reports.new_contributors
|
||||
dataSourceName="new_contributors"
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
|
||||
{{dashboard-mini-chart
|
||||
dataSourceName="dau_by_mau"
|
||||
average=true
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
|
||||
{{dashboard-mini-chart
|
||||
dataSourceName="daily_engaged_users"
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
|
||||
{{dashboard-mini-chart
|
||||
dataSourceName="inactive_users"
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -32,6 +45,7 @@
|
|||
<div class="section-columns">
|
||||
<div class="section-column">
|
||||
<div class="dashboard-table">
|
||||
{{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.activity_metrics")}}
|
||||
<div class="table-title">
|
||||
<h3>{{i18n "admin.dashboard.activity_metrics"}}</h3>
|
||||
</div>
|
||||
|
@ -49,25 +63,24 @@
|
|||
</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}}
|
||||
{{#each reports as |report|}}
|
||||
{{admin-report-counts report=report}}
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{/conditional-loading-section}}
|
||||
</div>
|
||||
|
||||
{{dashboard-inline-table
|
||||
model=model.reports.user_reports.users_by_type
|
||||
lastRefreshedAt=lastRefreshedAt
|
||||
isLoading=isLoading}}
|
||||
dataSourceName="users_by_type"
|
||||
lastRefreshedAt=lastRefreshedAt}}
|
||||
|
||||
{{dashboard-inline-table
|
||||
model=model.reports.user_reports.users_by_trust_level
|
||||
lastRefreshedAt=lastRefreshedAt
|
||||
isLoading=isLoading}}
|
||||
dataSourceName="users_by_trust_level"
|
||||
lastRefreshedAt=lastRefreshedAt}}
|
||||
|
||||
{{#conditional-loading-spinner isLoading=isLoading}}
|
||||
{{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.backups")}}
|
||||
<div class="misc">
|
||||
<div class="durability">
|
||||
{{#if currentUser.admin}}
|
||||
|
@ -99,12 +112,11 @@
|
|||
{{i18n "admin.dashboard.whats_new_in_discourse"}}
|
||||
</a>
|
||||
</div>
|
||||
{{/conditional-loading-spinner}}
|
||||
{{/conditional-loading-section}}
|
||||
</div>
|
||||
|
||||
<div class="section-column">
|
||||
{{dashboard-table-trending-search
|
||||
model=global_reports_trending_search
|
||||
dataSourceName="trending_search"
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
|
||||
export default Ember.Component.extend({
|
||||
classNames: ["conditional-loading-section"],
|
||||
|
||||
classNameBindings: ["isLoading"],
|
||||
|
||||
isLoading: false,
|
||||
|
||||
@computed("title")
|
||||
computedTitle(title) {
|
||||
return title || I18n.t("conditional_loading_section.loading");
|
||||
}
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
{{#if isLoading}}
|
||||
<span class="title">{{computedTitle}}</span>
|
||||
<div class="spinner {{size}}"></div>
|
||||
{{else}}
|
||||
{{yield}}
|
||||
{{/if}}
|
|
@ -8,8 +8,24 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@include small-width {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-column {
|
||||
min-width: calc(50% - .5em);
|
||||
|
||||
@include small-width {
|
||||
min-width: 100%;
|
||||
|
||||
&:last-child {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-column:last-child {
|
||||
|
@ -19,6 +35,12 @@
|
|||
.section-column:first-child {
|
||||
margin-right: .5em;
|
||||
}
|
||||
|
||||
@include small-width {
|
||||
.section-column:last-child, .section-column:first-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
|
@ -43,8 +65,12 @@
|
|||
.dashboard-table {
|
||||
margin-bottom: 1em;
|
||||
|
||||
&.fixed table {
|
||||
table-layout: fixed;
|
||||
@include small-width {
|
||||
table {
|
||||
tbody tr td {
|
||||
font-size: $font-down-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-loading {
|
||||
|
@ -63,6 +89,7 @@
|
|||
|
||||
table {
|
||||
border: 1px solid $primary-low-mid;
|
||||
table-layout: fixed;
|
||||
|
||||
thead {
|
||||
tr {
|
||||
|
@ -70,7 +97,6 @@
|
|||
th {
|
||||
border: 1px solid $primary-low-mid;
|
||||
text-align: center;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
@ -80,6 +106,12 @@
|
|||
|
||||
tbody {
|
||||
tr {
|
||||
td:first-child {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
td {
|
||||
border: 1px solid $primary-low-mid;
|
||||
text-align: center;
|
||||
|
@ -120,22 +152,34 @@
|
|||
flex-wrap: wrap;
|
||||
|
||||
.dashboard-mini-chart {
|
||||
width: calc(100% * (1/3));
|
||||
max-width: calc(100% * (1/3));
|
||||
width: 100%;
|
||||
margin-bottom: 1em;
|
||||
flex-grow: 1;
|
||||
|
||||
@include small-width {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&.is-loading {
|
||||
height: 150px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.loading-container.visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.d-icon-question-circle {
|
||||
cursor: pointer;
|
||||
margin-left: .25em;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
h3 {
|
||||
margin: 1em 0;
|
||||
|
@ -174,21 +218,24 @@
|
|||
}
|
||||
}
|
||||
|
||||
@include small-width {
|
||||
.dashboard-mini-chart {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
padding: 0 1em;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.chart-trend {
|
||||
font-size: $font-up-5;
|
||||
position: absolute;
|
||||
right: 40px;
|
||||
top: 5px;
|
||||
font-size: $font-up-3;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
.conditional-loading-section {
|
||||
|
||||
&.is-loading {
|
||||
padding: 2em;
|
||||
margin: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $primary-very-low;
|
||||
flex-direction: column;
|
||||
|
||||
.title {
|
||||
font-size: $font-up-1;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,8 +7,8 @@ class Admin::ReportsController < Admin::AdminController
|
|||
|
||||
raise Discourse::NotFound unless report_type =~ /^[a-z0-9\_]+$/
|
||||
|
||||
start_date = (params[:start_date].present? ? Time.zone.parse(params[:start_date]) : 30.days.ago).beginning_of_day
|
||||
end_date = (params[:end_date].present? ? Time.zone.parse(params[:end_date]) : start_date + 30.days).end_of_day
|
||||
start_date = (params[:start_date].present? ? params[:start_date].to_date : 30.days.ago).beginning_of_day
|
||||
end_date = (params[:end_date].present? ? params[:end_date].to_date : start_date + 30.days).end_of_day
|
||||
|
||||
if params.has_key?(:category_id) && params[:category_id].to_i > 0
|
||||
category_id = params[:category_id].to_i
|
||||
|
@ -22,7 +22,12 @@ class Admin::ReportsController < Admin::AdminController
|
|||
group_id = nil
|
||||
end
|
||||
|
||||
report = Report.find(report_type, start_date: start_date, end_date: end_date, category_id: category_id, group_id: group_id)
|
||||
report = Report.find(report_type,
|
||||
start_date: start_date,
|
||||
end_date: end_date,
|
||||
category_id: category_id,
|
||||
group_id: group_id,
|
||||
async: params[:async])
|
||||
|
||||
raise Discourse::NotFound if report.blank?
|
||||
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
require_dependency 'report'
|
||||
|
||||
module Jobs
|
||||
class RetrieveReport < Jobs::Base
|
||||
sidekiq_options retry: false
|
||||
|
||||
def execute(args)
|
||||
raise Discourse::InvalidParameters.new(:report_type) if !args["report_type"]
|
||||
|
||||
type = args.delete("report_type")
|
||||
report = Report.new(type)
|
||||
report.start_date = args["start_date"].to_date if args["start_date"]
|
||||
report.end_date = args["end_date"].to_date if args["end_date"]
|
||||
report.category_id = args["category_id"] if args["category_id"]
|
||||
report.group_id = args["group_id"] if args["group_id"]
|
||||
|
||||
Report.send("report_#{type}", report)
|
||||
|
||||
Discourse.cache.write(Report.cache_key(report), report.as_json, force: true, expires_in: 30.minutes)
|
||||
|
||||
MessageBus.publish("/admin/reports/#{type}", report.as_json, user_ids: User.staff.pluck(:id))
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,17 +1,7 @@
|
|||
class AdminDashboardNextData
|
||||
include StatsCacheable
|
||||
|
||||
GLOBAL_REPORTS ||= [
|
||||
'signups',
|
||||
'topics',
|
||||
'trending_search',
|
||||
'new_contributors'
|
||||
]
|
||||
|
||||
USER_REPORTS ||= [
|
||||
'users_by_trust_level',
|
||||
'users_by_type'
|
||||
]
|
||||
REPORTS = [ "visits", "posts", "time_to_first_response", "likes", "flags" ]
|
||||
|
||||
def initialize(opts = {})
|
||||
@opts = opts
|
||||
|
@ -27,8 +17,7 @@ class AdminDashboardNextData
|
|||
|
||||
def as_json(_options = nil)
|
||||
@json ||= {
|
||||
global_reports: AdminDashboardNextData.reports(GLOBAL_REPORTS),
|
||||
user_reports: AdminDashboardNextData.reports(USER_REPORTS),
|
||||
reports: AdminDashboardNextData.reports(REPORTS),
|
||||
last_backup_taken_at: last_backup_taken_at,
|
||||
updated_at: Time.zone.now.as_json
|
||||
}
|
||||
|
|
|
@ -25,21 +25,25 @@ module DateGroupable extend ActiveSupport::Concern
|
|||
.order("date_trunc('#{aggregation_unit}', #{column})::DATE")
|
||||
end
|
||||
|
||||
def smart_group_by_date(column, start_date, end_date)
|
||||
def aggregation_unit_for_period(start_date, end_date)
|
||||
days = (start_date.to_date..end_date.to_date).count
|
||||
|
||||
case
|
||||
when days <= 40
|
||||
aggregation_unit = :day
|
||||
return :day
|
||||
when days <= 210 # 30 weeks
|
||||
aggregation_unit = :week
|
||||
return :week
|
||||
when days <= 550 # ~18 months
|
||||
aggregation_unit = :month
|
||||
return :month
|
||||
when days <= 1461 # ~4 years
|
||||
aggregation_unit = :quarter
|
||||
return :quarter
|
||||
else
|
||||
aggregation_unit = :year
|
||||
return :year
|
||||
end
|
||||
end
|
||||
|
||||
def smart_group_by_date(column, start_date, end_date)
|
||||
aggregation_unit = aggregation_unit_for_period(start_date, end_date)
|
||||
|
||||
where("#{column} BETWEEN ? AND ?", start_date, end_date)
|
||||
.group_by_unit(aggregation_unit, column)
|
||||
|
|
|
@ -2,7 +2,8 @@ require_dependency 'topic_subtype'
|
|||
|
||||
class Report
|
||||
|
||||
attr_accessor :type, :data, :total, :prev30Days, :start_date, :end_date, :category_id, :group_id, :labels
|
||||
attr_accessor :type, :data, :total, :prev30Days, :start_date,
|
||||
:end_date, :category_id, :group_id, :labels, :async
|
||||
|
||||
def self.default_days
|
||||
30
|
||||
|
@ -14,6 +15,16 @@ class Report
|
|||
@end_date ||= Time.zone.now.end_of_day
|
||||
end
|
||||
|
||||
def self.cache_key(report)
|
||||
"reports:#{report.type}:#{report.start_date.strftime("%Y%m%d")}:#{report.end_date.strftime("%Y%m%d")}"
|
||||
end
|
||||
|
||||
def self.clear_cache
|
||||
Discourse.cache.keys("reports:*").each do |key|
|
||||
Discourse.cache.redis.del(key)
|
||||
end
|
||||
end
|
||||
|
||||
def as_json(options = nil)
|
||||
{
|
||||
type: type,
|
||||
|
@ -45,14 +56,22 @@ class Report
|
|||
|
||||
# Load the report
|
||||
report = Report.new(type)
|
||||
report.start_date = opts[:start_date] if opts[:start_date]
|
||||
report.end_date = opts[:end_date] if opts[:end_date]
|
||||
report.start_date = opts[:start_date].to_date if opts[:start_date]
|
||||
report.end_date = opts[:end_date].to_date if opts[:end_date]
|
||||
report.category_id = opts[:category_id] if opts[:category_id]
|
||||
report.group_id = opts[:group_id] if opts[:group_id]
|
||||
report.async = opts[:async] || false
|
||||
report_method = :"report_#{type}"
|
||||
|
||||
if respond_to?(report_method)
|
||||
cached_report = Discourse.cache.read(cache_key(report))
|
||||
if cached_report
|
||||
return cached_report
|
||||
elsif report.async
|
||||
Jobs.enqueue(:retrieve_report, opts.merge(report_type: type))
|
||||
else
|
||||
send(report_method, report)
|
||||
end
|
||||
elsif type =~ /_reqs$/
|
||||
req_report(report, type.split(/_reqs$/)[0].to_sym)
|
||||
else
|
||||
|
@ -73,7 +92,7 @@ class Report
|
|||
end
|
||||
|
||||
report.data = []
|
||||
data.where('date >= ? AND date <= ?', report.start_date.to_date, report.end_date.to_date)
|
||||
data.where('date >= ? AND date <= ?', report.start_date, report.end_date)
|
||||
.order(date: :asc)
|
||||
.group(:date)
|
||||
.sum(:count)
|
||||
|
@ -85,7 +104,7 @@ class Report
|
|||
|
||||
report.prev30Days = data.where(
|
||||
'date >= ? AND date < ?',
|
||||
(report.start_date - 31.days).to_date, report.start_date.to_date
|
||||
(report.start_date - 31.days), report.start_date
|
||||
).sum(:count)
|
||||
end
|
||||
|
||||
|
@ -110,13 +129,73 @@ class Report
|
|||
end
|
||||
end
|
||||
|
||||
def self.report_inactive_users(report)
|
||||
report.data = []
|
||||
|
||||
data = User.real.count_by_inactivity(report.start_date, report.end_date)
|
||||
|
||||
data.each do |data_point|
|
||||
report.data << { x: data_point["date_trunc"], y: data_point["count"] }
|
||||
end
|
||||
end
|
||||
|
||||
def self.report_new_contributors(report)
|
||||
report_about report, User.real, :count_by_first_post
|
||||
report.data = []
|
||||
|
||||
data = User.real.count_by_first_post(report.start_date, report.end_date)
|
||||
|
||||
prev30DaysData = User.real.count_by_first_post(report.start_date - 30.days, report.start_date)
|
||||
report.prev30Days = prev30DaysData.sum { |k, v| v }
|
||||
|
||||
report.total = User.real.count_by_first_post
|
||||
|
||||
data.each do |key, value|
|
||||
report.data << { x: key, y: value }
|
||||
end
|
||||
end
|
||||
|
||||
def self.report_daily_engaged_users(report)
|
||||
report.data = []
|
||||
|
||||
data = UserAction.count_daily_engaged_users(report.start_date, report.end_date)
|
||||
prev30DaysData = UserAction.count_daily_engaged_users(report.start_date - 30.days, report.start_date)
|
||||
|
||||
report.total = UserAction.count_daily_engaged_users
|
||||
|
||||
report.prev30Days = prev30DaysData.sum { |k, v| v }
|
||||
|
||||
data.each do |key, value|
|
||||
report.data << { x: key, y: value }
|
||||
end
|
||||
end
|
||||
|
||||
def self.report_dau_by_mau(report)
|
||||
data_points = UserVisit.count_by_active_users(report.start_date, report.end_date)
|
||||
|
||||
report.data = []
|
||||
|
||||
compute_dau_by_mau = Proc.new { |data_point|
|
||||
if data_point["mau"] == 0
|
||||
0
|
||||
else
|
||||
((data_point["dau"].to_f / data_point["mau"].to_f) * 100).ceil
|
||||
end
|
||||
}
|
||||
|
||||
data_points.each do |data_point|
|
||||
report.data << { x: data_point["date"], y: compute_dau_by_mau.call(data_point) }
|
||||
end
|
||||
|
||||
prev_data_points = UserVisit.count_by_active_users(report.start_date - 30.days, report.start_date)
|
||||
if !prev_data_points.empty?
|
||||
sum = prev_data_points.sum { |data_point| compute_dau_by_mau.call(data_point) }
|
||||
report.prev30Days = sum / prev_data_points.count
|
||||
end
|
||||
end
|
||||
|
||||
def self.report_profile_views(report)
|
||||
start_date = report.start_date.to_date
|
||||
end_date = report.end_date.to_date
|
||||
start_date = report.start_date
|
||||
end_date = report.end_date
|
||||
basic_report_about report, UserProfileView, :profile_views_by_day, start_date, end_date, report.group_id
|
||||
|
||||
report.total = UserProfile.sum(:views)
|
||||
|
|
|
@ -829,8 +829,49 @@ class User < ActiveRecord::Base
|
|||
(tl_badge + other_badges).take(limit)
|
||||
end
|
||||
|
||||
def self.count_by_signup_date(start_date, end_date, group_id = nil)
|
||||
result = smart_group_by_date("users.created_at", start_date, end_date)
|
||||
def self.count_by_inactivity(start_date, end_date)
|
||||
sql = <<SQL
|
||||
SELECT
|
||||
date_trunc('day', d.generated_date) :: DATE,
|
||||
COUNT(u.id)
|
||||
FROM (SELECT generate_series('#{start_date}', '#{end_date}', '1 day' :: INTERVAL) :: DATE AS generated_date) d
|
||||
JOIN users u ON (u.created_at :: DATE <= d.generated_date)
|
||||
WHERE u.active AND
|
||||
u.id > 0 AND
|
||||
NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM user_custom_fields ucf
|
||||
WHERE
|
||||
ucf.user_id = u.id AND
|
||||
ucf.name = 'master_id' AND
|
||||
ucf.value :: int > 0
|
||||
) AND
|
||||
NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM user_visits v
|
||||
WHERE v.visited_at BETWEEN (d.generated_date - INTERVAL '89 days') :: DATE AND d.generated_date
|
||||
AND v.user_id = u.id
|
||||
) AND
|
||||
NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM incoming_emails e
|
||||
WHERE e.user_id = u.id AND
|
||||
e.post_id IS NOT NULL AND
|
||||
e.created_at BETWEEN (d.generated_date - INTERVAL '89 days') :: DATE AND d.generated_date
|
||||
)
|
||||
GROUP BY date_trunc('day', d.generated_date) :: DATE
|
||||
ORDER BY date_trunc('day', d.generated_date) :: DATE
|
||||
SQL
|
||||
|
||||
exec_sql(sql).to_a
|
||||
end
|
||||
|
||||
def self.count_by_signup_date(start_date = nil, end_date = nil, group_id = nil)
|
||||
result = self
|
||||
|
||||
if start_date && end_date
|
||||
result = result.smart_group_by_date("users.created_at", start_date, end_date)
|
||||
end
|
||||
|
||||
if group_id
|
||||
result = result.joins("INNER JOIN group_users ON group_users.user_id = users.id")
|
||||
|
@ -840,10 +881,14 @@ class User < ActiveRecord::Base
|
|||
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
|
||||
def self.count_by_first_post(start_date = nil, end_date = nil)
|
||||
result = joins('INNER JOIN user_stats AS us ON us.user_id = users.id')
|
||||
|
||||
if start_date && end_date
|
||||
result = result.smart_group_by_date("us.first_post_created_at", start_date, end_date)
|
||||
end
|
||||
|
||||
result.count
|
||||
end
|
||||
|
||||
def secure_category_ids
|
||||
|
|
|
@ -121,11 +121,16 @@ SQL
|
|||
|
||||
end
|
||||
|
||||
def self.count_daily_engaged_users(start_date, end_date)
|
||||
select(:user_id).distinct
|
||||
def self.count_daily_engaged_users(start_date = nil, end_date = nil)
|
||||
result = select(:user_id)
|
||||
.distinct
|
||||
.where(action_type: [LIKE, NEW_TOPIC, REPLY, NEW_PRIVATE_MESSAGE])
|
||||
.smart_group_by_date(:created_at, start_date, end_date)
|
||||
.count
|
||||
|
||||
if start_date && end_date
|
||||
result = result.smart_group_by_date(:created_at, start_date, end_date)
|
||||
end
|
||||
|
||||
result.count
|
||||
end
|
||||
|
||||
def self.stream_item(action_id, guardian)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
class UserVisit < ActiveRecord::Base
|
||||
include DateGroupable
|
||||
|
||||
def self.counts_by_day_query(start_date, end_date, group_id = nil)
|
||||
result = where('visited_at >= ? and visited_at <= ?', start_date.to_date, end_date.to_date)
|
||||
|
@ -11,6 +12,30 @@ class UserVisit < ActiveRecord::Base
|
|||
result.group(:visited_at).order(:visited_at)
|
||||
end
|
||||
|
||||
def self.count_by_active_users(start_date, end_date)
|
||||
aggregation_unit = aggregation_unit_for_period(start_date, end_date)
|
||||
|
||||
sql = <<SQL
|
||||
WITH dau AS (
|
||||
SELECT date_trunc('#{aggregation_unit}', user_visits.visited_at)::DATE AS date,
|
||||
count(distinct user_visits.user_id) AS dau
|
||||
FROM user_visits
|
||||
WHERE user_visits.visited_at::DATE BETWEEN '#{start_date}' AND '#{end_date}'
|
||||
GROUP BY date_trunc('#{aggregation_unit}', user_visits.visited_at)::DATE
|
||||
ORDER BY date_trunc('#{aggregation_unit}', user_visits.visited_at)::DATE
|
||||
)
|
||||
|
||||
SELECT date, dau,
|
||||
(SELECT count(distinct user_visits.user_id)
|
||||
FROM user_visits
|
||||
WHERE user_visits.visited_at::DATE BETWEEN dau.date - 29 AND dau.date
|
||||
) AS mau
|
||||
FROM dau
|
||||
SQL
|
||||
|
||||
UserVisit.exec_sql(sql).to_a
|
||||
end
|
||||
|
||||
# A count of visits in a date range by day
|
||||
def self.by_day(start_date, end_date, group_id = nil)
|
||||
counts_by_day_query(start_date, end_date, group_id).count
|
||||
|
|
|
@ -1228,6 +1228,9 @@ en:
|
|||
ctrl: 'Ctrl'
|
||||
alt: 'Alt'
|
||||
|
||||
conditional_loading_section:
|
||||
loading: Loading...
|
||||
|
||||
select_kit:
|
||||
default_header_text: Select...
|
||||
no_content: No matches found
|
||||
|
|
|
@ -840,12 +840,27 @@ en:
|
|||
title: "New Users"
|
||||
xaxis: "Day"
|
||||
yaxis: "Number of new users"
|
||||
description: "Users created for this period"
|
||||
description: "New registrations for this period"
|
||||
new_contributors:
|
||||
title: "New Contributors"
|
||||
xaxis: "Day"
|
||||
yaxis: "Number of new contributors"
|
||||
description: "Number of users who made their first contribution"
|
||||
description: "Number of users who made their first post during this period"
|
||||
inactive_users:
|
||||
title: "Inactive Users"
|
||||
xaxis: "Day"
|
||||
yaxis: "Number of new inactive users"
|
||||
description: "Number of users that haven’t logged on for the last 3 months"
|
||||
dau_by_mau:
|
||||
title: "DAU/MAU"
|
||||
xaxis: "Day"
|
||||
yaxis: "DAU/MAY"
|
||||
description: "Daily Active Users / Monthly Active Users"
|
||||
daily_engaged_users:
|
||||
title: "Daily Engaged Users"
|
||||
xaxis: "Day"
|
||||
yaxis: "Engaged Users"
|
||||
description: "Number of users that have liked or posted in the last day"
|
||||
profile_views:
|
||||
title: "User Profile Views"
|
||||
xaxis: "Day"
|
||||
|
@ -854,7 +869,7 @@ en:
|
|||
title: "Topics"
|
||||
xaxis: "Day"
|
||||
yaxis: "Number of new topics"
|
||||
description: "Topics created for this period"
|
||||
description: "New topics created during this period"
|
||||
posts:
|
||||
title: "Posts"
|
||||
xaxis: "Day"
|
||||
|
|
|
@ -21,8 +21,12 @@ class Cache < ActiveSupport::Cache::Store
|
|||
redis.reconnect
|
||||
end
|
||||
|
||||
def keys(pattern = "*")
|
||||
redis.keys("#{@namespace}:#{pattern}")
|
||||
end
|
||||
|
||||
def clear
|
||||
redis.keys("#{@namespace}:*").each do |k|
|
||||
keys.each do |k|
|
||||
redis.del(k)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -73,4 +73,11 @@ describe Cache do
|
|||
end
|
||||
expect(r).to eq("bill")
|
||||
end
|
||||
|
||||
it "can fetch keys with pattern" do
|
||||
cache.write "users:admins", "jeff"
|
||||
cache.write "users:moderators", "bob"
|
||||
|
||||
expect(cache.keys("users:*").count).to eq(2)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -342,6 +342,76 @@ describe Report do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'DAU/MAU report' do
|
||||
let(:report) { Report.find('dau_by_mau') }
|
||||
|
||||
context "no activity" do
|
||||
it "returns an empty report" do
|
||||
expect(report.data).to be_blank
|
||||
end
|
||||
end
|
||||
|
||||
context "with different users/visits" do
|
||||
before do
|
||||
freeze_time
|
||||
|
||||
arpit = Fabricate(:user)
|
||||
arpit.user_visits.create(visited_at: 1.day.ago)
|
||||
|
||||
sam = Fabricate(:user)
|
||||
sam.user_visits.create(visited_at: 2.days.ago)
|
||||
|
||||
robin = Fabricate(:user)
|
||||
robin.user_visits.create(visited_at: 2.days.ago)
|
||||
|
||||
michael = Fabricate(:user)
|
||||
michael.user_visits.create(visited_at: 35.days.ago)
|
||||
|
||||
gerhard = Fabricate(:user)
|
||||
gerhard.user_visits.create(visited_at: 45.days.ago)
|
||||
end
|
||||
|
||||
it "returns a report with data" do
|
||||
expect(report.data.first[:y]).to eq(100)
|
||||
expect(report.data.last[:y]).to eq(34)
|
||||
expect(report.prev30Days).to eq(75)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Daily engaged users' do
|
||||
let(:report) { Report.find('daily_engaged_users') }
|
||||
|
||||
context "no activity" do
|
||||
it "returns an empty report" do
|
||||
expect(report.data).to be_blank
|
||||
end
|
||||
end
|
||||
|
||||
context "with different activities" do
|
||||
before do
|
||||
freeze_time
|
||||
|
||||
UserActionCreator.enable
|
||||
|
||||
arpit = Fabricate(:user)
|
||||
sam = Fabricate(:user)
|
||||
|
||||
jeff = Fabricate(:user, created_at: 1.day.ago)
|
||||
topic = Fabricate(:topic, user: jeff, created_at: 1.day.ago)
|
||||
post = Fabricate(:post, topic: topic, user: jeff, created_at: 1.day.ago)
|
||||
|
||||
PostAction.act(arpit, post, PostActionType.types[:like])
|
||||
PostAction.act(sam, post, PostActionType.types[:like])
|
||||
end
|
||||
|
||||
it "returns a report with data" do
|
||||
expect(report.data.first[:y]).to eq(1)
|
||||
expect(report.data.last[:y]).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'posts counts' do
|
||||
it "only counts regular posts" do
|
||||
post = Fabricate(:post)
|
||||
|
|
|
@ -7,7 +7,7 @@ acceptance("Dashboard Next", {
|
|||
loggedIn: true
|
||||
});
|
||||
|
||||
QUnit.test("Vist dashboard next page", assert => {
|
||||
QUnit.test("Visit dashboard next page", assert => {
|
||||
visit("/admin/dashboard-next");
|
||||
|
||||
andThen(() => {
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
export default {
|
||||
"/admin/reports/daily_engaged_users": {
|
||||
"report": {
|
||||
"type": "daily_engaged_users",
|
||||
"title": "Daily Engaged Users",
|
||||
"xaxis": "Day",
|
||||
"yaxis": "Engaged Users",
|
||||
"description": "Number of users that have liked or posted in the last day",
|
||||
"data": null,
|
||||
"total": null,
|
||||
"start_date": "2018-04-03",
|
||||
"end_date": "2018-05-03",
|
||||
"category_id": null,
|
||||
"group_id": null,
|
||||
"prev30Days": null,
|
||||
"labels": null
|
||||
}
|
||||
}
|
||||
};
|
|
@ -1,139 +1,6 @@
|
|||
export default {
|
||||
"/admin/dashboard-next.json": {
|
||||
"global_reports": [{
|
||||
"type": "signups",
|
||||
"title": "New Users",
|
||||
"xaxis": "Day",
|
||||
"yaxis": "Number of new users",
|
||||
"data": [{
|
||||
"x": "2018-04-11",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-12",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-13",
|
||||
"y": 58
|
||||
}, {
|
||||
"x": "2018-04-15",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-16",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-17",
|
||||
"y": 19
|
||||
}, {
|
||||
"x": "2018-04-19",
|
||||
"y": 19
|
||||
}],
|
||||
"total": 136,
|
||||
"start_date": "2018-03-26T00:00:00.000Z",
|
||||
"end_date": "2018-04-25T23:59:59.999Z",
|
||||
"category_id": null,
|
||||
"group_id": null,
|
||||
"prev30Days": 0,
|
||||
"labels": null
|
||||
}, {
|
||||
"type": "topics",
|
||||
"title": "Topics",
|
||||
"xaxis": "Day",
|
||||
"yaxis": "Number of new topics",
|
||||
"data": [{
|
||||
"x": "2018-04-11",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-12",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-13",
|
||||
"y": 60
|
||||
}, {
|
||||
"x": "2018-04-15",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-16",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-17",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-19",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-20",
|
||||
"y": 1
|
||||
}],
|
||||
"total": 121,
|
||||
"start_date": "2018-03-26T00:00:00.000Z",
|
||||
"end_date": "2018-04-25T23:59:59.999Z",
|
||||
"category_id": null,
|
||||
"group_id": null,
|
||||
"prev30Days": 0,
|
||||
"labels": null
|
||||
}, {
|
||||
"type": "trending_search",
|
||||
"title": "Trending search",
|
||||
"xaxis": "translation missing: en.reports.trending_search.xaxis",
|
||||
"yaxis": "translation missing: en.reports.trending_search.yaxis",
|
||||
"data": [
|
||||
["lon", 3, 1],
|
||||
["pub", 1, 1],
|
||||
["something", 1, 1]
|
||||
],
|
||||
"total": null,
|
||||
"start_date": "2018-03-26T00:00:00.000Z",
|
||||
"end_date": "2018-04-25T23:59:59.999Z",
|
||||
"category_id": null,
|
||||
"group_id": null,
|
||||
"prev30Days": null,
|
||||
"labels": ["Term", "Searches", "Unique"]
|
||||
}],
|
||||
"user_reports": [{
|
||||
"type": "users_by_trust_level",
|
||||
"title": "Users per Trust Level",
|
||||
"xaxis": "Trust Level",
|
||||
"yaxis": "Number of Users",
|
||||
"data": [{
|
||||
"x": 0,
|
||||
"y": 132
|
||||
}, {
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}, {
|
||||
"x": 3,
|
||||
"y": 1
|
||||
}, {
|
||||
"x": 2,
|
||||
"y": 1
|
||||
}, {
|
||||
"x": 4,
|
||||
"y": 1
|
||||
}],
|
||||
"total": null,
|
||||
"start_date": "2018-03-26T00:00:00.000Z",
|
||||
"end_date": "2018-04-25T23:59:59.999Z",
|
||||
"category_id": null,
|
||||
"group_id": null,
|
||||
"prev30Days": null,
|
||||
"labels": null
|
||||
}, {
|
||||
"type": "users_by_type",
|
||||
"title": "Users per Type",
|
||||
"xaxis": "Type",
|
||||
"yaxis": "Number of Users",
|
||||
"data": [{
|
||||
"x": "Admin",
|
||||
"y": 1
|
||||
}],
|
||||
"total": null,
|
||||
"start_date": "2018-03-26T00:00:00.000Z",
|
||||
"end_date": "2018-04-25T23:59:59.999Z",
|
||||
"category_id": null,
|
||||
"group_id": null,
|
||||
"prev30Days": null,
|
||||
"labels": null
|
||||
}],
|
||||
"reports": [],
|
||||
"last_backup_taken_at": "2018-04-13T12:51:19.926Z",
|
||||
"updated_at": "2018-04-25T08:06:11.292Z",
|
||||
"disk_space": {
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
export default {
|
||||
"/admin/reports/dau_by_mau": {
|
||||
"report": {
|
||||
"type": "dau_by_mau",
|
||||
"title": "DAU/MAU",
|
||||
"xaxis": "Day",
|
||||
"yaxis": "DAU/MAY",
|
||||
"description": "Percentage of daily active users on monthly active users",
|
||||
"data": null,
|
||||
"total": null,
|
||||
"start_date": "2018-01-26T00:00:00.000Z",
|
||||
"end_date": "2018-04-27T23:59:59.999Z",
|
||||
"category_id": null,
|
||||
"group_id": null,
|
||||
"prev30Days": 46,
|
||||
"labels": null
|
||||
}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
export default {
|
||||
"/admin/reports/inactive_users": {
|
||||
"report": {
|
||||
"type": "inactive_users",
|
||||
"title": "Inactive Users",
|
||||
"xaxis": "Day",
|
||||
"yaxis": "Number of new inactive users",
|
||||
"description": "Number of users that haven’t logged on for the last 3 months",
|
||||
"data": null,
|
||||
"total": null,
|
||||
"start_date": "2018-04-26",
|
||||
"end_date": "2018-05-03",
|
||||
"category_id": null,
|
||||
"group_id": null,
|
||||
"prev30Days": null,
|
||||
"labels": null
|
||||
}
|
||||
}
|
||||
};
|
|
@ -5,38 +5,8 @@ export default {
|
|||
"title": "Topics",
|
||||
"xaxis": "Day",
|
||||
"yaxis": "Number of new topics",
|
||||
"data": [{
|
||||
"x": "2018-04-11",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-12",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-13",
|
||||
"y": 60
|
||||
}, {
|
||||
"x": "2018-04-14",
|
||||
"y": 60
|
||||
}, {
|
||||
"x": "2018-04-15",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-16",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-17",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-19",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-18",
|
||||
"y": 10
|
||||
}, {
|
||||
"x": "2018-04-20",
|
||||
"y": 1
|
||||
}],
|
||||
"total": 121,
|
||||
"data": null,
|
||||
"total": null,
|
||||
"start_date": "2018-03-26T00:00:00.000Z",
|
||||
"end_date": "2018-04-25T23:59:59.999Z",
|
||||
"category_id": null,
|
||||
|
|
|
@ -5,11 +5,7 @@ export default {
|
|||
"title": "Trending search",
|
||||
"xaxis": "",
|
||||
"yaxis": "",
|
||||
"data": [
|
||||
["lon", 3, 1],
|
||||
["pub", 1, 1],
|
||||
["something", 1, 1]
|
||||
],
|
||||
"data": null,
|
||||
"total": null,
|
||||
"start_date": "2018-03-26T00:00:00.000Z",
|
||||
"end_date": "2018-04-25T23:59:59.999Z",
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
export default {
|
||||
"/admin/reports/users_by_trust_level": {
|
||||
"report": {
|
||||
"type": "users_by_trust_level",
|
||||
"title": "Users per Trust Level",
|
||||
"xaxis": "Trust Level",
|
||||
"yaxis": "Number of Users",
|
||||
"description": "translation missing: en.reports.users_by_trust_level.description",
|
||||
"data": null,
|
||||
"total": null,
|
||||
"start_date": "2018-03-30T00:00:00.000Z",
|
||||
"end_date": "2018-04-29T23:59:59.999Z",
|
||||
"category_id": null,
|
||||
"group_id": null,
|
||||
"prev30Days": null,
|
||||
"labels": null
|
||||
}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
export default {
|
||||
"/admin/reports/users_by_type": {
|
||||
"report": {
|
||||
"type": "users_by_type",
|
||||
"title": "Users per Type",
|
||||
"xaxis": "Type",
|
||||
"yaxis": "Number of Users",
|
||||
"description": "translation missing: en.reports.users_by_type.description",
|
||||
"data": null,
|
||||
"total": null,
|
||||
"start_date": "2018-03-30T00:00:00.000Z",
|
||||
"end_date": "2018-04-29T23:59:59.999Z",
|
||||
"category_id": null,
|
||||
"group_id": null,
|
||||
"prev30Days": null,
|
||||
"labels": null
|
||||
}
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue