dashboard next: caching, mobile support and new charts

This commit is contained in:
Joffrey JAFFEUX 2018-05-03 15:41:41 +02:00 committed by GitHub
parent c718c59b5d
commit 980972182f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 819 additions and 545 deletions

View File

@ -1,65 +1,23 @@
import { ajax } from 'discourse/lib/ajax'; 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"], classNames: ["dashboard-table", "dashboard-inline-table", "fixed"],
isLoading: true,
classNameBindings: ["isLoading"],
total: null,
labels: null,
title: null,
chartData: null,
isLoading: false,
help: null, help: null,
helpPage: 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); this.set("isLoading", true);
ajax(this.get("dataSource")) ajax(this.get("dataSource"))
.then((response) => { .then((response) => {
this._setPropertiesFromModel(response.report); this._setPropertiesFromReport(Report.create(response.report));
}).finally(() => { }).finally(() => {
this.set("isLoading", false); 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
});
} }
}); });

View File

@ -1,43 +1,23 @@
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import computed from "ember-addons/ember-computed-decorators"; 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 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"], classNames: ["dashboard-mini-chart"],
classNameBindings: ["thirtyDayTrend", "oneDataPoint"],
classNameBindings: ["trend", "oneDataPoint", "isLoading"], isLoading: true,
thirtyDayTrend: Ember.computed.alias("report.thirtyDayTrend"),
isLoading: false,
total: null,
trend: null,
title: null,
oneDataPoint: false, oneDataPoint: false,
backgroundColor: "rgba(200,220,240,0.3)", backgroundColor: "rgba(200,220,240,0.3)",
borderColor: "#08C", borderColor: "#08C",
average: false,
didInsertElement() { willDestroyEelement() {
this._super(); this._super();
if (this.get("model")) { this.messageBus.unsubscribe(this.get("dataSource"));
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();
}
});
}, },
@computed("dataSourceName") @computed("dataSourceName")
@ -47,9 +27,9 @@ export default Ember.Component.extend({
} }
}, },
@computed("trend") @computed("thirtyDayTrend")
trendIcon(trend) { trendIcon(thirtyDayTrend) {
switch (trend) { switch (thirtyDayTrend) {
case "trending-up": case "trending-up":
return "angle-up"; return "angle-up";
case "trending-down": case "trending-down":
@ -63,79 +43,73 @@ export default Ember.Component.extend({
} }
}, },
_fetchReport() { fetchReport() {
if (this.get("isLoading")) return;
this.set("isLoading", true); this.set("isLoading", true);
let payload = { let payload = {
data: {} data: { async: true }
}; };
if (this.get("startDate")) { 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")) { 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) ajax(this.get("dataSource"), payload)
.then((response) => { .then((response) => {
this._setPropertiesFromModel(Report.create(response.report)); // if (!Ember.isEmpty(response.report.data)) {
this._setPropertiesFromReport(Report.create(response.report));
// }
}) })
.finally(() => { .finally(() => {
this.set("isLoading", false); if (this.get("oneDataPoint")) {
this.set("isLoading", false);
return;
}
Ember.run.schedule("afterRender", () => { if (!Ember.isEmpty(this.get("report.data"))) {
if (!this.get("oneDataPoint")) { this.set("isLoading", false);
this._drawChart(); this.renderReport();
} }
});
}); });
}, },
_drawChart() { renderReport() {
const $chartCanvas = this.$(".chart-canvas"); if (!this.element || this.isDestroying || this.isDestroyed) { return; }
if (!$chartCanvas.length) return; if (this.get("oneDataPoint")) return;
const context = $chartCanvas[0].getContext("2d"); Ember.run.schedule("afterRender", () => {
const $chartCanvas = this.$(".chart-canvas");
const data = { if (!$chartCanvas.length) return;
labels: this.get("labels"), const context = $chartCanvas[0].getContext("2d");
datasets: [{
data: Ember.makeArray(this.get("values")),
backgroundColor: this.get("backgroundColor"),
borderColor: this.get("borderColor")
}]
};
this._chart = new window.Chart(context, this._buildChartConfig(data)); const data = {
}, labels: this.get("labels"),
datasets: [{
data: Ember.makeArray(this.get("values")),
backgroundColor: this.get("backgroundColor"),
borderColor: this.get("borderColor")
}]
};
_setPropertiesFromModel(report) { this._chart = new window.Chart(context, this._buildChartConfig(data));
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) { _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 { return {
type: "line", type: "line",
data, data,
@ -144,7 +118,6 @@ export default Ember.Component.extend({
display: false display: false
}, },
responsive: true, responsive: true,
maintainAspectRatio: false,
layout: { layout: {
padding: { padding: {
left: 0, left: 0,
@ -156,11 +129,7 @@ export default Ember.Component.extend({
scales: { scales: {
yAxes: [{ yAxes: [{
display: true, display: true,
ticks: { ticks: { callback: (label) => number(label) }
suggestedMin: 0,
stepSize,
suggestedMax: max + stepSize
}
}], }],
xAxes: [{ xAxes: [{
display: true, display: true,

View File

@ -1,17 +1,8 @@
import DashboardTable from "admin/components/dashboard-table"; 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", layoutName: "admin/templates/components/dashboard-table",
classNames: ["dashboard-table", "dashboard-table-trending-search"], 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])];
})
};
},
}); });

View File

@ -1,83 +1,50 @@
import { ajax } from 'discourse/lib/ajax'; 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";
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"], classNames: ["dashboard-table"],
classNameBindings: ["isLoading"],
total: null,
labels: null,
title: null,
chartData: null,
isLoading: false,
help: null, help: null,
helpPage: null, helpPage: null,
model: null,
transformModel(model) { @computed("report")
const data = model.data.sort((a, b) => a.x >= b.x); values(report) {
if (!report) return;
return { return Ember.makeArray(report.data)
labels: model.labels, .sort((a, b) => a.x >= b.x)
values: data .map(x => {
}; return [ x[0], number(x[1]), number(x[2]) ];
});
}, },
didInsertElement() { @computed("report")
this._super(); labels(report) {
this._initializeTable(); if (!report) return;
return Ember.makeArray(report.labels);
}, },
didUpdateAttrs() { fetchReport() {
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;
this.set("isLoading", true); this.set("isLoading", true);
let payload = {data: {}}; let payload = { data: { async: true } };
if (this.get("startDate")) { 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")) { 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) ajax(this.get("dataSource"), payload)
.then((response) => { .then((response) => {
this._setPropertiesFromModel(response.report); this._setPropertiesFromReport(Report.create(response.report));
}).finally(() => { }).finally(() => {
this.set("isLoading", false); 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
});
} }
}); });

View File

@ -1,14 +1,14 @@
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
import computed from "ember-addons/ember-computed-decorators"; 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({ export default Ember.Controller.extend({
queryParams: ["period"], queryParams: ["period"],
period: "all", period: "all",
isLoading: false, isLoading: false,
dashboardFetchedAt: null, dashboardFetchedAt: null,
exceptionController: Ember.inject.controller('exception'), exceptionController: Ember.inject.controller("exception"),
diskSpace: Ember.computed.alias("model.attributes.disk_space"), diskSpace: Ember.computed.alias("model.attributes.disk_space"),
fetchDashboard() { fetchDashboard() {
@ -18,8 +18,11 @@ export default Ember.Controller.extend({
this.set("isLoading", true); this.set("isLoading", true);
AdminDashboardNext.find().then(adminDashboardNextModel => { AdminDashboardNext.find().then(adminDashboardNextModel => {
this.set("dashboardFetchedAt", new Date()); this.setProperties({
this.set("model", adminDashboardNextModel); dashboardFetchedAt: new Date(),
model: adminDashboardNextModel,
reports: adminDashboardNextModel.reports.map(x => Report.create(x))
});
}).catch(e => { }).catch(e => {
this.get("exceptionController").set("thrown", e.jqXHR); this.get("exceptionController").set("thrown", e.jqXHR);
this.replaceRoute("exception"); this.replaceRoute("exception");

View File

@ -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 });
}
});

View File

@ -1,9 +1,6 @@
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 ATTRIBUTES = [ "disk_space", "updated_at", "last_backup_taken_at" ];
const REPORTS = [ "global_reports", "user_reports" ];
const AdminDashboardNext = Discourse.Model.extend({}); const AdminDashboardNext = Discourse.Model.extend({});
@ -19,12 +16,7 @@ AdminDashboardNext.reopenClass({
return ajax("/admin/dashboard-next.json").then(function(json) { return ajax("/admin/dashboard-next.json").then(function(json) {
var model = AdminDashboardNext.create(); var model = AdminDashboardNext.create();
const reports = {}; model.set("reports", json.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 = {}; const attributes = {};
ATTRIBUTES.forEach(a => attributes[a] = json[a]); ATTRIBUTES.forEach(a => attributes[a] = json[a]);

View File

@ -5,6 +5,8 @@ import { fillMissingDates } from 'discourse/lib/utilities';
import computed from 'ember-addons/ember-computed-decorators'; import computed from 'ember-addons/ember-computed-decorators';
const Report = Discourse.Model.extend({ const Report = Discourse.Model.extend({
average: false,
reportUrl: fmt("type", "/admin/reports/%@"), reportUrl: fmt("type", "/admin/reports/%@"),
valueAt(numDaysAgo) { valueAt(numDaysAgo) {
@ -35,30 +37,43 @@ const Report = Discourse.Model.extend({
} }
}, },
todayCount: function() { return this.valueAt(0); }.property("data"), todayCount: function() { return this.valueAt(0); }.property("data", "average"),
yesterdayCount: function() { return this.valueAt(1); }.property("data"), yesterdayCount: function() { return this.valueAt(1); }.property("data", "average"),
sevenDaysAgoCount: function() { return this.valueAt(7); }.property("data"), sevenDaysAgoCount: function() { return this.valueAt(7); }.property("data", "average"),
thirtyDaysAgoCount: function() { return this.valueAt(30); }.property("data"), 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"), averageCount(count, value) {
lastThirtyDaysCount: function() { return this.valueFor(1, 30); }.property("data"), return this.get("average") ? value / count : value;
},
@computed('data') @computed('yesterdayCount')
yesterdayTrend() { yesterdayTrend(yesterdayCount) {
const yesterdayVal = this.valueAt(1); const yesterdayVal = yesterdayCount;
const twoDaysAgoVal = this.valueAt(2); 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"; return "trending-up";
} else if (yesterdayVal < twoDaysAgoVal) { } else if (change === 0) {
return "trending-down";
} else {
return "no-change"; return "no-change";
} else if (change < -50) {
return "high-trending-down";
} else if (change < 0) {
return "trending-down";
} }
}, },
@computed('data') @computed('lastSevenDaysCount')
sevenDayTrend() { sevenDayTrend(lastSevenDaysCount) {
const currentPeriod = this.valueFor(1, 7); const currentPeriod = lastSevenDaysCount;
const prevPeriod = this.valueFor(8, 14); const prevPeriod = this.valueFor(8, 14);
const change = ((currentPeriod - prevPeriod) / prevPeriod) * 100; const change = ((currentPeriod - prevPeriod) / prevPeriod) * 100;
@ -75,17 +90,22 @@ const Report = Discourse.Model.extend({
} }
}, },
@computed('prev30Days', 'data') @computed('prev30Days', 'lastThirtyDaysCount')
thirtyDayTrend(prev30Days) { thirtyDayTrend(prev30Days, lastThirtyDaysCount) {
if (prev30Days) { const currentPeriod = lastThirtyDaysCount;
const currentPeriod = this.valueFor(1, 30); const change = ((currentPeriod - prev30Days) / currentPeriod) * 100;
if (currentPeriod > this.get("prev30Days")) {
return "trending-up"; if (change > 50) {
} else if (currentPeriod < prev30Days) { return "high-trending-up";
return "trending-down"; } else if (change > 0) {
} return "trending-up";
} 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') @computed('type')
@ -126,19 +146,19 @@ const Report = Discourse.Model.extend({
return title; return title;
}, },
@computed('data') @computed('yesterdayCount')
yesterdayCountTitle() { yesterdayCountTitle(yesterdayCount) {
return this.changeTitle(this.valueAt(1), this.valueAt(2), "two days ago"); return this.changeTitle(yesterdayCount, this.valueAt(2), "two days ago");
}, },
@computed('data') @computed('lastSevenDaysCount')
sevenDayCountTitle() { sevenDayCountTitle(lastSevenDaysCount) {
return this.changeTitle(this.valueFor(1, 7), this.valueFor(8, 14), "two weeks ago"); return this.changeTitle(lastSevenDaysCount, this.valueFor(8, 14), "two weeks ago");
}, },
@computed('prev30Days', 'data') @computed('prev30Days', 'lastThirtyDaysCount')
thirtyDayCountTitle(prev30Days) { thirtyDayCountTitle(prev30Days, lastThirtyDaysCount) {
return this.changeTitle(this.valueFor(1, 30), prev30Days, "in the previous 30 day period"); return this.changeTitle(lastThirtyDaysCount, prev30Days, "in the previous 30 day period");
}, },
@computed('data') @computed('data')

View File

@ -1,5 +1,9 @@
import loadScript from "discourse/lib/load-script";
export default Discourse.Route.extend({ export default Discourse.Route.extend({
activate() { activate() {
this.controllerFor('admin-dashboard-next').fetchDashboard(); loadScript("/javascripts/Chart.min.js").then(() => {
this.controllerFor('admin-dashboard-next').fetchDashboard();
});
} }
}); });

View File

@ -1,6 +1,6 @@
{{#conditional-loading-spinner condition=isLoading}} {{#conditional-loading-section isLoading=isLoading title=report.title}}
<div class="table-title"> <div class="table-title">
<h3>{{title}}</h3> <h3>{{report.title}}</h3>
{{#if help}} {{#if help}}
<a href="{{helpPage}}">{{i18n help}}</a> <a href="{{helpPage}}">{{i18n help}}</a>
@ -18,11 +18,11 @@
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
{{#each dataset as |data|}} {{#each values as |value|}}
<td>{{number data}}</td> <td>{{number value}}</td>
{{/each}} {{/each}}
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
{{/conditional-loading-spinner}} {{/conditional-loading-section}}

View File

@ -1,28 +1,31 @@
{{#conditional-loading-spinner condition=isLoading}} {{#conditional-loading-section isLoading=isLoading title=report.title}}
<div class="chart-title"> <div class="chart-title">
<h3>{{title}}</h3> <h3 title={{report.description}}>
{{report.title}}
{{#if description}} {{#if report.description}}
<span title={{description}}>
{{d-icon "question-circle"}} {{d-icon "question-circle"}}
{{/if}}
</h3>
<div class="chart-trend {{trend}}">
<span title="{{report.thirtyDayCountTitle}}">
{{number report.lastThirtyDaysCount}}
</span> </span>
{{/if}}
{{#if trendIcon}}
{{d-icon trendIcon}}
{{/if}}
</div>
</div> </div>
<div class="chart-container"> <div class="chart-container">
{{#if oneDataPoint}} {{#if oneDataPoint}}
<span class="data-point"> <span class="data-point">
{{number chartData.lastObject.y}} {{number values.lastObject.y}}
</span> </span>
{{else}} {{else}}
<div class="chart-trend {{trend}}">
<span>{{number prev30Days}}</span>
{{#if trendIcon}}
{{d-icon trendIcon}}
{{/if}}
</div>
<canvas class="chart-canvas"></canvas> <canvas class="chart-canvas"></canvas>
{{/if}} {{/if}}
</div> </div>
{{/conditional-loading-spinner}} {{/conditional-loading-section}}

View File

@ -1,6 +1,6 @@
{{#conditional-loading-spinner condition=isLoading}} {{#conditional-loading-section isLoading=isLoading title=report.title}}
<div class="table-title"> <div class="table-title">
<h3>{{title}}</h3> <h3>{{report.title}}</h3>
{{#if help}} {{#if help}}
<a href="{{helpPage}}">{{i18n help}}</a> <a href="{{helpPage}}">{{i18n help}}</a>
@ -28,4 +28,4 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{{/conditional-loading-spinner}} {{/conditional-loading-section}}

View File

@ -1,4 +1,4 @@
{{plugin-outlet name="admin-dashboard-top"}} {{plugin-outlet name="admin-dashboard-top"}}
<div class="community-health section"> <div class="community-health section">
<div class="section-title"> <div class="section-title">
@ -9,22 +9,35 @@
<div class="section-body"> <div class="section-body">
<div class="charts"> <div class="charts">
{{dashboard-mini-chart {{dashboard-mini-chart
model=model.reports.global_reports.signups
dataSourceName="signups" dataSourceName="signups"
startDate=startDate startDate=startDate
endDate=endDate}} endDate=endDate}}
{{dashboard-mini-chart {{dashboard-mini-chart
model=model.reports.global_reports.topics
dataSourceName="topics" dataSourceName="topics"
startDate=startDate startDate=startDate
endDate=endDate}} endDate=endDate}}
{{dashboard-mini-chart {{dashboard-mini-chart
model=model.reports.global_reports.new_contributors
dataSourceName="new_contributors" dataSourceName="new_contributors"
startDate=startDate startDate=startDate
endDate=endDate}} 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> </div>
</div> </div>
@ -32,42 +45,42 @@
<div class="section-columns"> <div class="section-columns">
<div class="section-column"> <div class="section-column">
<div class="dashboard-table"> <div class="dashboard-table">
<div class="table-title"> {{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.activity_metrics")}}
<h3>{{i18n "admin.dashboard.activity_metrics"}}</h3> <div class="table-title">
</div> <h3>{{i18n "admin.dashboard.activity_metrics"}}</h3>
</div>
<div class="table-container"> <div class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>{{i18n 'admin.dashboard.reports.today'}}</th> <th>{{i18n 'admin.dashboard.reports.today'}}</th>
<th>{{i18n 'admin.dashboard.reports.yesterday'}}</th> <th>{{i18n 'admin.dashboard.reports.yesterday'}}</th>
<th>{{i18n 'admin.dashboard.reports.last_7_days'}}</th> <th>{{i18n 'admin.dashboard.reports.last_7_days'}}</th>
<th>{{i18n 'admin.dashboard.reports.last_30_days'}}</th> <th>{{i18n 'admin.dashboard.reports.last_30_days'}}</th>
<th>{{i18n 'admin.dashboard.reports.all'}}</th> <th>{{i18n 'admin.dashboard.reports.all'}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{admin-report-counts report=model.reports.global_reports.topics}} {{#each reports as |report|}}
{{admin-report-counts report=model.reports.global_reports.signups}} {{admin-report-counts report=report}}
{{admin-report-counts report=model.reports.global_reports.new_contributors}} {{/each}}
</tbody> </tbody>
</table> </table>
</div> </div>
{{/conditional-loading-section}}
</div> </div>
{{dashboard-inline-table {{dashboard-inline-table
model=model.reports.user_reports.users_by_type dataSourceName="users_by_type"
lastRefreshedAt=lastRefreshedAt lastRefreshedAt=lastRefreshedAt}}
isLoading=isLoading}}
{{dashboard-inline-table {{dashboard-inline-table
model=model.reports.user_reports.users_by_trust_level dataSourceName="users_by_trust_level"
lastRefreshedAt=lastRefreshedAt lastRefreshedAt=lastRefreshedAt}}
isLoading=isLoading}}
{{#conditional-loading-spinner isLoading=isLoading}} {{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.backups")}}
<div class="misc"> <div class="misc">
<div class="durability"> <div class="durability">
{{#if currentUser.admin}} {{#if currentUser.admin}}
@ -99,12 +112,11 @@
{{i18n "admin.dashboard.whats_new_in_discourse"}} {{i18n "admin.dashboard.whats_new_in_discourse"}}
</a> </a>
</div> </div>
{{/conditional-loading-spinner}} {{/conditional-loading-section}}
</div> </div>
<div class="section-column"> <div class="section-column">
{{dashboard-table-trending-search {{dashboard-table-trending-search
model=global_reports_trending_search
dataSourceName="trending_search" dataSourceName="trending_search"
startDate=startDate startDate=startDate
endDate=endDate}} endDate=endDate}}

View File

@ -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");
}
});

View File

@ -0,0 +1,6 @@
{{#if isLoading}}
<span class="title">{{computedTitle}}</span>
<div class="spinner {{size}}"></div>
{{else}}
{{yield}}
{{/if}}

View File

@ -8,8 +8,24 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@include small-width {
flex-direction: column;
}
.section-column { .section-column {
min-width: calc(50% - .5em); min-width: calc(50% - .5em);
@include small-width {
min-width: 100%;
&:last-child {
order: 1;
}
&:first-child {
order: 2;
}
}
} }
.section-column:last-child { .section-column:last-child {
@ -19,6 +35,12 @@
.section-column:first-child { .section-column:first-child {
margin-right: .5em; margin-right: .5em;
} }
@include small-width {
.section-column:last-child, .section-column:first-child {
margin: 0;
}
}
} }
.section { .section {
@ -43,8 +65,12 @@
.dashboard-table { .dashboard-table {
margin-bottom: 1em; margin-bottom: 1em;
&.fixed table { @include small-width {
table-layout: fixed; table {
tbody tr td {
font-size: $font-down-2;
}
}
} }
&.is-loading { &.is-loading {
@ -63,6 +89,7 @@
table { table {
border: 1px solid $primary-low-mid; border: 1px solid $primary-low-mid;
table-layout: fixed;
thead { thead {
tr { tr {
@ -70,7 +97,6 @@
th { th {
border: 1px solid $primary-low-mid; border: 1px solid $primary-low-mid;
text-align: center; text-align: center;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@ -80,6 +106,12 @@
tbody { tbody {
tr { tr {
td:first-child {
text-overflow: ellipsis;
overflow: hidden;
white-space: normal;
}
td { td {
border: 1px solid $primary-low-mid; border: 1px solid $primary-low-mid;
text-align: center; text-align: center;
@ -120,22 +152,34 @@
flex-wrap: wrap; flex-wrap: wrap;
.dashboard-mini-chart { .dashboard-mini-chart {
width: calc(100% * (1/3)); max-width: calc(100% * (1/3));
width: 100%;
margin-bottom: 1em; margin-bottom: 1em;
flex-grow: 1; flex-grow: 1;
@include small-width {
max-width: 100%;
}
&.is-loading { &.is-loading {
height: 150px; height: 200px;
}
.loading-container.visible {
display: flex;
align-items: center;
height: 100%;
width: 100%;
} }
.d-icon-question-circle { .d-icon-question-circle {
cursor: pointer; cursor: pointer;
margin-left: .25em;
} }
.chart-title { .chart-title {
align-items: center; align-items: center;
display: flex; display: flex;
justify-content: space-between;
h3 { h3 {
margin: 1em 0; margin: 1em 0;
@ -174,21 +218,24 @@
} }
} }
@include small-width {
.dashboard-mini-chart {
width: 100%;
}
}
.chart-container { .chart-container {
position: relative; position: relative;
padding: 0 1em; padding: 0 1em;
min-height: 200px;
} }
.chart-trend { .chart-trend {
font-size: $font-up-5; font-size: $font-up-3;
position: absolute;
right: 40px;
top: 5px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-weight: bold; font-weight: bold;
margin-right: 1em;
} }
.chart-canvas { .chart-canvas {

View File

@ -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;
}
}
}

View File

@ -7,8 +7,8 @@ class Admin::ReportsController < Admin::AdminController
raise Discourse::NotFound unless report_type =~ /^[a-z0-9\_]+$/ 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 start_date = (params[:start_date].present? ? params[:start_date].to_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 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 if params.has_key?(:category_id) && params[:category_id].to_i > 0
category_id = params[:category_id].to_i category_id = params[:category_id].to_i
@ -22,7 +22,12 @@ class Admin::ReportsController < Admin::AdminController
group_id = nil group_id = nil
end 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? raise Discourse::NotFound if report.blank?

View File

@ -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

View File

@ -1,17 +1,7 @@
class AdminDashboardNextData class AdminDashboardNextData
include StatsCacheable include StatsCacheable
GLOBAL_REPORTS ||= [ REPORTS = [ "visits", "posts", "time_to_first_response", "likes", "flags" ]
'signups',
'topics',
'trending_search',
'new_contributors'
]
USER_REPORTS ||= [
'users_by_trust_level',
'users_by_type'
]
def initialize(opts = {}) def initialize(opts = {})
@opts = opts @opts = opts
@ -27,8 +17,7 @@ class AdminDashboardNextData
def as_json(_options = nil) def as_json(_options = nil)
@json ||= { @json ||= {
global_reports: AdminDashboardNextData.reports(GLOBAL_REPORTS), reports: AdminDashboardNextData.reports(REPORTS),
user_reports: AdminDashboardNextData.reports(USER_REPORTS),
last_backup_taken_at: last_backup_taken_at, last_backup_taken_at: last_backup_taken_at,
updated_at: Time.zone.now.as_json updated_at: Time.zone.now.as_json
} }

View File

@ -25,21 +25,25 @@ module DateGroupable extend ActiveSupport::Concern
.order("date_trunc('#{aggregation_unit}', #{column})::DATE") .order("date_trunc('#{aggregation_unit}', #{column})::DATE")
end 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 days = (start_date.to_date..end_date.to_date).count
case case
when days <= 40 when days <= 40
aggregation_unit = :day return :day
when days <= 210 # 30 weeks when days <= 210 # 30 weeks
aggregation_unit = :week return :week
when days <= 550 # ~18 months when days <= 550 # ~18 months
aggregation_unit = :month return :month
when days <= 1461 # ~4 years when days <= 1461 # ~4 years
aggregation_unit = :quarter return :quarter
else else
aggregation_unit = :year return :year
end 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) where("#{column} BETWEEN ? AND ?", start_date, end_date)
.group_by_unit(aggregation_unit, column) .group_by_unit(aggregation_unit, column)

View File

@ -2,7 +2,8 @@ require_dependency 'topic_subtype'
class Report 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 def self.default_days
30 30
@ -14,6 +15,16 @@ class Report
@end_date ||= Time.zone.now.end_of_day @end_date ||= Time.zone.now.end_of_day
end 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) def as_json(options = nil)
{ {
type: type, type: type,
@ -45,14 +56,22 @@ class Report
# Load the report # Load the report
report = Report.new(type) report = Report.new(type)
report.start_date = opts[:start_date] if opts[:start_date] report.start_date = opts[:start_date].to_date if opts[:start_date]
report.end_date = opts[:end_date] if opts[:end_date] report.end_date = opts[:end_date].to_date if opts[:end_date]
report.category_id = opts[:category_id] if opts[:category_id] report.category_id = opts[:category_id] if opts[:category_id]
report.group_id = opts[:group_id] if opts[:group_id] report.group_id = opts[:group_id] if opts[:group_id]
report.async = opts[:async] || false
report_method = :"report_#{type}" report_method = :"report_#{type}"
if respond_to?(report_method) if respond_to?(report_method)
send(report_method, report) 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$/ elsif type =~ /_reqs$/
req_report(report, type.split(/_reqs$/)[0].to_sym) req_report(report, type.split(/_reqs$/)[0].to_sym)
else else
@ -73,7 +92,7 @@ class Report
end end
report.data = [] 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) .order(date: :asc)
.group(:date) .group(:date)
.sum(:count) .sum(:count)
@ -85,7 +104,7 @@ class Report
report.prev30Days = data.where( report.prev30Days = data.where(
'date >= ? AND date < ?', '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) ).sum(:count)
end end
@ -110,13 +129,73 @@ class Report
end end
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) 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 end
def self.report_profile_views(report) def self.report_profile_views(report)
start_date = report.start_date.to_date start_date = report.start_date
end_date = report.end_date.to_date end_date = report.end_date
basic_report_about report, UserProfileView, :profile_views_by_day, start_date, end_date, report.group_id basic_report_about report, UserProfileView, :profile_views_by_day, start_date, end_date, report.group_id
report.total = UserProfile.sum(:views) report.total = UserProfile.sum(:views)

View File

@ -829,8 +829,49 @@ class User < ActiveRecord::Base
(tl_badge + other_badges).take(limit) (tl_badge + other_badges).take(limit)
end end
def self.count_by_signup_date(start_date, end_date, group_id = nil) def self.count_by_inactivity(start_date, end_date)
result = smart_group_by_date("users.created_at", 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 if group_id
result = result.joins("INNER JOIN group_users ON group_users.user_id = users.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 result.count
end end
def self.count_by_first_post(start_date, end_date) def self.count_by_first_post(start_date = nil, end_date = nil)
joins('INNER JOIN user_stats AS us ON us.user_id = users.id') result = 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 if start_date && end_date
result = result.smart_group_by_date("us.first_post_created_at", start_date, end_date)
end
result.count
end end
def secure_category_ids def secure_category_ids

View File

@ -121,11 +121,16 @@ SQL
end end
def self.count_daily_engaged_users(start_date, end_date) def self.count_daily_engaged_users(start_date = nil, end_date = nil)
select(:user_id).distinct result = select(:user_id)
.distinct
.where(action_type: [LIKE, NEW_TOPIC, REPLY, NEW_PRIVATE_MESSAGE]) .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 end
def self.stream_item(action_id, guardian) def self.stream_item(action_id, guardian)

View File

@ -1,4 +1,5 @@
class UserVisit < ActiveRecord::Base class UserVisit < ActiveRecord::Base
include DateGroupable
def self.counts_by_day_query(start_date, end_date, group_id = nil) 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) 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) result.group(:visited_at).order(:visited_at)
end 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 # A count of visits in a date range by day
def self.by_day(start_date, end_date, group_id = nil) def self.by_day(start_date, end_date, group_id = nil)
counts_by_day_query(start_date, end_date, group_id).count counts_by_day_query(start_date, end_date, group_id).count

View File

@ -1228,6 +1228,9 @@ en:
ctrl: 'Ctrl' ctrl: 'Ctrl'
alt: 'Alt' alt: 'Alt'
conditional_loading_section:
loading: Loading...
select_kit: select_kit:
default_header_text: Select... default_header_text: Select...
no_content: No matches found no_content: No matches found

View File

@ -840,12 +840,27 @@ en:
title: "New Users" title: "New Users"
xaxis: "Day" xaxis: "Day"
yaxis: "Number of new users" yaxis: "Number of new users"
description: "Users created for this period" description: "New registrations for this period"
new_contributors: new_contributors:
title: "New Contributors" title: "New Contributors"
xaxis: "Day" xaxis: "Day"
yaxis: "Number of new contributors" 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 havent 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: profile_views:
title: "User Profile Views" title: "User Profile Views"
xaxis: "Day" xaxis: "Day"
@ -854,7 +869,7 @@ en:
title: "Topics" title: "Topics"
xaxis: "Day" xaxis: "Day"
yaxis: "Number of new topics" yaxis: "Number of new topics"
description: "Topics created for this period" description: "New topics created during this period"
posts: posts:
title: "Posts" title: "Posts"
xaxis: "Day" xaxis: "Day"

View File

@ -21,8 +21,12 @@ class Cache < ActiveSupport::Cache::Store
redis.reconnect redis.reconnect
end end
def keys(pattern = "*")
redis.keys("#{@namespace}:#{pattern}")
end
def clear def clear
redis.keys("#{@namespace}:*").each do |k| keys.each do |k|
redis.del(k) redis.del(k)
end end
end end

View File

@ -73,4 +73,11 @@ describe Cache do
end end
expect(r).to eq("bill") expect(r).to eq("bill")
end 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 end

View File

@ -342,6 +342,76 @@ describe Report do
end end
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 describe 'posts counts' do
it "only counts regular posts" do it "only counts regular posts" do
post = Fabricate(:post) post = Fabricate(:post)

View File

@ -7,7 +7,7 @@ acceptance("Dashboard Next", {
loggedIn: true loggedIn: true
}); });
QUnit.test("Vist dashboard next page", assert => { QUnit.test("Visit dashboard next page", assert => {
visit("/admin/dashboard-next"); visit("/admin/dashboard-next");
andThen(() => { andThen(() => {

View File

@ -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
}
}
};

View File

@ -1,139 +1,6 @@
export default { export default {
"/admin/dashboard-next.json": { "/admin/dashboard-next.json": {
"global_reports": [{ "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
}],
"last_backup_taken_at": "2018-04-13T12:51:19.926Z", "last_backup_taken_at": "2018-04-13T12:51:19.926Z",
"updated_at": "2018-04-25T08:06:11.292Z", "updated_at": "2018-04-25T08:06:11.292Z",
"disk_space": { "disk_space": {

View File

@ -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
}
}
};

View File

@ -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 havent 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
}
}
};

View File

@ -5,38 +5,8 @@ export default {
"title": "Topics", "title": "Topics",
"xaxis": "Day", "xaxis": "Day",
"yaxis": "Number of new topics", "yaxis": "Number of new topics",
"data": [{ "data": null,
"x": "2018-04-11", "total": null,
"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,
"start_date": "2018-03-26T00:00:00.000Z", "start_date": "2018-03-26T00:00:00.000Z",
"end_date": "2018-04-25T23:59:59.999Z", "end_date": "2018-04-25T23:59:59.999Z",
"category_id": null, "category_id": null,

View File

@ -5,11 +5,7 @@ export default {
"title": "Trending search", "title": "Trending search",
"xaxis": "", "xaxis": "",
"yaxis": "", "yaxis": "",
"data": [ "data": null,
["lon", 3, 1],
["pub", 1, 1],
["something", 1, 1]
],
"total": null, "total": null,
"start_date": "2018-03-26T00:00:00.000Z", "start_date": "2018-03-26T00:00:00.000Z",
"end_date": "2018-04-25T23:59:59.999Z", "end_date": "2018-04-25T23:59:59.999Z",

View File

@ -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
}
}
};

View File

@ -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
}
}
};