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

View File

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

View File

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

View File

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

View File

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

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 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]);

View File

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

View File

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

View File

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

View File

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

View File

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

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="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}}

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;
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 {

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\_]+$/
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?

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(() => {

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 {
"/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": {

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",
"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,

View File

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

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