FEATURE: part 2 of dashboard improvements

- moderation tab
- sorting/pagination
- improved third party reports support
- trending charts
- better perf
- many fixes
- refactoring
- new reports

Co-Authored-By: Simon Cossar <scossar@users.noreply.github.com>
This commit is contained in:
Joffrey JAFFEUX 2018-07-19 14:33:11 -04:00 committed by GitHub
parent 4e09206061
commit 1a78e12f4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
76 changed files with 3177 additions and 1484 deletions

View File

@ -0,0 +1,117 @@
import { number } from "discourse/lib/formatter";
import loadScript from "discourse/lib/load-script";
export default Ember.Component.extend({
classNames: ["admin-report-chart"],
limit: 8,
primaryColor: "rgb(0,136,204)",
total: 0,
willDestroyElement() {
this._super(...arguments);
this._resetChart();
},
didReceiveAttrs() {
this._super(...arguments);
Ember.run.schedule("afterRender", () => {
const $chartCanvas = this.$(".chart-canvas");
if (!$chartCanvas || !$chartCanvas.length) return;
const context = $chartCanvas[0].getContext("2d");
const model = this.get("model");
const chartData = Ember.makeArray(
model.get("chartData") || model.get("data")
);
const prevChartData = Ember.makeArray(
model.get("prevChartData") || model.get("prev_data")
);
const labels = chartData.map(d => d.x);
const data = {
labels,
datasets: [
{
data: chartData.map(d => Math.round(parseFloat(d.y))),
backgroundColor: prevChartData.length
? "transparent"
: "rgba(200,220,240,0.3)",
borderColor: this.get("primaryColor")
}
]
};
if (prevChartData.length) {
data.datasets.push({
data: prevChartData.map(d => Math.round(parseFloat(d.y))),
borderColor: this.get("primaryColor"),
borderDash: [5, 5],
backgroundColor: "transparent",
borderWidth: 1,
pointRadius: 0
});
}
loadScript("/javascripts/Chart.min.js").then(() => {
this._resetChart();
this._chart = new window.Chart(context, this._buildChartConfig(data));
});
});
},
_buildChartConfig(data) {
return {
type: "line",
data,
options: {
tooltips: {
callbacks: {
title: tooltipItem =>
moment(tooltipItem[0].xLabel, "YYYY-MM-DD").format("LL")
}
},
legend: {
display: false
},
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
left: 0,
top: 0,
right: 0,
bottom: 0
}
},
scales: {
yAxes: [
{
display: true,
ticks: { callback: label => number(label) }
}
],
xAxes: [
{
display: true,
gridLines: { display: false },
type: "time",
time: {
parser: "YYYY-MM-DD"
}
}
]
}
}
};
},
_resetChart() {
if (this._chart) {
this._chart.destroy();
this._chart = null;
}
}
});

View File

@ -0,0 +1,3 @@
export default Ember.Component.extend({
classNames: ["admin-report-inline-table"]
});

View File

@ -0,0 +1,18 @@
import computed from "ember-addons/ember-computed-decorators";
export default Ember.Component.extend({
tagName: "th",
classNames: ["admin-report-table-header"],
classNameBindings: ["label.property", "isCurrentSort"],
attributeBindings: ["label.title:title"],
@computed("currentSortLabel.sort_property", "label.sort_property")
isCurrentSort(currentSortField, labelSortField) {
return currentSortField === labelSortField;
},
@computed("currentSortDirection")
sortIcon(currentSortDirection) {
return currentSortDirection === 1 ? "caret-up" : "caret-down";
}
});

View File

@ -0,0 +1,13 @@
import computed from "ember-addons/ember-computed-decorators";
export default Ember.Component.extend({
tagName: "tr",
classNames: ["admin-report-table-row"],
@computed("data", "labels")
cells(row, labels) {
return labels.map(label => {
return label.compute(row);
});
}
});

View File

@ -0,0 +1,147 @@
import computed from "ember-addons/ember-computed-decorators";
import { registerTooltip, unregisterTooltip } from "discourse/lib/tooltip";
import { isNumeric } from "discourse/lib/utilities";
const PAGES_LIMIT = 8;
export default Ember.Component.extend({
classNameBindings: ["sortable", "twoColumns"],
classNames: ["admin-report-table"],
sortable: false,
sortDirection: 1,
perPage: Ember.computed.alias("options.perPage"),
page: 0,
didRender() {
this._super(...arguments);
unregisterTooltip($(".text[data-tooltip]"));
registerTooltip($(".text[data-tooltip]"));
},
willDestroyElement() {
this._super(...arguments);
unregisterTooltip($(".text[data-tooltip]"));
},
@computed("model.computedLabels.length")
twoColumns(labelsLength) {
return labelsLength === 2;
},
@computed("totalsForSample", "options.total", "model.dates_filtering")
showTotalForSample(totalsForSample, total, datesFiltering) {
// check if we have at least one cell which contains a value
const sum = totalsForSample
.map(t => t.value)
.compact()
.reduce((s, v) => s + v, 0);
return sum >= 1 && total && datesFiltering;
},
@computed("model.total", "options.total", "twoColumns")
showTotal(reportTotal, total, twoColumns) {
return reportTotal && total && twoColumns;
},
@computed("model.data.length")
showSortingUI(dataLength) {
return dataLength >= 5;
},
@computed("totalsForSampleRow", "model.computedLabels")
totalsForSample(row, labels) {
return labels.map(label => label.compute(row));
},
@computed("model.data", "model.computedLabels")
totalsForSampleRow(rows, labels) {
if (!rows || !rows.length) return {};
let totalsRow = {};
labels.forEach(label => {
const reducer = (sum, row) => {
const computedLabel = label.compute(row);
const value = computedLabel.value;
if (computedLabel.type === "link" || (value && !isNumeric(value))) {
return undefined;
} else {
return sum + value;
}
};
totalsRow[label.property] = rows.reduce(reducer, 0);
});
return totalsRow;
},
@computed("sortLabel", "sortDirection", "model.data.[]")
sortedData(sortLabel, sortDirection, data) {
data = Ember.makeArray(data);
if (sortLabel) {
const compare = (label, direction) => {
return (a, b) => {
let aValue = label.compute(a).value;
let bValue = label.compute(b).value;
const result = aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
return result * direction;
};
};
return data.sort(compare(sortLabel, sortDirection));
}
return data;
},
@computed("sortedData.[]", "perPage", "page")
paginatedData(data, perPage, page) {
if (perPage < data.length) {
const start = perPage * page;
return data.slice(start, start + perPage);
}
return data;
},
@computed("model.data", "perPage", "page")
pages(data, perPage, page) {
if (!data || data.length <= perPage) return [];
let pages = [...Array(Math.ceil(data.length / perPage)).keys()].map(v => {
return {
page: v + 1,
index: v,
class: v === page ? "current" : null
};
});
if (pages.length > PAGES_LIMIT) {
const before = Math.max(0, page - PAGES_LIMIT / 2);
const after = Math.max(PAGES_LIMIT, page + PAGES_LIMIT / 2);
pages = pages.slice(before, after);
}
return pages;
},
actions: {
changePage(page) {
this.set("page", page);
},
sortByLabel(label) {
if (this.get("sortLabel") === label) {
this.set("sortDirection", this.get("sortDirection") === 1 ? -1 : 1);
} else {
this.set("sortLabel", label);
}
}
}
});

View File

@ -0,0 +1,366 @@
import { exportEntity } from "discourse/lib/export-csv";
import { outputExportResult } from "discourse/lib/export-result";
import { ajax } from "discourse/lib/ajax";
import Report from "admin/models/report";
import computed from "ember-addons/ember-computed-decorators";
import { registerTooltip, unregisterTooltip } from "discourse/lib/tooltip";
const TABLE_OPTIONS = {
perPage: 8,
total: true,
limit: 20
};
const CHART_OPTIONS = {};
function collapseWeekly(data, average) {
let aggregate = [];
let bucket, i;
let offset = data.length % 7;
for (i = offset; i < data.length; i++) {
if (bucket && i % 7 === offset) {
if (average) {
bucket.y = parseFloat((bucket.y / 7.0).toFixed(2));
}
aggregate.push(bucket);
bucket = null;
}
bucket = bucket || { x: data[i].x, y: 0 };
bucket.y += data[i].y;
}
return aggregate;
}
export default Ember.Component.extend({
classNameBindings: [
"isEnabled",
"isLoading",
"dasherizedDataSourceName",
"currentMode"
],
classNames: ["admin-report"],
isEnabled: true,
disabledLabel: "admin.dashboard.disabled",
isLoading: false,
dataSourceName: null,
report: null,
model: null,
reportOptions: null,
forcedModes: null,
showAllReportsLink: false,
startDate: null,
endDate: null,
showTrend: false,
showHeader: true,
showTitle: true,
showFilteringUI: false,
showCategoryOptions: Ember.computed.alias("model.category_filtering"),
showDatesOptions: Ember.computed.alias("model.dates_filtering"),
showGroupOptions: Ember.computed.alias("model.group_filtering"),
showExport: Ember.computed.not("model.onlyTable"),
hasFilteringActions: Ember.computed.or(
"showCategoryOptions",
"showDatesOptions",
"showGroupOptions",
"showExport"
),
init() {
this._super(...arguments);
this._reports = [];
},
didReceiveAttrs() {
this._super(...arguments);
if (this.get("report")) {
this._renderReport(
this.get("report"),
this.get("forcedModes"),
this.get("currentMode")
);
} else if (this.get("dataSourceName")) {
this._fetchReport().finally(() => this._computeReport());
}
},
didRender() {
this._super(...arguments);
unregisterTooltip($(".info[data-tooltip]"));
registerTooltip($(".info[data-tooltip]"));
},
willDestroyElement() {
this._super(...arguments);
unregisterTooltip($(".info[data-tooltip]"));
},
showTimeoutError: Ember.computed.alias("model.timeout"),
@computed("dataSourceName", "model.type")
dasherizedDataSourceName(dataSourceName, type) {
return (dataSourceName || type || "undefined").replace(/_/g, "-");
},
@computed("dataSourceName", "model.type")
dataSource(dataSourceName, type) {
dataSourceName = dataSourceName || type;
return `/admin/reports/${dataSourceName}`;
},
@computed("displayedModes.length")
showModes(displayedModesLength) {
return displayedModesLength > 1;
},
@computed("currentMode", "model.modes", "forcedModes")
displayedModes(currentMode, reportModes, forcedModes) {
const modes = forcedModes ? forcedModes.split(",") : reportModes;
return Ember.makeArray(modes).map(mode => {
const base = `mode-button ${mode}`;
const cssClass = currentMode === mode ? `${base} current` : base;
return {
mode,
cssClass,
icon: mode === "table" ? "table" : "signal"
};
});
},
@computed()
categoryOptions() {
const arr = [{ name: I18n.t("category.all"), value: "all" }];
return arr.concat(
Discourse.Site.currentProp("sortedCategories").map(i => {
return { name: i.get("name"), value: i.get("id") };
})
);
},
@computed()
groupOptions() {
const arr = [
{ name: I18n.t("admin.dashboard.reports.groups"), value: "all" }
];
return arr.concat(
this.site.groups.map(i => {
return { name: i["name"], value: i["id"] };
})
);
},
@computed("currentMode")
modeComponent(currentMode) {
return `admin-report-${currentMode}`;
},
actions: {
exportCsv() {
exportEntity("report", {
name: this.get("model.type"),
start_date: this.get("startDate"),
end_date: this.get("endDate"),
category_id:
this.get("categoryId") === "all" ? undefined : this.get("categoryId"),
group_id:
this.get("groupId") === "all" ? undefined : this.get("groupId")
}).then(outputExportResult);
},
changeMode(mode) {
this.set("currentMode", mode);
}
},
_computeReport() {
if (!this.element || this.isDestroying || this.isDestroyed) {
return;
}
if (!this._reports || !this._reports.length) {
return;
}
// on a slow network _fetchReport could be called multiple times between
// T and T+x, and all the ajax responses would occur after T+(x+y)
// to avoid any inconsistencies we filter by period and make sure
// the array contains only unique values
let filteredReports = this._reports.uniqBy("report_key");
let report;
const sort = r => {
if (r.length > 1) {
return r.findBy("type", this.get("dataSourceName"));
} else {
return r;
}
};
let startDate = this.get("startDate");
let endDate = this.get("endDate");
startDate =
startDate && typeof startDate.isValid === "function"
? startDate.format("YYYYMMDD")
: startDate;
endDate =
startDate && typeof endDate.isValid === "function"
? endDate.format("YYYYMMDD")
: endDate;
if (!startDate || !endDate) {
report = sort(filteredReports)[0];
} else {
let reportKey = `reports:${this.get("dataSourceName")}`;
if (this.get("categoryId") && this.get("categoryId") !== "all") {
reportKey += `:${this.get("categoryId")}`;
} else {
reportKey += `:`;
}
reportKey += `:${startDate.replace(/-/g, "")}`;
reportKey += `:${endDate.replace(/-/g, "")}`;
if (this.get("groupId") && this.get("groupId") !== "all") {
reportKey += `:${this.get("groupId")}`;
} else {
reportKey += `:`;
}
reportKey += `:`;
report = sort(
filteredReports.filter(r => r.report_key.includes(reportKey))
)[0];
if (!report) {
console.log(
"failed to find a report to render",
`expected key: ${reportKey}`,
`existing keys: ${filteredReports.map(f => f.report_key)}`
);
return;
}
}
this._renderReport(
report,
this.get("forcedModes"),
this.get("currentMode")
);
},
_renderReport(report, forcedModes, currentMode) {
const modes = forcedModes ? forcedModes.split(",") : report.modes;
currentMode = currentMode || modes[0];
this.setProperties({
model: report,
currentMode,
options: this._buildOptions(currentMode)
});
},
_fetchReport() {
this._super();
this.set("isLoading", true);
let payload = this._buildPayload(["prev_period"]);
return ajax(this.get("dataSource"), payload)
.then(response => {
if (response && response.report) {
this._reports.push(this._loadReport(response.report));
} else {
console.log("failed loading", this.get("dataSource"));
}
})
.finally(() => {
if (this.element && !this.isDestroying && !this.isDestroyed) {
this.set("isLoading", false);
}
});
},
_buildPayload(facets) {
let payload = { data: { cache: true, facets } };
if (this.get("startDate")) {
payload.data.start_date = moment(
this.get("startDate"),
"YYYY-MM-DD"
).format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ");
}
if (this.get("endDate")) {
payload.data.end_date = moment(this.get("endDate"), "YYYY-MM-DD").format(
"YYYY-MM-DD[T]HH:mm:ss.SSSZZ"
);
}
if (this.get("groupId") && this.get("groupId") !== "all") {
payload.data.group_id = this.get("groupId");
}
if (this.get("categoryId") && this.get("categoryId") !== "all") {
payload.data.category_id = this.get("categoryId");
}
if (this.get("reportOptions.table.limit")) {
payload.data.limit = this.get("reportOptions.table.limit");
}
return payload;
},
_buildOptions(mode) {
if (mode === "table") {
const tableOptions = JSON.parse(JSON.stringify(TABLE_OPTIONS));
return Ember.Object.create(
_.assign(tableOptions, this.get("reportOptions.table") || {})
);
} else {
const chartOptions = JSON.parse(JSON.stringify(CHART_OPTIONS));
return Ember.Object.create(
_.assign(chartOptions, this.get("reportOptions.chart") || {})
);
}
},
_loadReport(jsonReport) {
Report.fillMissingDates(jsonReport, { filledField: "chartData" });
if (jsonReport.chartData && jsonReport.chartData.length > 40) {
jsonReport.chartData = collapseWeekly(
jsonReport.chartData,
jsonReport.average
);
}
if (jsonReport.prev_data) {
Report.fillMissingDates(jsonReport, {
filledField: "prevChartData",
dataField: "prev_data",
starDate: jsonReport.prev_start_date,
endDate: jsonReport.prev_end_date
});
if (jsonReport.prevChartData && jsonReport.prevChartData.length > 40) {
jsonReport.prevChartData = collapseWeekly(
jsonReport.prevChartData,
jsonReport.average
);
}
}
return Report.create(jsonReport);
}
});

View File

@ -1,9 +0,0 @@
import computed from "ember-addons/ember-computed-decorators";
export default Ember.Component.extend({
@computed("model.sortedData")
totalForPeriod(data) {
const values = data.map(d => d.y);
return values.reduce((sum, v) => sum + v);
}
});

View File

@ -1,20 +0,0 @@
import { ajax } from "discourse/lib/ajax";
import AsyncReport from "admin/mixins/async-report";
export default Ember.Component.extend(AsyncReport, {
classNames: ["dashboard-inline-table"],
fetchReport() {
this._super();
let payload = this.buildPayload(["total", "prev30Days"]);
return Ember.RSVP.Promise.all(
this.get("dataSources").map(dataSource => {
return ajax(dataSource, payload).then(response => {
this.get("reports").pushObject(this.loadReport(response.report));
});
})
);
}
});

View File

@ -1,176 +0,0 @@
import { ajax } from "discourse/lib/ajax";
import AsyncReport from "admin/mixins/async-report";
import Report from "admin/models/report";
import { number } from "discourse/lib/formatter";
import loadScript from "discourse/lib/load-script";
import { registerTooltip, unregisterTooltip } from "discourse/lib/tooltip";
function collapseWeekly(data, average) {
let aggregate = [];
let bucket, i;
let offset = data.length % 7;
for (i = offset; i < data.length; i++) {
if (bucket && i % 7 === offset) {
if (average) {
bucket.y = parseFloat((bucket.y / 7.0).toFixed(2));
}
aggregate.push(bucket);
bucket = null;
}
bucket = bucket || { x: data[i].x, y: 0 };
bucket.y += data[i].y;
}
return aggregate;
}
export default Ember.Component.extend(AsyncReport, {
classNames: ["chart", "dashboard-mini-chart"],
total: 0,
init() {
this._super();
this._colorsPool = ["rgb(0,136,204)", "rgb(235,83,148)"];
},
didRender() {
this._super();
registerTooltip($(this.element).find("[data-tooltip]"));
},
willDestroyElement() {
this._super();
unregisterTooltip($(this.element).find("[data-tooltip]"));
},
pickColorAtIndex(index) {
return this._colorsPool[index] || this._colorsPool[0];
},
fetchReport() {
this._super();
let payload = this.buildPayload(["prev_period"]);
if (this._chart) {
this._chart.destroy();
this._chart = null;
}
return Ember.RSVP.Promise.all(
this.get("dataSources").map(dataSource => {
return ajax(dataSource, payload).then(response => {
this.get("reports").pushObject(this.loadReport(response.report));
});
})
);
},
loadReport(report, previousReport) {
Report.fillMissingDates(report);
if (report.data && report.data.length > 40) {
report.data = collapseWeekly(report.data, report.average);
}
if (previousReport && previousReport.color.length) {
report.color = previousReport.color;
} else {
const dataSourceNameIndex = this.get("dataSourceNames")
.split(",")
.indexOf(report.type);
report.color = this.pickColorAtIndex(dataSourceNameIndex);
}
return Report.create(report);
},
renderReport() {
this._super();
Ember.run.schedule("afterRender", () => {
const $chartCanvas = this.$(".chart-canvas");
if (!$chartCanvas.length) return;
const context = $chartCanvas[0].getContext("2d");
const reportsForPeriod = this.get("reportsForPeriod");
const labels = Ember.makeArray(
reportsForPeriod.get("firstObject.data")
).map(d => d.x);
const data = {
labels,
datasets: reportsForPeriod.map(report => {
return {
data: Ember.makeArray(report.data).map(d =>
Math.round(parseFloat(d.y))
),
backgroundColor: "rgba(200,220,240,0.3)",
borderColor: report.color
};
})
};
if (this._chart) {
this._chart.destroy();
this._chart = null;
}
loadScript("/javascripts/Chart.min.js").then(() => {
if (this._chart) {
this._chart.destroy();
}
this._chart = new window.Chart(context, this._buildChartConfig(data));
});
});
},
_buildChartConfig(data) {
return {
type: "line",
data,
options: {
tooltips: {
callbacks: {
title: context =>
moment(context[0].xLabel, "YYYY-MM-DD").format("LL")
}
},
legend: {
display: false
},
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
left: 0,
top: 0,
right: 0,
bottom: 0
}
},
scales: {
yAxes: [
{
display: true,
ticks: { callback: label => number(label) }
}
],
xAxes: [
{
display: true,
gridLines: { display: false },
type: "time",
time: {
parser: "YYYY-MM-DD"
}
}
]
}
}
};
}
});

View File

@ -1,20 +0,0 @@
import { ajax } from "discourse/lib/ajax";
import AsyncReport from "admin/mixins/async-report";
export default Ember.Component.extend(AsyncReport, {
classNames: ["dashboard-table"],
fetchReport() {
this._super();
let payload = this.buildPayload(["total", "prev30Days"]);
return Ember.RSVP.Promise.all(
this.get("dataSources").map(dataSource => {
return ajax(dataSource, payload).then(response => {
this.get("reports").pushObject(this.loadReport(response.report));
});
})
);
}
});

View File

@ -0,0 +1,102 @@
import { setting } from "discourse/lib/computed";
import computed from "ember-addons/ember-computed-decorators";
import AdminDashboardNext from "admin/models/admin-dashboard-next";
import Report from "admin/models/report";
import PeriodComputationMixin from "admin/mixins/period-computation";
export default Ember.Controller.extend(PeriodComputationMixin, {
isLoading: false,
dashboardFetchedAt: null,
exceptionController: Ember.inject.controller("exception"),
diskSpace: Ember.computed.alias("model.attributes.disk_space"),
logSearchQueriesEnabled: setting("log_search_queries"),
lastBackupTakenAt: Ember.computed.alias(
"model.attributes.last_backup_taken_at"
),
shouldDisplayDurability: Ember.computed.and("lastBackupTakenAt", "diskSpace"),
@computed
topReferredTopicsTopions() {
return { table: { total: false, limit: 8 } };
},
@computed
trendingSearchOptions() {
return { table: { total: false, limit: 8 } };
},
@computed("reports.[]")
topReferredTopicsReport(reports) {
return reports.find(x => x.type === "top_referred_topics");
},
@computed("reports.[]")
trendingSearchReport(reports) {
return reports.find(x => x.type === "trending_search");
},
@computed("reports.[]")
usersByTypeReport(reports) {
return reports.find(x => x.type === "users_by_type");
},
@computed("reports.[]")
usersByTrustLevelReport(reports) {
return reports.find(x => x.type === "users_by_trust_level");
},
@computed("reports.[]")
activityMetricsReports(reports) {
return reports.filter(report => {
return [
"page_view_total_reqs",
"visits",
"time_to_first_response",
"likes",
"flags",
"user_to_user_private_messages_with_replies"
].includes(report.type);
});
},
fetchDashboard() {
if (this.get("isLoading")) return;
if (
!this.get("dashboardFetchedAt") ||
moment()
.subtract(30, "minutes")
.toDate() > this.get("dashboardFetchedAt")
) {
this.set("isLoading", true);
AdminDashboardNext.fetchGeneral()
.then(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");
})
.finally(() => this.set("isLoading", false));
}
},
@computed("model.attributes.updated_at")
updatedTimestamp(updatedAt) {
return moment(updatedAt).format("LLL");
},
@computed("lastBackupTakenAt")
backupTimestamp(lastBackupTakenAt) {
return moment(lastBackupTakenAt).format("LLL");
},
_reportsForPeriodURL(period) {
return Discourse.getURL(`/admin/dashboard/general?period=${period}`);
}
});

View File

@ -0,0 +1,64 @@
import computed from "ember-addons/ember-computed-decorators";
import Report from "admin/models/report";
import AdminDashboardNext from "admin/models/admin-dashboard-next";
import PeriodComputationMixin from "admin/mixins/period-computation";
export default Ember.Controller.extend(PeriodComputationMixin, {
isLoading: false,
dashboardFetchedAt: null,
exceptionController: Ember.inject.controller("exception"),
@computed
flagsStatusOptions() {
return {
table: {
total: false,
perPage: 10
}
};
},
@computed("reports.[]")
flagsStatusReport(reports) {
return reports.find(x => x.type === "flags_status");
},
@computed("reports.[]")
postEditsReport(reports) {
return reports.find(x => x.type === "post_edits");
},
fetchDashboard() {
if (this.get("isLoading")) return;
if (
!this.get("dashboardFetchedAt") ||
moment()
.subtract(30, "minutes")
.toDate() > this.get("dashboardFetchedAt")
) {
this.set("isLoading", true);
AdminDashboardNext.fetchModeration()
.then(model => {
const reports = model.reports.map(x => Report.create(x));
this.setProperties({
dashboardFetchedAt: new Date(),
model,
reports
});
})
.catch(e => {
this.get("exceptionController").set("thrown", e.jqXHR);
this.replaceRoute("exception");
})
.finally(() => {
this.set("isLoading", false);
});
}
},
_reportsForPeriodURL(period) {
return Discourse.getURL(`/admin/dashboard/moderation?period=${period}`);
}
});

View File

@ -1,34 +1,38 @@
import { setting } from "discourse/lib/computed";
import DiscourseURL from "discourse/lib/url";
import computed from "ember-addons/ember-computed-decorators";
import AdminDashboardNext from "admin/models/admin-dashboard-next";
import Report from "admin/models/report";
import VersionCheck from "admin/models/version-check";
const PROBLEMS_CHECK_MINUTES = 1;
export default Ember.Controller.extend({
queryParams: ["period"],
period: "monthly",
isLoading: false,
dashboardFetchedAt: null,
exceptionController: Ember.inject.controller("exception"),
showVersionChecks: setting("version_checks"),
diskSpace: Ember.computed.alias("model.attributes.disk_space"),
lastBackupTakenAt: Ember.computed.alias(
"model.attributes.last_backup_taken_at"
),
logSearchQueriesEnabled: setting("log_search_queries"),
availablePeriods: ["yearly", "quarterly", "monthly", "weekly"],
shouldDisplayDurability: Ember.computed.and("lastBackupTakenAt", "diskSpace"),
@computed("problems.length")
foundProblems(problemsLength) {
return this.currentUser.get("admin") && (problemsLength || 0) > 0;
},
fetchProblems() {
if (this.get("isLoadingProblems")) return;
if (
!this.get("problemsFetchedAt") ||
moment()
.subtract(PROBLEMS_CHECK_MINUTES, "minutes")
.toDate() > this.get("problemsFetchedAt")
) {
this._loadProblems();
}
},
fetchDashboard() {
if (this.get("isLoading")) return;
const versionChecks = this.siteSettings.version_checks;
if (this.get("isLoading") || !versionChecks) return;
if (
!this.get("dashboardFetchedAt") ||
@ -38,22 +42,17 @@ export default Ember.Controller.extend({
) {
this.set("isLoading", true);
const versionChecks = this.siteSettings.version_checks;
AdminDashboardNext.fetch()
.then(model => {
let properties = {
dashboardFetchedAt: new Date()
};
AdminDashboardNext.find()
.then(adminDashboardNextModel => {
if (versionChecks) {
this.set(
"versionCheck",
VersionCheck.create(adminDashboardNextModel.version_check)
);
properties.versionCheck = VersionCheck.create(model.version_check);
}
this.setProperties({
dashboardFetchedAt: new Date(),
model: adminDashboardNextModel,
reports: adminDashboardNextModel.reports.map(x => Report.create(x))
});
this.setProperties(properties);
})
.catch(e => {
this.get("exceptionController").set("thrown", e.jqXHR);
@ -63,27 +62,17 @@ export default Ember.Controller.extend({
this.set("isLoading", false);
});
}
if (
!this.get("problemsFetchedAt") ||
moment()
.subtract(PROBLEMS_CHECK_MINUTES, "minutes")
.toDate() > this.get("problemsFetchedAt")
) {
this.loadProblems();
}
},
loadProblems() {
this.set("loadingProblems", true);
this.set("problemsFetchedAt", new Date());
_loadProblems() {
this.setProperties({
loadingProblems: true,
problemsFetchedAt: new Date()
});
AdminDashboardNext.fetchProblems()
.then(d => {
this.set("problems", d.problems);
})
.finally(() => {
this.set("loadingProblems", false);
});
.then(model => this.set("problems", model.problems))
.finally(() => this.set("loadingProblems", false));
},
@computed("problemsFetchedAt")
@ -93,69 +82,9 @@ export default Ember.Controller.extend({
.format("LLL");
},
@computed("period")
startDate(period) {
let fullDay = moment()
.locale("en")
.utc()
.subtract(1, "day");
switch (period) {
case "yearly":
return fullDay.subtract(1, "year").startOf("day");
break;
case "quarterly":
return fullDay.subtract(3, "month").startOf("day");
break;
case "weekly":
return fullDay.subtract(1, "week").startOf("day");
break;
case "monthly":
return fullDay.subtract(1, "month").startOf("day");
break;
default:
return fullDay.subtract(1, "month").startOf("day");
}
},
@computed()
lastWeek() {
return moment()
.locale("en")
.utc()
.endOf("day")
.subtract(1, "week");
},
@computed()
endDate() {
return moment()
.locale("en")
.utc()
.subtract(1, "day")
.endOf("day");
},
@computed("model.attributes.updated_at")
updatedTimestamp(updatedAt) {
return moment(updatedAt).format("LLL");
},
@computed("lastBackupTakenAt")
backupTimestamp(lastBackupTakenAt) {
return moment(lastBackupTakenAt).format("LLL");
},
actions: {
changePeriod(period) {
DiscourseURL.routeTo(this._reportsForPeriodURL(period));
},
refreshProblems() {
this.loadProblems();
this._loadProblems();
}
},
_reportsForPeriodURL(period) {
return Discourse.getURL(`/admin?period=${period}`);
}
});

View File

@ -1,108 +1,36 @@
import { exportEntity } from "discourse/lib/export-csv";
import { outputExportResult } from "discourse/lib/export-result";
import Report from "admin/models/report";
import computed from "ember-addons/ember-computed-decorators";
export default Ember.Controller.extend({
queryParams: ["mode", "start_date", "end_date", "category_id", "group_id"],
viewMode: "graph",
viewingTable: Em.computed.equal("viewMode", "table"),
viewingGraph: Em.computed.equal("viewMode", "graph"),
startDate: null,
endDate: null,
queryParams: ["start_date", "end_date", "category_id", "group_id"],
categoryId: null,
groupId: null,
refreshing: false,
@computed()
categoryOptions() {
const arr = [{ name: I18n.t("category.all"), value: "all" }];
return arr.concat(
Discourse.Site.currentProp("sortedCategories").map(i => {
return { name: i.get("name"), value: i.get("id") };
})
);
},
@computed()
groupOptions() {
const arr = [
{ name: I18n.t("admin.dashboard.reports.groups"), value: "all" }
];
return arr.concat(
this.site.groups.map(i => {
return { name: i["name"], value: i["id"] };
})
);
},
@computed("model.type")
showCategoryOptions(modelType) {
return [
"topics",
"posts",
"time_to_first_response_total",
"topics_with_no_response",
"flags",
"likes",
"bookmarks"
].includes(modelType);
},
reportOptions(type) {
let options = { table: { perPage: 50, limit: 50 } };
@computed("model.type")
showGroupOptions(modelType) {
return (
modelType === "visits" ||
modelType === "signups" ||
modelType === "profile_views"
);
if (type === "top_referred_topics") {
options.table.limit = 10;
}
return options;
},
actions: {
refreshReport() {
var q;
this.set("refreshing", true);
this.setProperties({
start_date: this.get("startDate"),
end_date: this.get("endDate"),
category_id: this.get("categoryId")
});
if (this.get("groupId")) {
this.set("group_id", this.get("groupId"));
}
q = Report.find(
this.get("model.type"),
this.get("startDate"),
this.get("endDate"),
this.get("categoryId"),
this.get("groupId")
);
q.then(m => this.set("model", m)).finally(() =>
this.set("refreshing", false)
);
onSelectStartDate(startDate) {
this.set("start_date", startDate);
},
viewAsTable() {
this.set("viewMode", "table");
onSelectCategory(categoryId) {
this.set("category_id", categoryId);
},
viewAsGraph() {
this.set("viewMode", "graph");
onSelectGroup(groupId) {
this.set("group_id", groupId);
},
exportCsv() {
exportEntity("report", {
name: this.get("model.type"),
start_date: this.get("startDate"),
end_date: this.get("endDate"),
category_id:
this.get("categoryId") === "all" ? undefined : this.get("categoryId"),
group_id:
this.get("groupId") === "all" ? undefined : this.get("groupId")
}).then(outputExportResult);
onSelectEndDate(endDate) {
this.set("end_date", endDate);
}
}
});

View File

@ -1,108 +0,0 @@
import computed from "ember-addons/ember-computed-decorators";
import Report from "admin/models/report";
export default Ember.Mixin.create({
classNameBindings: ["isLoading", "dataSourceNames"],
reports: null,
isLoading: false,
dataSourceNames: "",
title: null,
init() {
this._super();
this.set("reports", []);
},
@computed("dataSourceNames")
dataSources(dataSourceNames) {
return dataSourceNames.split(",").map(source => `/admin/reports/${source}`);
},
buildPayload(facets) {
let payload = { data: { cache: true, facets } };
if (this.get("startDate")) {
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").format(
"YYYY-MM-DD[T]HH:mm:ss.SSSZZ"
);
}
if (this.get("limit")) {
payload.data.limit = this.get("limit");
}
return payload;
},
@computed("reports.[]", "startDate", "endDate", "dataSourceNames")
reportsForPeriod(reports, startDate, endDate, dataSourceNames) {
// on a slow network fetchReport could be called multiple times between
// T and T+x, and all the ajax responses would occur after T+(x+y)
// to avoid any inconsistencies we filter by period and make sure
// the array contains only unique values
reports = reports.uniqBy("report_key");
const sort = r => {
if (r.length > 1) {
return dataSourceNames.split(",").map(name => r.findBy("type", name));
} else {
return r;
}
};
if (!startDate || !endDate) {
return sort(reports);
}
return sort(
reports.filter(report => {
return (
report.report_key.includes(startDate.format("YYYYMMDD")) &&
report.report_key.includes(endDate.format("YYYYMMDD"))
);
})
);
},
didInsertElement() {
this._super();
this.fetchReport().finally(() => {
this.renderReport();
});
},
didUpdateAttrs() {
this._super();
this.fetchReport().finally(() => {
this.renderReport();
});
},
renderReport() {
if (!this.element || this.isDestroying || this.isDestroyed) return;
this.set(
"title",
this.get("reportsForPeriod")
.map(r => r.title)
.join(", ")
);
this.set("isLoading", false);
},
loadReport(jsonReport) {
return Report.create(jsonReport);
},
fetchReport() {
this.set("reports", []);
this.set("isLoading", true);
}
});

View File

@ -0,0 +1,59 @@
import DiscourseURL from "discourse/lib/url";
import computed from "ember-addons/ember-computed-decorators";
export default Ember.Mixin.create({
queryParams: ["period"],
period: "monthly",
availablePeriods: ["yearly", "quarterly", "monthly", "weekly"],
@computed("period")
startDate(period) {
let fullDay = moment()
.locale("en")
.utc()
.subtract(1, "day");
switch (period) {
case "yearly":
return fullDay.subtract(1, "year").startOf("day");
break;
case "quarterly":
return fullDay.subtract(3, "month").startOf("day");
break;
case "weekly":
return fullDay.subtract(1, "week").startOf("day");
break;
case "monthly":
return fullDay.subtract(1, "month").startOf("day");
break;
default:
return fullDay.subtract(1, "month").startOf("day");
}
},
@computed()
lastWeek() {
return moment()
.locale("en")
.utc()
.endOf("day")
.subtract(1, "week");
},
@computed()
endDate() {
return moment()
.locale("en")
.utc()
.subtract(1, "day")
.endOf("day");
},
actions: {
changePeriod(period) {
DiscourseURL.routeTo(this._reportsForPeriodURL(period));
}
}
});

View File

@ -1,29 +1,41 @@
import { ajax } from "discourse/lib/ajax";
const ATTRIBUTES = ["disk_space", "updated_at", "last_backup_taken_at"];
const GENERAL_ATTRIBUTES = ["disk_space", "updated_at", "last_backup_taken_at"];
const AdminDashboardNext = Discourse.Model.extend({});
AdminDashboardNext.reopenClass({
/**
Fetch all dashboard data. This can be an expensive request when the cached data
has expired and the server must collect the data again.
@method find
@return {jqXHR} a jQuery Promise object
**/
find() {
return ajax("/admin/dashboard-next.json").then(function(json) {
var model = AdminDashboardNext.create();
model.set("reports", json.reports);
fetch() {
return ajax("/admin/dashboard-next.json").then(json => {
const model = AdminDashboardNext.create();
model.set("version_check", json.version_check);
return model;
});
},
fetchModeration() {
return ajax("/admin/dashboard/moderation.json").then(json => {
const model = AdminDashboardNext.create();
model.setProperties({
reports: json.reports,
loaded: true
});
return model;
});
},
fetchGeneral() {
return ajax("/admin/dashboard/general.json").then(json => {
const model = AdminDashboardNext.create();
const attributes = {};
ATTRIBUTES.forEach(a => (attributes[a] = json[a]));
model.set("attributes", attributes);
GENERAL_ATTRIBUTES.forEach(a => (attributes[a] = json[a]));
model.set("loaded", true);
model.setProperties({
reports: json.reports,
attributes,
loaded: true
});
return model;
});

View File

@ -1,22 +1,105 @@
import { escapeExpression } from "discourse/lib/utilities";
import { ajax } from "discourse/lib/ajax";
import round from "discourse/lib/round";
import { fillMissingDates } from "discourse/lib/utilities";
import { fillMissingDates, isNumeric } from "discourse/lib/utilities";
import computed from "ember-addons/ember-computed-decorators";
import { number } from "discourse/lib/formatter";
import { number, durationTiny } from "discourse/lib/formatter";
const Report = Discourse.Model.extend({
average: false,
percent: false,
higher_is_better: true,
@computed("labels")
computedLabels(labels) {
return labels.map(label => {
const type = label.type;
const properties = label.properties;
const property = properties[0];
return {
title: label.title,
sort_property: label.sort_property || property,
property,
compute: row => {
let value = row[property];
let escapedValue = escapeExpression(value);
let tooltip;
let base = { property, value, type };
if (value === null || typeof value === "undefined") {
return _.assign(base, {
value: null,
formatedValue: "-",
type: "undefined"
});
}
if (type === "seconds") {
return _.assign(base, {
formatedValue: escapeExpression(durationTiny(value))
});
}
if (type === "link") {
return _.assign(base, {
formatedValue: `<a href="${escapeExpression(
row[properties[1]]
)}">${escapedValue}</a>`
});
}
if (type === "percent") {
return _.assign(base, {
formatedValue: `${escapedValue}%`
});
}
if (type === "number" || isNumeric(value))
return _.assign(base, {
type: "number",
formatedValue: number(value)
});
if (type !== "string" && type !== "text") {
const date = moment(value, "YYYY-MM-DD");
if (type === "date" || date.isValid()) {
return _.assign(base, {
type: "date",
formatedValue: date.format("LL")
});
}
}
if (type === "text") tooltip = escapedValue;
return _.assign(base, {
tooltip,
type: type || "string",
formatedValue: escapedValue
});
}
};
});
},
@computed("modes")
onlyTable(modes) {
return modes.length === 1 && modes[0] === "table";
},
@computed("type", "start_date", "end_date")
reportUrl(type, start_date, end_date) {
start_date = moment(start_date)
start_date = moment
.utc(start_date)
.locale("en")
.format("YYYY-MM-DD");
end_date = moment(end_date)
end_date = moment
.utc(end_date)
.locale("en")
.format("YYYY-MM-DD");
return Discourse.getURL(
`/admin/reports/${type}?start_date=${start_date}&end_date=${end_date}`
);
@ -142,29 +225,6 @@ const Report = Discourse.Model.extend({
return this._computeTrend(prev30Days, lastThirtyDaysCount, higherIsBetter);
},
@computed("type")
icon(type) {
if (type.indexOf("message") > -1) {
return "envelope";
}
switch (type) {
case "page_view_total_reqs":
return "file";
case "visits":
return "user";
case "time_to_first_response":
return "reply";
case "flags":
return "flag";
case "likes":
return "heart";
case "bookmarks":
return "bookmark";
default:
return null;
}
},
@computed("type")
method(type) {
if (type === "time_to_first_response") {
@ -290,18 +350,23 @@ const Report = Discourse.Model.extend({
});
Report.reopenClass({
fillMissingDates(report) {
if (_.isArray(report.data)) {
fillMissingDates(report, options = {}) {
const dataField = options.dataField || "data";
const filledField = options.filledField || "data";
const startDate = options.startDate || "start_date";
const endDate = options.endDate || "end_date";
if (_.isArray(report[dataField])) {
const startDateFormatted = moment
.utc(report.start_date)
.utc(report[startDate])
.locale("en")
.format("YYYY-MM-DD");
const endDateFormatted = moment
.utc(report.end_date)
.utc(report[endDate])
.locale("en")
.format("YYYY-MM-DD");
report.data = fillMissingDates(
report.data,
report[filledField] = fillMissingDates(
JSON.parse(JSON.stringify(report[dataField])),
startDateFormatted,
endDateFormatted
);
@ -317,8 +382,12 @@ Report.reopenClass({
group_id: groupId
}
}).then(json => {
// Add zero values for missing dates
Report.fillMissingDates(json.report);
// dont fill for large multi column tables
// which are not date based
const modes = json.report.modes;
if (modes.length !== 1 && modes[0] !== "table") {
Report.fillMissingDates(json.report);
}
const model = Report.create({ type: type });
model.setProperties(json.report);

View File

@ -0,0 +1,5 @@
export default Discourse.Route.extend({
activate() {
this.controllerFor("admin-dashboard-next-general").fetchDashboard();
}
});

View File

@ -0,0 +1,5 @@
export default Discourse.Route.extend({
activate() {
this.controllerFor("admin-dashboard-next-moderation").fetchDashboard();
}
});

View File

@ -2,7 +2,22 @@ import { scrollTop } from "discourse/mixins/scroll-top";
export default Discourse.Route.extend({
activate() {
this.controllerFor("admin-dashboard-next").fetchProblems();
this.controllerFor("admin-dashboard-next").fetchDashboard();
scrollTop();
},
afterModel(model, transition) {
if (transition.targetName === "admin.dashboardNext.index") {
this.transitionTo("admin.dashboardNext.general");
}
},
actions: {
willTransition(transition) {
if (transition.targetName === "admin.dashboardNext.index") {
this.transitionTo("admin.dashboardNext.general");
}
}
}
});

View File

@ -1,35 +1,18 @@
import Report from "admin/models/report";
export default Discourse.Route.extend({
queryParams: {
mode: {},
start_date: {},
end_date: {},
category_id: {},
group_id: {}
},
setupController(controller) {
this._super(...arguments);
model(params) {
return Report.find(
params.type,
params["start_date"],
params["end_date"],
params["category_id"],
params["group_id"]
);
},
if (!controller.get("start_date")) {
controller.set(
"start_date",
moment()
.subtract("30", "day")
.format("YYYY-MM-DD")
);
}
setupController(controller, model) {
controller.setProperties({
model: model,
categoryId: model.get("category_id") || "all",
groupId: model.get("group_id"),
startDate: moment(model.get("start_date"))
.utc()
.format("YYYY-MM-DD"),
endDate: moment(model.get("end_date"))
.utc()
.format("YYYY-MM-DD")
});
if (!controller.get("end_date")) {
controller.set("end_date", moment().format("YYYY-MM-DD"));
}
}
});

View File

@ -1,7 +1,12 @@
export default function() {
this.route("admin", { resetNamespace: true }, function() {
this.route("dashboard", { path: "/dashboard-old" });
this.route("dashboardNext", { path: "/" });
this.route("dashboardNext", { path: "/" }, function() {
this.route("general", { path: "/dashboard/general" });
this.route("moderation", { path: "/dashboard/moderation" });
});
this.route(
"adminSiteSettings",
{ path: "/site_settings", resetNamespace: true },

View File

@ -0,0 +1,3 @@
<div class="chart-canvas-container">
<canvas class="chart-canvas"></canvas>
</div>

View File

@ -0,0 +1,15 @@
<div class="table-container">
{{#each model.data as |data|}}
<a class="table-cell user-{{data.key}}" href="{{data.url}}">
<span class="label">
{{#if data.icon}}
{{d-icon data.icon}}
{{/if}}
{{data.x}}
</span>
<span class="value">
{{number data.y}}
</span>
</a>
{{/each}}
</div>

View File

@ -0,0 +1,5 @@
{{#if showSortingUI}}
{{d-button action=sortByLabel icon=sortIcon class="sort-button"}}
{{/if}}
<span>{{label.title}}</span>

View File

@ -0,0 +1,5 @@
{{#each cells as |cell|}}
<td class="{{cell.type}} {{cell.property}}" title="{{cell.tooltip}}">
{{{cell.formatedValue}}}
</td>
{{/each}}

View File

@ -0,0 +1,60 @@
<table class="report-table">
<thead>
<tr>
{{#if model.computedLabels}}
{{#each model.computedLabels as |label index|}}
{{admin-report-table-header
showSortingUI=showSortingUI
currentSortDirection=sortDirection
currentSortLabel=sortLabel
label=label
sortByLabel=(action "sortByLabel" label)}}
{{/each}}
{{else}}
{{#each model.data as |data|}}
<th>{{data.x}}</th>
{{/each}}
{{/if}}
</tr>
</thead>
<tbody>
{{#each paginatedData as |data|}}
{{admin-report-table-row data=data labels=model.computedLabels}}
{{/each}}
</tbody>
</table>
{{#if showTotalForSample}}
<small>{{i18n 'admin.dashboard.reports.totals_for_sample'}}</small>
<table class="totals-sample-table">
<tbody>
<tr>
{{#each totalsForSample as |total|}}
<td>{{total.formatedValue}}</td>
{{/each}}
</tr>
</tbody>
</table>
{{/if}}
{{#if showTotal}}
<small>{{i18n 'admin.dashboard.reports.total'}}</small>
<table class="totals-table">
<tbody>
<tr>
<td>-</td>
<td>{{number model.total}}</td>
</tr>
</tbody>
</table>
{{/if}}
<div class="pagination">
{{#each pages as |pageState|}}
{{d-button
translatedLabel=pageState.page
action="changePage"
actionParam=pageState.index
class=pageState.class}}
{{/each}}
</div>

View File

@ -0,0 +1,154 @@
{{#if isEnabled}}
{{#conditional-loading-section isLoading=isLoading}}
{{#if showTimeoutError}}
<div class="alert alert-error">
{{i18n "admin.dashboard.timeout_error"}}
</div>
{{/if}}
{{#if showHeader}}
<div class="report-header">
{{#if showTitle}}
<div class="report-title">
<h3 class="title">
{{#if showAllReportsLink}}
{{#link-to "adminReports" class="all-report-link"}}
{{i18n "admin.dashboard.all_reports"}}
{{/link-to}}
<span class="separator">|</span>
{{/if}}
<a href="{{model.reportUrl}}" class="report-link">
{{model.title}}
</a>
</h3>
{{#if model.description}}
<span class="info" data-tooltip="{{model.description}}">
{{d-icon "question-circle"}}
</span>
{{/if}}
</div>
{{/if}}
{{#if showTrend}}
{{#if model.prev_period}}
<div class="trend {{model.trend}}">
<span class="trend-value" title="{{model.trendTitle}}">
{{#if model.average}}
{{number model.currentAverage}}{{#if model.percent}}%{{/if}}
{{else}}
{{number model.currentTotal noTitle="true"}}{{#if model.percent}}%{{/if}}
{{/if}}
</span>
{{#if model.trendIcon}}
{{d-icon model.trendIcon class="trend-icon"}}
{{/if}}
</div>
{{/if}}
{{/if}}
{{#if showModes}}
<ul class="mode-switch">
{{#each displayedModes as |displayedMode|}}
<li class="mode">
{{d-button
action="changeMode"
actionParam=displayedMode.mode
class=displayedMode.cssClass
icon=displayedMode.icon}}
</li>
{{/each}}
</ul>
{{/if}}
</div>
{{/if}}
<div class="report-body">
{{#unless showTimeoutError}}
{{#if currentMode}}
{{component modeComponent model=model options=options}}
{{/if}}
{{/unless}}
{{#if showFilteringUI}}
{{#if hasFilteringActions}}
<div class="report-filters">
{{#if showDatesOptions}}
<div class="filtering-control">
<span class="filtering-label">
{{i18n 'admin.dashboard.reports.start_date'}}
</span>
<div class="filtering-input">
{{date-picker-past
value=startDate
defaultDate=startDate
onSelect=onSelectStartDate}}
</div>
</div>
<div class="filtering-control">
<span class="filtering-label">
{{i18n 'admin.dashboard.reports.end_date'}}
</span>
<div class="filtering-input">
{{date-picker-past
value=endDate
defaultDate=endDate
onSelect=onSelectEndDate}}
</div>
</div>
{{/if}}
{{#if showCategoryOptions}}
<div class="filtering-control">
<div class="filtering-input">
{{combo-box
onSelect=onSelectCategory
filterable=true
valueAttribute="value"
content=categoryOptions
castInteger=true
value=categoryId}}
</div>
</div>
{{/if}}
{{#if showGroupOptions}}
<div class="filtering-control">
<div class="filtering-input">
{{combo-box
onSelect=onSelectGroup
castInteger=true
filterable=true
valueAttribute="value"
content=groupOptions
value=groupId}}
</div>
</div>
{{/if}}
{{#if showExport}}
<div class="filtering-control">
<div class="filtering-input">
{{d-button class="export-btn" action="exportCsv" label="admin.export_csv.button_text" icon="download"}}
</div>
</div>
{{/if}}
</div>
{{/if}}
{{/if}}
</div>
{{#if model.relatedReport}}
{{admin-report dataSourceName=model.relatedReport.type}}
{{/if}}
{{/conditional-loading-section}}
{{else}}
<div class="alert alert-info">
{{{i18n disabledLabel}}}
</div>
{{/if}}

View File

@ -1,37 +0,0 @@
{{#if model.sortedData}}
<table class="table report {{model.type}}">
<tr>
<th>{{model.xaxis}}</th>
<th>{{model.yaxis}}</th>
</tr>
{{#each model.sortedData as |row|}}
<tr>
<td class="x-value">{{row.x}}</td>
<td>
{{row.y}}
</td>
</tr>
{{/each}}
<tr class="total-for-period">
<td class="x-value">
{{i18n 'admin.dashboard.reports.total_for_period'}}
</td>
<td>
{{totalForPeriod}}
</td>
</tr>
{{#if model.total}}
<tr class="total">
<td class="x-value">
{{i18n 'admin.dashboard.reports.total'}}
</td>
<td>
{{model.total}}
</td>
</tr>
{{/if}}
</table>
{{/if}}

View File

@ -1,27 +0,0 @@
{{#conditional-loading-section isLoading=isLoading}}
<div class="table-title">
<h3>{{title}}</h3>
</div>
{{#each reportsForPeriod as |report|}}
<div class="table-container">
{{#unless hasBlock}}
{{#each report.data as |data|}}
<a class="table-cell user-{{data.key}}" href="{{data.url}}">
<span class="label">
{{#if data.icon}}
{{d-icon data.icon}}
{{/if}}
{{data.x}}
</span>
<span class="value">
{{number data.y}}
</span>
</a>
{{/each}}
{{else}}
{{yield (hash report=report)}}
{{/unless}}
</div>
{{/each}}
{{/conditional-loading-section}}

View File

@ -1,33 +0,0 @@
{{#conditional-loading-section isLoading=isLoading}}
{{#each reportsForPeriod as |report|}}
<div class="status">
<h4 class="title">
<a href="{{report.reportUrl}}">
{{report.title}}
</a>
<span class="info" data-tooltip="{{report.description}}">
{{d-icon "question-circle"}}
</span>
</h4>
<div class="trend {{report.trend}}">
<span class="trend-value" title="{{report.trendTitle}}">
{{#if report.average}}
{{number report.currentAverage}}{{#if report.percent}}%{{/if}}
{{else}}
{{number report.currentTotal noTitle="true"}}{{#if report.percent}}%{{/if}}
{{/if}}
</span>
{{#if report.trendIcon}}
{{d-icon report.trendIcon class="trend-icon"}}
{{/if}}
</div>
</div>
{{/each}}
<div class="chart-canvas-container">
<canvas class="chart-canvas"></canvas>
</div>
{{/conditional-loading-section}}

View File

@ -1,36 +0,0 @@
{{#conditional-loading-section isLoading=isLoading}}
<div class="table-title">
<h3>{{title}}</h3>
</div>
{{#each reportsForPeriod as |report|}}
<div class="table-container">
<table>
<thead>
<tr>
{{#if report.labels}}
{{#each report.labels as |label|}}
<th>{{label}}</th>
{{/each}}
{{else}}
{{#each report.data as |data|}}
<th>{{data.x}}</th>
{{/each}}
{{/if}}
</tr>
</thead>
<tbody>
{{#unless hasBlock}}
{{#each report.data as |data|}}
<tr>
<td>{{number data.y}}</td>
</tr>
{{/each}}
{{else}}
{{yield (hash report=report)}}
{{/unless}}
</tbody>
</table>
</div>
{{/each}}
{{/conditional-loading-section}}

View File

@ -3,179 +3,26 @@
{{#if showVersionChecks}}
<div class="section-top">
<div class="version-checks">
{{partial 'admin/templates/version-checks'}}
{{partial "admin/templates/version-checks"}}
</div>
</div>
{{/if}}
{{partial 'admin/templates/dashboard-problems'}}
{{partial "admin/templates/dashboard-problems"}}
<div class="community-health section">
<div class="section-title">
<h2>{{i18n "admin.dashboard.community_health"}}</h2>
{{period-chooser period=period action="changePeriod" content=availablePeriods fullDay=true}}
</div>
<div class="section-body">
<div class="charts">
{{dashboard-mini-chart
dataSourceNames="signups"
startDate=startDate
endDate=endDate}}
{{dashboard-mini-chart
dataSourceNames="topics"
startDate=startDate
endDate=endDate}}
{{dashboard-mini-chart
dataSourceNames="posts"
startDate=startDate
endDate=endDate}}
{{dashboard-mini-chart
dataSourceNames="dau_by_mau"
startDate=startDate
endDate=endDate}}
{{dashboard-mini-chart
dataSourceNames="daily_engaged_users"
startDate=startDate
endDate=endDate}}
{{dashboard-mini-chart
dataSourceNames="new_contributors"
startDate=startDate
endDate=endDate}}
</div>
</div>
</div>
<div class="section-columns">
<div class="section-column">
<div class="dashboard-table activity-metrics">
{{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.activity_metrics")}}
<div class="table-title">
<h3>{{i18n "admin.dashboard.activity_metrics"}}</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th></th>
<th>{{i18n 'admin.dashboard.reports.today'}}</th>
<th>{{i18n 'admin.dashboard.reports.yesterday'}}</th>
<th>{{i18n 'admin.dashboard.reports.last_7_days'}}</th>
<th>{{i18n 'admin.dashboard.reports.last_30_days'}}</th>
</tr>
</thead>
<tbody>
{{#each reports as |report|}}
{{admin-report-counts report=report allTime=false}}
{{/each}}
</tbody>
</table>
</div>
{{/conditional-loading-section}}
</div>
{{#link-to "adminReports"}}
{{i18n "admin.dashboard.all_reports"}}
<ul class="navigation">
<li class="navigation-item general">
{{#link-to "admin.dashboardNext.general" class="navigation-link"}}
{{i18n "admin.dashboard.general_tab"}}
{{/link-to}}
</li>
<li class="navigation-item moderation">
{{#link-to "admin.dashboardNext.moderation" class="navigation-link"}}
{{i18n "admin.dashboard.moderation_tab"}}
{{/link-to}}
</li>
</ul>
<div class="user-metrics">
{{dashboard-inline-table dataSourceNames="users_by_type" lastRefreshedAt=lastRefreshedAt}}
{{outlet}}
{{dashboard-inline-table dataSourceNames="users_by_trust_level" lastRefreshedAt=lastRefreshedAt}}
</div>
{{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.backups")}}
<div class="misc">
{{#if shouldDisplayDurability}}
<div class="durability">
{{#if currentUser.admin}}
<div class="backups">
<h3 class="durability-title">
<a href="/admin/backups">{{d-icon "archive"}} {{i18n "admin.dashboard.backups"}}</a>
</h3>
<p>
{{diskSpace.backups_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.backups_free}})
<br />
{{{i18n "admin.dashboard.lastest_backup" date=backupTimestamp}}}
</p>
</div>
{{/if}}
<div class="uploads">
<h3 class="durability-title">{{d-icon "upload"}} {{i18n "admin.dashboard.uploads"}}</h3>
<p>
{{diskSpace.uploads_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.uploads_free}})
</p>
</div>
</div>
{{/if}}
<div class="last-dashboard-update">
<div>
<h4>{{i18n "admin.dashboard.last_updated"}} </h4>
<p>{{updatedTimestamp}}</p>
<a rel="noopener" target="_blank" href="https://meta.discourse.org/tags/release-notes" class="btn">
{{i18n "admin.dashboard.whats_new_in_discourse"}}
</a>
</div>
</div>
</div>
<p>
{{i18n 'admin.dashboard.find_old'}} {{#link-to 'admin.dashboard'}}{{i18n "admin.dashboard.old_link"}}{{/link-to}}
</p>
{{/conditional-loading-section}}
</div>
<div class="section-column">
<div class="top-referred-topics">
{{#dashboard-table
dataSourceNames="top_referred_topics"
lastRefreshedAt=lastRefreshedAt
limit=8
as |context|}}
{{#each context.report.data as |data|}}
<tr>
<td class='left'>
<a href="{{data.topic_url}}">
{{data.topic_title}}
</a>
</td>
<td>
{{data.num_clicks}}
</td>
</tr>
{{/each}}
{{/dashboard-table}}
</div>
<div class="trending-search">
{{#dashboard-table
limit=8
dataSourceNames="trending_search"
isEnabled=logSearchQueriesEnabled
disabledLabel="admin.dashboard.reports.trending_search.disabled"
startDate=lastWeek
endDate=endDate as |context|}}
{{#each context.report.data as |data|}}
<tr>
<td class='left'>
{{data.term}}
</td>
<td>
{{number data.unique_searches}}
</td>
<td>
{{data.ctr}}
</td>
</tr>
{{/each}}
{{/dashboard-table}}
{{{i18n "admin.dashboard.reports.trending_search.more"}}}
</div>
</div>
</div>
{{plugin-outlet name="admin-dashboard-bottom"}}

View File

@ -0,0 +1,180 @@
{{#conditional-loading-spinner condition=isLoading}}
{{plugin-outlet name="admin-dashboard-general-top"}}
<div class="community-health section">
<div class="period-section">
<div class="section-title">
<h2>{{i18n "admin.dashboard.community_health"}}</h2>
{{period-chooser period=period action="changePeriod" content=availablePeriods fullDay=true}}
</div>
<div class="section-body">
<div class="charts">
{{admin-report
dataSourceName="signups"
showTrend=true
forcedModes="chart"
startDate=startDate
endDate=endDate}}
{{admin-report
dataSourceName="topics"
showTrend=true
forcedModes="chart"
startDate=startDate
endDate=endDate}}
{{admin-report
dataSourceName="posts"
showTrend=true
forcedModes="chart"
startDate=startDate
endDate=endDate}}
{{admin-report
dataSourceName="dau_by_mau"
showTrend=true
forcedModes="chart"
startDate=startDate
endDate=endDate}}
{{admin-report
dataSourceName="daily_engaged_users"
showTrend=true
forcedModes="chart"
startDate=startDate
endDate=endDate}}
{{admin-report
dataSourceName="new_contributors"
showTrend=true
forcedModes="chart"
startDate=startDate
endDate=endDate}}
</div>
</div>
</div>
</div>
<div class="section-columns">
<div class="section-column">
<div class="admin-report activity-metrics">
{{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.activity_metrics")}}
<div class="report-header">
<div class="report-title">
<h3 class="title">
{{#link-to "adminReports" class="report-link"}}
{{i18n "admin.dashboard.activity_metrics"}}
{{/link-to}}
</h3>
</div>
</div>
<div class="report-body">
<div class="admin-report-table">
<table class="report-table">
<thead>
<tr>
<th class="admin-report-table-header"></th>
<th class="admin-report-table-header">
{{i18n 'admin.dashboard.reports.today'}}
</th>
<th class="admin-report-table-header">
{{i18n 'admin.dashboard.reports.yesterday'}}
</th>
<th class="admin-report-table-header">
{{i18n 'admin.dashboard.reports.last_7_days'}}
</th>
<th class="admin-report-table-header">
{{i18n 'admin.dashboard.reports.last_30_days'}}
</th>
</tr>
</thead>
<tbody>
{{#each activityMetricsReports as |report|}}
{{admin-report-counts report=report allTime=false class="admin-report-table-row"}}
{{/each}}
</tbody>
</table>
</div>
</div>
{{/conditional-loading-section}}
</div>
{{#link-to "adminReports"}}
{{i18n "admin.dashboard.all_reports"}}
{{/link-to}}
<div class="user-metrics">
{{admin-report
forcedModes="inline-table"
report=usersByTypeReport
lastRefreshedAt=lastRefreshedAt}}
{{admin-report
forcedModes="inline-table"
report=usersByTrustLevelReport
lastRefreshedAt=lastRefreshedAt}}
</div>
{{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.backups")}}
<div class="misc">
{{#if shouldDisplayDurability}}
<div class="durability">
{{#if currentUser.admin}}
<div class="backups">
<h3 class="durability-title">
<a href="/admin/backups">{{d-icon "archive"}} {{i18n "admin.dashboard.backups"}}</a>
</h3>
<p>
{{diskSpace.backups_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.backups_free}})
<br />
{{{i18n "admin.dashboard.lastest_backup" date=backupTimestamp}}}
</p>
</div>
{{/if}}
<div class="uploads">
<h3 class="durability-title">{{d-icon "upload"}} {{i18n "admin.dashboard.uploads"}}</h3>
<p>
{{diskSpace.uploads_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.uploads_free}})
</p>
</div>
</div>
{{/if}}
<div class="last-dashboard-update">
<div>
<h4>{{i18n "admin.dashboard.last_updated"}} </h4>
<p>{{updatedTimestamp}}</p>
<a rel="noopener" target="_blank" href="https://meta.discourse.org/tags/release-notes" class="btn">
{{i18n "admin.dashboard.whats_new_in_discourse"}}
</a>
</div>
</div>
</div>
<p>
{{i18n 'admin.dashboard.find_old'}} {{#link-to 'admin.dashboard'}}{{i18n "admin.dashboard.old_link"}}{{/link-to}}
</p>
{{/conditional-loading-section}}
</div>
<div class="section-column">
{{admin-report
report=topReferredTopicsReport
reportOptions=topReferredTopicsTopions}}
{{admin-report
reportOptions=trendingSearchOptions
report=trendingSearchReport
isEnabled=logSearchQueriesEnabled
disabledLabel="admin.dashboard.reports.trending_search.disabled"
startDate=startDate
endDate=endDate}}
{{{i18n "admin.dashboard.reports.trending_search.more"}}}
</div>
</div>
{{plugin-outlet name="admin-dashboard-general-bottom"}}
{{/conditional-loading-spinner}}

View File

@ -0,0 +1,40 @@
{{#conditional-loading-spinner condition=isLoading}}
<div class="sections">
{{plugin-outlet name="admin-dashboard-moderation-top"}}
<div class="moderators-activity section">
<div class="section-title">
<h2>{{i18n "admin.dashboard.moderators_activity"}}</h2>
{{period-chooser
period=period
action="changePeriod"
content=availablePeriods
fullDay=true}}
</div>
<div class="section-body">
{{admin-report
startDate=startDate
endDate=endDate
showHeader=false
dataSourceName="moderators_activity"}}
</div>
</div>
<div class="main-section">
{{admin-report
report=flagsStatusReport
startDate=lastWeek
reportOptions=flagsStatusOptions
endDate=endDate}}
{{admin-report
report=postEditsReport
startDate=lastWeek
endDate=endDate}}
{{plugin-outlet name="admin-dashboard-moderation-bottom"}}
</div>
</div>
{{/conditional-loading-spinner}}

View File

@ -1,60 +1,16 @@
<h3>
{{#link-to "adminReports"}}
{{i18n "admin.dashboard.all_reports"}}
{{/link-to}}
|
{{model.title}}
</h3>
{{#if model.description}}
<p>{{model.description}}</p>
{{/if}}
<div class="report-container">
<div class="visualization">
{{#conditional-loading-spinner condition=refreshing}}
<div class='view-options'>
{{#if viewingTable}}
{{i18n 'admin.dashboard.reports.view_table'}}
{{else}}
<a href {{action "viewAsTable"}}>{{i18n 'admin.dashboard.reports.view_table'}}</a>
{{/if}}
|
{{#if viewingGraph}}
{{i18n 'admin.dashboard.reports.view_graph'}}
{{else}}
<a href {{action "viewAsGraph"}}>{{i18n 'admin.dashboard.reports.view_graph'}}</a>
{{/if}}
</div>
{{#if viewingGraph}}
{{admin-graph model=model}}
{{else}}
{{admin-table-report model=model}}
{{/if}}
{{#if model.relatedReport}}
{{admin-table-report model=model.relatedReport}}
{{/if}}
{{/conditional-loading-spinner}}
</div>
<div class="filters">
<span>
{{i18n 'admin.dashboard.reports.start_date'}} {{date-picker-past value=startDate defaultDate=startDate}}
</span>
<span>
{{i18n 'admin.dashboard.reports.end_date'}} {{date-picker-past value=endDate defaultDate=endDate}}
</span>
{{#if showCategoryOptions}}
{{combo-box filterable=true valueAttribute="value" content=categoryOptions value=categoryId}}
{{/if}}
{{#if showGroupOptions}}
{{combo-box filterable=true valueAttribute="value" content=groupOptions value=groupId}}
{{/if}}
{{d-button action="refreshReport" class="btn-primary" label="admin.dashboard.reports.refresh_report" icon="refresh"}}
{{d-button action="exportCsv" label="admin.export_csv.button_text" icon="download"}}
{{admin-report
showAllReportsLink=true
dataSourceName=model.type
categoryId=category_id
groupId=group_id
reportOptions=reportOptions
startDate=start_date
endDate=end_date
showFilteringUI=true
onSelectCategory=(action "onSelectCategory")
onSelectStartDate=(action "onSelectStartDate")
onSelectEndDate=(action "onSelectEndDate")}}
</div>
</div>

View File

@ -5,7 +5,8 @@ export default DatePicker.extend({
_opts() {
return {
defaultDate: new Date(this.get("defaultDate")) || new Date(),
defaultDate:
moment(this.get("defaultDate"), "YYYY-MM-DD").toDate() || new Date(),
setDefaultDate: !!this.get("defaultDate"),
maxDate: new Date()
};

View File

@ -29,13 +29,17 @@ export default Ember.Component.extend({
weekdays: moment.weekdays(),
weekdaysShort: moment.weekdaysShort()
},
onSelect: date =>
this.set(
"value",
moment(date)
.locale("en")
.format("YYYY-MM-DD")
)
onSelect: date => {
const formattedDate = moment(date).format("YYYY-MM-DD");
if (this.attrs.onSelect) {
this.attrs.onSelect(formattedDate);
}
if (!this.element || this.isDestroying || this.isDestroyed) return;
this.set("value", formattedDate);
}
};
this._picker = new Pikaday(_.merge(default_opts, this._opts()));

View File

@ -591,6 +591,10 @@ export function clipboardData(e, canUpload) {
return { clipboard, types, canUpload, canPasteHtml };
}
export function isNumeric(input) {
return !isNaN(parseFloat(input)) && isFinite(input);
}
export function fillMissingDates(data, startDate, endDate) {
const startMoment = moment(startDate, "YYYY-MM-DD");
const endMoment = moment(endDate, "YYYY-MM-DD");

View File

@ -447,7 +447,7 @@ export default Ember.Component.extend(
if (get(this.actions, actionName)) {
run.next(() => this.send(actionName, ...params));
} else if (this.get(actionName)) {
run.next(() => this.get(actionName)());
run.next(() => this.get(actionName)(...params));
}
},

View File

@ -932,4 +932,8 @@ table#user-badges {
@import "common/admin/backups";
@import "common/admin/plugins";
@import "common/admin/admin_reports";
@import "common/admin/admin_report";
@import "common/admin/admin_report_chart";
@import "common/admin/admin_report_table";
@import "common/admin/admin_report_inline_table";
@import "common/admin/dashboard_previous";

View File

@ -0,0 +1,127 @@
.admin-report {
.report-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1em;
.report-title {
align-items: center;
display: flex;
justify-content: space-between;
.title {
margin: 0;
padding: 0;
border: 0;
font-size: $font-up-1;
.separator {
font-weight: normal;
}
.report-link {
color: $primary;
}
.separator + .report-link {
font-weight: normal;
}
}
.info {
cursor: pointer;
margin-left: 0.25em;
color: $primary-low-mid;
&:hover {
color: $primary-medium;
}
}
}
.trend {
align-items: center;
&.trending-down,
&.high-trending-down {
color: $danger;
}
&.trending-up,
&.high-trending-up {
color: $success;
}
&.no-change {
color: $primary-medium;
}
.trend-value {
font-size: $font-up-1;
}
.trend-icon {
font-size: $font-up-1;
font-weight: 700;
}
}
.mode-switch {
list-style: none;
display: flex;
margin: 0;
.mode {
display: inline;
.mode-button.current {
color: $tertiary;
}
}
}
}
.report-body {
display: flex;
justify-content: space-between;
.admin-report-table,
.admin-report-chart {
width: 100%;
}
.report-filters {
margin-left: 1em;
min-width: 250px;
display: flex;
flex-direction: column;
.filtering-control {
display: flex;
flex-direction: column;
margin-bottom: 1em;
}
.filtering-label {
}
.filtering-input {
width: 100%;
.date-picker-wrapper,
.combo-box,
.export-btn {
width: 100%;
.date-picker {
width: 100%;
box-sizing: border-box;
margin: 0;
}
}
}
}
.report-filters:only-child {
margin-left: auto;
}
}
}

View File

@ -0,0 +1,2 @@
.admin-report-chart {
}

View File

@ -0,0 +1,7 @@
.admin-report-inline-table {
.table-container {
display: flex;
flex-wrap: wrap;
flex: 1 1 auto;
}
}

View File

@ -0,0 +1,101 @@
.admin-report-table {
@media screen and (max-width: 650px) {
table {
tbody tr td {
font-size: $font-down-1;
}
}
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 0.5em;
button {
margin-left: 0.5em;
&.current {
color: $tertiary;
}
}
}
&.two-columns {
.report-table tbody tr td:first-child,
.report-table thead tr th:first-child {
text-align: left;
}
.report-table {
table-layout: auto;
}
}
table {
table-layout: fixed;
border: 1px solid $primary-low;
margin-top: 0;
margin-bottom: 10px;
tbody {
border: none;
tr {
td {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
td {
text-align: center;
padding: 8px;
}
}
}
thead {
border: 1px solid $primary-low;
.admin-report-table-header {
.sort-button {
outline: none;
background: none;
overflow: hidden;
text-overflow: ellipsis;
}
&.is-current-sort {
.d-icon {
color: $tertiary;
}
.sort-button:hover {
color: $primary-medium;
background: $primary-low;
}
}
&:not(.is-current-sort) .sort-button {
background: none;
&:hover {
color: $primary-medium;
background: $primary-low;
}
}
}
tr {
th {
text-align: center;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
}
}

View File

@ -1,8 +1,16 @@
.admin-reports {
h3 {
border-bottom: 1px solid $primary-low;
margin-bottom: 0.5em;
padding-bottom: 0.5em;
.admin-report {
width: 100%;
.report-header {
padding-bottom: 0.5em;
margin-bottom: 1em;
border-bottom: 1px solid $primary-low;
}
}
.admin-report-chart .chart-canvas-container .chart-canvas {
height: 400px;
}
.reports-list {

View File

@ -10,6 +10,40 @@
margin-bottom: 1em;
}
.navigation {
display: flex;
margin: 0 0 1em 0;
.navigation-item {
display: inline;
background: $secondary;
padding: 0.5em 1em;
}
.navigation-link {
font-weight: 700;
}
}
@mixin active-navigation-item {
border-radius: 3px 3px 0 0;
border: 1px solid $primary-low;
border-bottom: 10px solid $secondary;
}
&.moderation .navigation-item.moderation {
@include active-navigation-item;
}
&.general .navigation-item.general {
@include active-navigation-item;
}
.sections {
display: flex;
flex-direction: column;
}
.section-columns {
display: flex;
justify-content: space-between;
@ -177,7 +211,7 @@
}
}
.community-health {
.section {
.period-chooser .period-chooser-header {
.selected-name,
.d-icon {
@ -203,105 +237,7 @@
}
}
.dashboard-mini-chart {
.status {
display: flex;
justify-content: space-between;
margin-bottom: 0.5em;
padding: 0 0.45em 0 0;
.title {
font-size: $font-up-1;
font-weight: 700;
margin: 0;
a {
color: $primary;
}
.info {
cursor: pointer;
margin-left: 0.25em;
color: $primary-low-mid;
&:hover {
color: $primary-medium;
}
}
}
.trend {
align-items: center;
&.trending-down,
&.high-trending-down {
color: $danger;
}
&.trending-up,
&.high-trending-up {
color: $success;
}
&.no-change {
color: $primary-medium;
}
.trend-value {
font-size: $font-up-1;
}
.trend-icon {
font-size: $font-up-1;
font-weight: 700;
}
}
}
.conditional-loading-section {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1;
width: 100%;
min-width: 0;
}
@include breakpoint(medium) {
max-width: 100%;
}
&.is-loading {
height: 200px;
}
.loading-container.visible {
display: flex;
align-items: center;
height: 100%;
width: 100%;
}
.tooltip {
cursor: pointer;
}
.chart-title {
align-items: center;
display: flex;
justify-content: space-between;
h3 {
margin: 1em 0;
a,
a:visited {
color: $primary;
}
}
}
}
.dashboard-table {
.admin-report-table {
margin-bottom: 1em;
&.is-disabled {
@ -320,92 +256,14 @@
&.is-loading {
height: 150px;
}
.table-title {
align-items: center;
display: flex;
justify-content: space-between;
h3 {
margin: 1em 0 0 0;
}
}
table {
table-layout: fixed;
border: 1px solid $primary-low;
thead {
border: 1px solid $primary-low;
tr {
th {
text-align: center;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
tbody {
border-top: none;
tr {
td:first-child {
text-overflow: ellipsis;
overflow: hidden;
white-space: normal;
}
td {
text-align: center;
padding: 8px;
}
td.left {
text-align: left;
}
td.title {
text-align: left;
}
td.value {
text-align: right;
padding: 8px 21px 8px 8px; // accounting for negative right caret margin
&:nth-of-type(2) {
padding: 8px 12px 8px;
}
i {
margin-right: -12px; // align on caret
@media screen and (max-width: 650px) {
margin-right: -9px;
}
}
&.high-trending-up,
&.trending-up {
i {
color: $success;
}
}
&.high-trending-down,
&.trending-down {
i {
color: $danger;
}
}
}
}
}
}
}
.user-metrics {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin-left: -5%;
margin: 2em 0 0.75em -5%; // Negative margin allows for a margin when in 2-columns,
flex-direction: column;
.dashboard-inline-table {
// and "hides" margin when the item spans 100% width
flex: 1 0 auto;
@ -468,23 +326,7 @@
}
}
.dashboard-inline-table {
margin-left: 5%;
margin-bottom: 1.25em;
.table-title {
border-bottom: 1px solid $primary-low;
margin-bottom: 1em;
}
.table-container {
display: flex;
flex-wrap: wrap;
flex: 1 1 auto;
}
}
.dashboard-table.activity-metrics {
.admin-report.activity-metrics {
table {
@media screen and (min-width: 400px) {
table-layout: auto;
@ -505,6 +347,60 @@
padding: 8px 0 8px 4px;
}
}
tr {
td {
text-overflow: unset;
overflow: auto;
white-space: normal;
}
td:first-child {
text-overflow: ellipsis;
overflow: hidden;
white-space: normal;
}
}
td {
text-align: center;
padding: 8px;
}
td.left {
text-align: left;
}
td.title {
text-align: left;
}
td.value {
text-align: right;
padding: 8px 21px 8px 8px; // accounting for negative right caret margin
&:nth-of-type(2) {
padding: 8px 12px 8px;
}
i {
margin-right: -12px; // align on caret
@media screen and (max-width: 650px) {
margin-right: -9px;
}
}
&.high-trending-up,
&.trending-up {
i {
color: $success;
}
}
&.high-trending-down,
&.trending-down {
i {
color: $danger;
}
}
}
}
}
@ -547,3 +443,81 @@
}
}
}
.community-health.section {
margin-bottom: 1em;
}
.admin-report.activity-metrics {
table {
table-layout: auto;
}
}
.admin-report.users-by-type {
margin-top: 1em;
}
.admin-report.users-by-type,
.admin-report.users-by-trust-level {
margin-bottom: 1em;
flex: 1;
.report-header {
border-bottom: 1px solid $primary-medium;
padding-bottom: 0.5em;
border-bottom: 1px solid #e9e9e9;
}
}
.admin-report.moderators-activity {
tbody tr td.username,
thead tr th.username {
text-align: left;
}
}
.admin-report.trending-search {
tbody tr td.term,
thead tr th.term {
text-align: left;
}
}
.admin-report.top-traffic-sources {
tbody tr td.domain,
thead tr th.domain {
text-align: left;
}
}
.dashboard-next.moderation {
.admin-dashboard-moderation-top {
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-column-gap: 1em;
}
.main-section {
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-column-gap: 1em;
> * {
grid-column: span 12;
}
.admin-dashboard-moderation-bottom-outlet {
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-column-gap: 1em;
}
}
.admin-report.flags-status {
grid-column: span 6;
}
.admin-report.post-edits {
grid-column: span 6;
}
}

View File

@ -2,9 +2,35 @@ require 'disk_space'
class Admin::DashboardNextController < Admin::AdminController
def index
dashboard_data = AdminDashboardNextData.fetch_cached_stats
dashboard_data.merge!(version_check: DiscourseUpdates.check_version.as_json) if SiteSetting.version_checks?
dashboard_data[:disk_space] = DiskSpace.cached_stats if SiteSetting.enable_backups
render json: dashboard_data
data = AdminDashboardNextIndexData.fetch_cached_stats
if SiteSetting.version_checks?
data.merge!(version_check: DiscourseUpdates.check_version.as_json)
end
render json: data
end
def moderation
render json: AdminDashboardNextModerationData.fetch_cached_stats
end
def general
data = AdminDashboardNextGeneralData.fetch_cached_stats
if SiteSetting.enable_backups
data[:last_backup_taken_at] = last_backup_taken_at
data[:disk_space] = DiskSpace.cached_stats
end
render json: data
end
private
def last_backup_taken_at
if last_backup = Backup.all.last
File.ctime(last_backup.path).utc
end
end
end

View File

@ -1,4 +1,7 @@
require_dependency 'admin_dashboard_data'
require_dependency 'admin_dashboard_next_index_data'
require_dependency 'admin_dashboard_next_general_data'
require_dependency 'admin_dashboard_next_moderation_data'
require_dependency 'group'
require_dependency 'group_message'
@ -15,7 +18,9 @@ module Jobs
end
# TODO: decide if we want to keep caching this every 30 minutes
AdminDashboardNextData.refresh_stats
AdminDashboardNextIndexData.refresh_stats
AdminDashboardNextGeneralData.refresh_stats
AdminDashboardNextModerationData.refresh_stats
AdminDashboardData.refresh_stats
end
end

View File

@ -1,51 +1,31 @@
class AdminDashboardNextData
include StatsCacheable
REPORTS = %w{
page_view_total_reqs
visits
time_to_first_response
likes
flags
user_to_user_private_messages_with_replies
}
def initialize(opts = {})
@opts = opts
end
def self.fetch_stats
AdminDashboardNextData.new.as_json
self.class.new.as_json
end
def self.stats_cache_key
'dash-next-stats'
def self.fetch_stats
new.as_json
end
def get_json
{}
end
def as_json(_options = nil)
@json ||= get_json
end
def get_json
json = {
reports: AdminDashboardNextData.reports(REPORTS),
updated_at: Time.zone.now.as_json
}
if SiteSetting.enable_backups
json[:last_backup_taken_at] = last_backup_taken_at
end
json
end
def last_backup_taken_at
if last_backup = Backup.all.last
File.ctime(last_backup.path).utc
end
end
def self.reports(source)
source.map { |type| Report.find(type).as_json }
end
def self.stats_cache_key
'dashboard-next-data'
end
end

View File

@ -0,0 +1,27 @@
class AdminDashboardNextGeneralData < AdminDashboardNextData
def reports
@reports ||= %w{
page_view_total_reqs
visits
time_to_first_response
likes
flags
user_to_user_private_messages_with_replies
top_referred_topics
users_by_type
users_by_trust_level
trending_search
}
end
def get_json
{
reports: self.class.reports(reports),
updated_at: Time.zone.now.as_json
}
end
def self.stats_cache_key
'general-dashboard-data'
end
end

View File

@ -0,0 +1,14 @@
class AdminDashboardNextIndexData < AdminDashboardNextData
def get_json
{
updated_at: Time.zone.now.as_json
}
end
def self.stats_cache_key
'index-dashboard-data'
end
# TODO: problems should be loaded from this model
# and not from a separate model/route
end

View File

@ -0,0 +1,19 @@
class AdminDashboardNextModerationData < AdminDashboardNextData
def reports
@reports ||= %w{
flags_status
post_edits
}
end
def get_json
{
reports: self.class.reports(reports),
updated_at: Time.zone.now.as_json
}
end
def self.stats_cache_key
'moderation-dashboard-data'
end
end

View File

@ -1,6 +1,6 @@
class IncomingLinksReport
attr_accessor :type, :data, :y_titles, :start_date, :limit
attr_accessor :type, :data, :y_titles, :start_date, :end_date, :limit
def initialize(type)
@type = type
@ -15,7 +15,8 @@ class IncomingLinksReport
xaxis: I18n.t("reports.#{self.type}.xaxis"),
ytitles: self.y_titles,
data: self.data,
start_date: start_date
start_date: start_date,
end_date: end_date
}
end
@ -27,6 +28,7 @@ class IncomingLinksReport
report = IncomingLinksReport.new(type)
report.start_date = _opts[:start_date] || 30.days.ago
report.end_date = _opts[:end_date] || Time.now.end_of_day
report.limit = _opts[:limit].to_i if _opts[:limit]
send(report_method, report)
@ -38,8 +40,8 @@ class IncomingLinksReport
report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks")
report.y_titles[:num_topics] = I18n.t("reports.#{report.type}.num_topics")
num_clicks = link_count_per_user(start_date: report.start_date)
num_topics = topic_count_per_user(start_date: report.start_date)
num_clicks = link_count_per_user(start_date: report.start_date, end_date: report.end_date)
num_topics = topic_count_per_user(start_date: report.start_date, end_date: report.end_date)
user_id_lookup = User.where(username: num_clicks.keys).select(:id, :username).inject({}) { |sum, v| sum[v.username] = v.id; sum; }
report.data = []
num_clicks.each_key do |username|
@ -48,19 +50,19 @@ class IncomingLinksReport
report.data = report.data.sort_by { |x| x[:num_clicks] }.reverse[0, 10]
end
def self.per_user(start_date:)
def self.per_user(start_date:, end_date:)
@per_user_query ||= public_incoming_links
.where('incoming_links.created_at > ? AND incoming_links.user_id IS NOT NULL', start_date)
.where('incoming_links.created_at > ? AND incoming_links.created_at < ? AND incoming_links.user_id IS NOT NULL', start_date, end_date)
.joins(:user)
.group('users.username')
end
def self.link_count_per_user(start_date:)
per_user(start_date: start_date).count
def self.link_count_per_user(start_date:, end_date:)
per_user(start_date: start_date, end_date: end_date).count
end
def self.topic_count_per_user(start_date:)
per_user(start_date: start_date).joins(:post).count("DISTINCT posts.topic_id")
def self.topic_count_per_user(start_date:, end_date:)
per_user(start_date: start_date, end_date: end_date).joins(:post).count("DISTINCT posts.topic_id")
end
# Return top 10 domains that brought traffic to the site within the last 30 days
@ -69,7 +71,7 @@ class IncomingLinksReport
report.y_titles[:num_topics] = I18n.t("reports.#{report.type}.num_topics")
report.y_titles[:num_users] = I18n.t("reports.#{report.type}.num_users")
num_clicks = link_count_per_domain(start_date: report.start_date)
num_clicks = link_count_per_domain(start_date: report.start_date, end_date: report.end_date)
num_topics = topic_count_per_domain(num_clicks.keys)
report.data = []
num_clicks.each_key do |domain|
@ -78,9 +80,9 @@ class IncomingLinksReport
report.data = report.data.sort_by { |x| x[:num_clicks] }.reverse[0, 10]
end
def self.link_count_per_domain(limit: 10, start_date:)
def self.link_count_per_domain(limit: 10, start_date:, end_date:)
public_incoming_links
.where('incoming_links.created_at > ?', start_date)
.where('incoming_links.created_at > ? AND incoming_links.created_at < ?', start_date, end_date)
.joins(incoming_referer: :incoming_domain)
.group('incoming_domains.name')
.order('count_all DESC')
@ -102,7 +104,7 @@ class IncomingLinksReport
def self.report_top_referred_topics(report)
report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks")
num_clicks = link_count_per_topic(start_date: report.start_date)
num_clicks = link_count_per_topic(start_date: report.start_date, end_date: report.end_date)
num_clicks = num_clicks.to_a.sort_by { |x| x[1] }.last(report.limit || 10).reverse
report.data = []
topics = Topic.select('id, slug, title').where('id in (?)', num_clicks.map { |z| z[0] })
@ -115,9 +117,9 @@ class IncomingLinksReport
report.data
end
def self.link_count_per_topic(start_date:)
def self.link_count_per_topic(start_date:, end_date:)
public_incoming_links
.where('incoming_links.created_at > ? AND topic_id IS NOT NULL', start_date)
.where('incoming_links.created_at > ? AND incoming_links.created_at < ? AND topic_id IS NOT NULL', start_date, end_date)
.group('topic_id')
.count
end

View File

@ -1,11 +1,12 @@
require_dependency 'topic_subtype'
class Report
attr_accessor :type, :data, :total, :prev30Days, :start_date,
:end_date, :category_id, :group_id, :labels, :async,
:prev_period, :facets, :limit, :processing, :average, :percent,
:higher_is_better
:higher_is_better, :icon, :modes, :category_filtering,
:group_filtering, :prev_data, :prev_start_date, :prev_end_date,
:dates_filtering, :timeout
def self.default_days
30
@ -15,9 +16,15 @@ class Report
@type = type
@start_date ||= Report.default_days.days.ago.beginning_of_day
@end_date ||= Time.zone.now.end_of_day
@prev_end_date = @start_date
@average = false
@percent = false
@higher_is_better = true
@category_filtering = false
@group_filtering = false
@modes = [:table, :chart]
@prev_data = nil
@dates_filtering = true
end
def self.cache_key(report)
@ -39,6 +46,28 @@ class Report
end
end
def self.wrap_slow_query(timeout = 20000)
begin
ActiveRecord::Base.connection.transaction do
# Set a statement timeout so we can't tie up the server
DB.exec "SET LOCAL statement_timeout = #{timeout}"
yield
end
rescue ActiveRecord::QueryCanceled
return :timeout
end
nil
end
def prev_start_date
self.start_date - (self.end_date - self.start_date)
end
def prev_end_date
self.start_date
end
def as_json(options = nil)
description = I18n.t("reports.#{type}.description", default: "")
@ -51,18 +80,29 @@ class Report
data: data,
start_date: start_date&.iso8601,
end_date: end_date&.iso8601,
prev_data: self.prev_data,
prev_start_date: prev_start_date&.iso8601,
prev_end_date: prev_end_date&.iso8601,
category_id: category_id,
group_id: group_id,
prev30Days: self.prev30Days,
dates_filtering: self.dates_filtering,
report_key: Report.cache_key(self),
labels: labels,
labels: labels || [
{ type: :date, properties: [:x], title: I18n.t("reports.default.labels.day") },
{ type: :number, properties: [:y], title: I18n.t("reports.default.labels.count") },
],
processing: self.processing,
average: self.average,
percent: self.percent,
higher_is_better: self.higher_is_better
higher_is_better: self.higher_is_better,
category_filtering: self.category_filtering,
group_filtering: self.group_filtering,
modes: self.modes
}.tap do |json|
json[:total] = total if total
json[:prev_period] = prev_period if prev_period
json[:timeout] = self.timeout if self.timeout
json[:total] = self.total if self.total
json[:prev_period] = self.prev_period if self.prev_period
json[:prev30Days] = self.prev30Days if self.prev30Days
json[:limit] = self.limit if self.limit
@ -105,6 +145,8 @@ class Report
end
def self.find(type, opts = nil)
clear_cache
report = _get(type, opts)
report_method = :"report_#{type}"
@ -129,6 +171,10 @@ class Report
ApplicationRequest.where(req_type: ApplicationRequest.req_types[filter])
end
if filter == :page_view_total
report.icon = 'file'
end
report.data = []
data.where('date >= ? AND date <= ?', report.start_date, report.end_date)
.order(date: :asc)
@ -147,6 +193,9 @@ class Report
end
def self.report_visits(report)
report.group_filtering = true
report.icon = 'user'
basic_report_about report, UserVisit, :by_day, report.start_date, report.end_date, report.group_id
add_counts report, UserVisit, 'visited_at'
@ -159,13 +208,16 @@ class Report
end
def self.report_signups(report)
report.group_filtering = true
if report.group_id
basic_report_about report, User.real, :count_by_signup_date, report.start_date, report.end_date, report.group_id
add_counts report, User.real, 'users.created_at'
else
report_about report, User.real, :count_by_signup_date
end
add_prev_data report, User.real, :count_by_signup_date, report.prev_start_date, report.prev_end_date
end
def self.report_new_contributors(report)
@ -183,8 +235,9 @@ class Report
end
if report.facets.include?(:prev_period)
prev_period_data = User.real.count_by_first_post(report.start_date - (report.end_date - report.start_date), report.start_date)
prev_period_data = User.real.count_by_first_post(report.prev_start_date, report.prev_end_date)
report.prev_period = prev_period_data.sum { |k, v| v }
report.prev_data = prev_period_data.map { |k, v| { x: k, y: v } }
end
data.each do |key, value|
@ -209,7 +262,7 @@ class Report
end
if report.facets.include?(:prev_period)
prev_data = UserAction.count_daily_engaged_users(report.start_date - (report.end_date - report.start_date), report.start_date)
prev_data = UserAction.count_daily_engaged_users(report.prev_start_date, report.prev_end_date)
prev = prev_data.sum { |k, v| v }
if prev > 0
@ -252,7 +305,7 @@ class Report
end
if report.facets.include?(:prev_period)
report.prev_period = dau_avg.call(report.start_date - (report.end_date - report.start_date), report.start_date)
report.prev_period = dau_avg.call(report.prev_start_date, report.prev_end_date)
end
if report.facets.include?(:prev30Days)
@ -261,6 +314,7 @@ class Report
end
def self.report_profile_views(report)
report.group_filtering = true
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
@ -270,6 +324,7 @@ class Report
end
def self.report_topics(report)
report.category_filtering = true
basic_report_about report, Topic, :listable_count_per_day, report.start_date, report.end_date, report.category_id
countable = Topic.listable_topics
countable = countable.where(category_id: report.category_id) if report.category_id
@ -277,6 +332,8 @@ class Report
end
def self.report_posts(report)
report.modes = [:table, :chart]
report.category_filtering = true
basic_report_about report, Post, :public_posts_count_per_day, report.start_date, report.end_date, report.category_id
countable = Post.public_posts.where(post_type: Post.types[:regular])
countable = countable.joins(:topic).where("topics.category_id = ?", report.category_id) if report.category_id
@ -284,6 +341,8 @@ class Report
end
def self.report_time_to_first_response(report)
report.category_filtering = true
report.icon = 'reply'
report.higher_is_better = false
report.data = []
Topic.time_to_first_response_per_day(report.start_date, report.end_date, category_id: report.category_id).each do |r|
@ -294,6 +353,7 @@ class Report
end
def self.report_topics_with_no_response(report)
report.category_filtering = true
report.data = []
Topic.with_no_response_per_day(report.start_date, report.end_date, report.category_id).each do |r|
report.data << { x: r["date"], y: r["count"].to_i }
@ -319,12 +379,21 @@ class Report
end
end
def self.add_prev_data(report, subject_class, report_method, *args)
if report.modes.include?(:chart) && report.facets.include?(:prev_period)
prev_data = subject_class.send(report_method, *args)
report.prev_data = prev_data.map { |k, v| { x: k, y: v } }
end
end
def self.add_counts(report, subject_class, query_column = 'created_at')
if report.facets.include?(:prev_period)
report.prev_period = subject_class
prev_data = subject_class
.where("#{query_column} >= ? and #{query_column} < ?",
(report.start_date - (report.end_date - report.start_date)),
report.start_date).count
report.prev_start_date,
report.prev_end_date)
report.prev_period = prev_data.count
end
if report.facets.include?(:total)
@ -342,6 +411,15 @@ class Report
def self.report_users_by_trust_level(report)
report.data = []
report.modes = [:table]
report.dates_filtering = false
report.labels = [
{ properties: [:key], title: I18n.t("reports.users_by_trust_level.labels.level") },
{ properties: [:y], title: I18n.t("reports.default.labels.count") },
]
User.real.group('trust_level').count.sort.each do |level, count|
key = TrustLevel.levels[level.to_i]
url = Proc.new { |k| "/admin/users/list/#{k}" }
@ -351,6 +429,8 @@ class Report
# Post action counts:
def self.report_flags(report)
report.category_filtering = true
report.icon = 'flag'
report.higher_is_better = false
basic_report_about report, PostAction, :flag_count_by_date, report.start_date, report.end_date, report.category_id
@ -360,10 +440,14 @@ class Report
end
def self.report_likes(report)
report.category_filtering = true
report.icon = 'heart'
post_action_report report, PostActionType.types[:like]
end
def self.report_bookmarks(report)
report.category_filtering = true
report.icon = 'bookmark'
post_action_report report, PostActionType.types[:bookmark]
end
@ -380,47 +464,68 @@ class Report
# Private messages counts:
def self.private_messages_report(report, topic_subtype)
report.icon = 'envelope'
basic_report_about report, Topic, :private_message_topics_count_per_day, report.start_date, report.end_date, topic_subtype
add_counts report, Topic.private_messages.with_subtype(topic_subtype), 'topics.created_at'
end
def self.report_user_to_user_private_messages(report)
report.icon = 'envelope'
private_messages_report report, TopicSubtype.user_to_user
end
def self.report_user_to_user_private_messages_with_replies(report)
report.icon = 'envelope'
topic_subtype = TopicSubtype.user_to_user
basic_report_about report, Post, :private_messages_count_per_day, report.start_date, report.end_date, topic_subtype
add_counts report, Post.private_posts.with_topic_subtype(topic_subtype), 'posts.created_at'
end
def self.report_system_private_messages(report)
report.icon = 'envelope'
private_messages_report report, TopicSubtype.system_message
end
def self.report_moderator_warning_private_messages(report)
report.icon = 'envelope'
private_messages_report report, TopicSubtype.moderator_warning
end
def self.report_notify_moderators_private_messages(report)
report.icon = 'envelope'
private_messages_report report, TopicSubtype.notify_moderators
end
def self.report_notify_user_private_messages(report)
report.icon = 'envelope'
private_messages_report report, TopicSubtype.notify_user
end
def self.report_web_crawlers(report)
report.labels = [
{ type: :string, properties: [:user_agent], title: I18n.t("reports.web_crawlers.labels.user_agent") },
{ properties: [:count], title: I18n.t("reports.web_crawlers.labels.page_views") }
]
report.modes = [:table]
report.data = WebCrawlerRequest.where('date >= ? and date <= ?', report.start_date, report.end_date)
.limit(200)
.order('sum_count DESC')
.group(:user_agent).sum(:count)
.map { |ua, count| { x: ua, y: count } }
.map { |ua, count| { user_agent: ua, count: count } }
end
def self.report_users_by_type(report)
report.data = []
report.modes = [:table]
report.dates_filtering = false
report.labels = [
{ properties: [:x], title: I18n.t("reports.users_by_type.labels.type") },
{ properties: [:y], title: I18n.t("reports.default.labels.count") },
]
label = Proc.new { |x| I18n.t("reports.users_by_type.xaxis_labels.#{x}") }
url = Proc.new { |key| "/admin/users/list/#{key}" }
@ -438,15 +543,49 @@ class Report
end
def self.report_top_referred_topics(report)
report.labels = [I18n.t("reports.top_referred_topics.xaxis"),
I18n.t("reports.top_referred_topics.num_clicks")]
result = IncomingLinksReport.find(:top_referred_topics, start_date: 7.days.ago, limit: report.limit)
report.data = result.data
report.modes = [:table]
report.labels = [
{ type: :link, properties: [:topic_title, :topic_url], title: I18n.t("reports.top_referred_topics.labels.topic") },
{ properties: [:num_clicks], title: I18n.t("reports.top_referred_topics.labels.num_clicks") }
]
options = { end_date: report.end_date, start_date: report.start_date, limit: report.limit || 8 }
result = nil
report.timeout = wrap_slow_query do
result = IncomingLinksReport.find(:top_referred_topics, options)
report.data = result.data
end
end
def self.report_top_traffic_sources(report)
report.modes = [:table]
report.labels = [
{ properties: [:domain], title: I18n.t("reports.top_traffic_sources.labels.domain") },
{ properties: [:num_clicks], title: I18n.t("reports.top_traffic_sources.labels.num_clicks") },
{ properties: [:num_topics], title: I18n.t("reports.top_traffic_sources.labels.num_topics") }
]
options = { end_date: report.end_date, start_date: report.start_date, limit: report.limit || 8 }
result = nil
report.timeout = wrap_slow_query do
result = IncomingLinksReport.find(:top_traffic_sources, options)
report.data = result.data
end
end
def self.report_trending_search(report)
report.labels = [
{ properties: [:term], title: I18n.t("reports.trending_search.labels.term") },
{ properties: [:unique_searches], title: I18n.t("reports.trending_search.labels.searches") },
{ type: :percent, properties: [:ctr], title: I18n.t("reports.trending_search.labels.click_through") }
]
report.data = []
report.modes = [:table]
select_sql = <<~SQL
lower(term) term,
COUNT(*) AS searches,
@ -463,10 +602,6 @@ class Report
.order('unique_searches DESC, click_through ASC, term ASC')
.limit(report.limit || 20).to_a
report.labels = [:term, :searches, :click_through].map { |key|
I18n.t("reports.trending_search.labels.#{key}")
}
trends.each do |trend|
ctr =
if trend.click_through == 0 || trend.searches == 0
@ -478,8 +613,300 @@ class Report
report.data << {
term: trend.term,
unique_searches: trend.unique_searches,
ctr: (ctr * 100).ceil(1).to_s + "%"
ctr: (ctr * 100).ceil(1)
}
end
end
def self.report_moderators_activity(report)
report.labels = [
{ type: :link, properties: [:username, :user_url], title: I18n.t("reports.moderators_activity.labels.moderator") },
{ properties: [:flag_count], title: I18n.t("reports.moderators_activity.labels.flag_count") },
{ type: :seconds, properties: [:time_read], title: I18n.t("reports.moderators_activity.labels.time_read") },
{ properties: [:topic_count], title: I18n.t("reports.moderators_activity.labels.topic_count") },
{ properties: [:post_count], title: I18n.t("reports.moderators_activity.labels.post_count") }
]
report.modes = [:table]
report.data = []
mod_data = {}
User.real.where(moderator: true).find_each do |u|
mod_data[u.id] = {
user_id: u.id,
username: u.username,
user_url: "/admin/users/#{u.id}/#{u.username}"
}
end
time_read_query = <<~SQL
SELECT SUM(uv.time_read) AS time_read,
uv.user_id
FROM user_visits uv
JOIN users u
ON u.id = uv.user_id
WHERE u.moderator = 'true'
AND u.id > 0
AND uv.visited_at >= '#{report.start_date}'
AND uv.visited_at <= '#{report.end_date}'
GROUP BY uv.user_id
SQL
flag_count_query = <<~SQL
WITH period_actions AS (
SELECT agreed_by_id,
disagreed_by_id
FROM post_actions
WHERE post_action_type_id IN (#{PostActionType.flag_types_without_custom.values.join(',')})
AND created_at >= '#{report.start_date}'
AND created_at <= '#{report.end_date}'
),
agreed_flags AS (
SELECT pa.agreed_by_id AS user_id,
COUNT(*) AS flag_count
FROM period_actions pa
JOIN users u
ON u.id = pa.agreed_by_id
WHERE u.moderator = 'true'
AND u.id > 0
GROUP BY agreed_by_id
),
disagreed_flags AS (
SELECT pa.disagreed_by_id AS user_id,
COUNT(*) AS flag_count
FROM period_actions pa
JOIN users u
ON u.id = pa.disagreed_by_id
WHERE u.moderator = 'true'
AND u.id > 0
GROUP BY disagreed_by_id
)
SELECT
COALESCE(af.user_id, df.user_id) AS user_id,
COALESCE(af.flag_count, 0) + COALESCE(df.flag_count, 0) AS flag_count
FROM agreed_flags af
FULL OUTER JOIN disagreed_flags df
ON df.user_id = af.user_id
SQL
topic_count_query = <<~SQL
SELECT t.user_id,
COUNT(*) AS topic_count
FROM topics t
JOIN users u
ON u.id = t.user_id
AND u.moderator = 'true'
AND u.id > 0
AND t.created_at >= '#{report.start_date}'
AND t.created_at <= '#{report.end_date}'
GROUP BY t.user_id
SQL
post_count_query = <<~SQL
SELECT p.user_id,
COUNT(*) AS post_count
FROM posts p
JOIN users u
ON u.id = p.user_id
WHERE u.moderator = 'true'
AND u.id > 0
AND p.created_at >= '#{report.start_date}'
AND p.created_at <= '#{report.end_date}'
GROUP BY user_id
SQL
DB.query(time_read_query).each do |row|
mod_data[row.user_id][:time_read] = row.time_read
end
DB.query(flag_count_query).each do |row|
mod_data[row.user_id][:flag_count] = row.flag_count
end
DB.query(topic_count_query).each do |row|
mod_data[row.user_id][:topic_count] = row.topic_count
end
DB.query(post_count_query).each do |row|
mod_data[row.user_id][:post_count] = row.post_count
end
report.data = mod_data.values
end
def self.report_flags_status(report)
report.modes = [:table]
report.labels = [
{ properties: [:action_type], title: I18n.t("reports.flags_status.labels.flag") },
{ type: :link, properties: [:staff_username, :staff_url], title: I18n.t("reports.flags_status.labels.assigned") },
{ type: :link, properties: [:poster_username, :poster_url], title: I18n.t("reports.flags_status.labels.poster") },
{ type: :link, properties: [:flagger_username, :flagger_url], title: I18n.t("reports.flags_status.labels.flagger") },
{ type: :seconds, properties: [:response_time], title: I18n.t("reports.flags_status.labels.time_to_resolution") }
]
report.data = []
flag_types = PostActionType.flag_types_without_custom
sql = <<~SQL
WITH period_actions AS (
SELECT id,
post_action_type_id,
created_at,
agreed_at,
disagreed_at,
deferred_at,
agreed_by_id,
disagreed_by_id,
deferred_by_id,
post_id,
user_id,
COALESCE(disagreed_at, agreed_at, deferred_at) AS responded_at
FROM post_actions
WHERE post_action_type_id IN (#{PostActionType.flag_types_without_custom.values.join(',')})
AND created_at >= '#{report.start_date}'
AND created_at <= '#{report.end_date}'
),
poster_data AS (
SELECT pa.id,
p.user_id AS poster_id,
u.username AS poster_username
FROM period_actions pa
JOIN posts p
ON p.id = pa.post_id
JOIN users u
ON u.id = p.user_id
),
flagger_data AS (
SELECT pa.id,
u.id AS flagger_id,
u.username AS flagger_username
FROM period_actions pa
JOIN users u
ON u.id = pa.user_id
),
staff_data AS (
SELECT pa.id,
u.id AS staff_id,
u.username AS staff_username
FROM period_actions pa
JOIN users u
ON u.id = COALESCE(pa.agreed_by_id, pa.disagreed_by_id, pa.deferred_by_id)
)
SELECT
sd.staff_username,
sd.staff_id,
pd.poster_username,
pd.poster_id,
fd.flagger_username,
fd.flagger_id,
pa.post_action_type_id,
pa.created_at,
pa.agreed_at,
pa.disagreed_at,
pa.deferred_at,
pa.agreed_by_id,
pa.disagreed_by_id,
pa.deferred_by_id,
COALESCE(pa.disagreed_at, pa.agreed_at, pa.deferred_at) AS responded_at
FROM period_actions pa
FULL OUTER JOIN staff_data sd
ON sd.id = pa.id
FULL OUTER JOIN flagger_data fd
ON fd.id = pa.id
FULL OUTER JOIN poster_data pd
ON pd.id = pa.id
SQL
DB.query(sql).each do |row|
data = {}
data[:action_type] = flag_types.key(row.post_action_type_id).to_s
data[:staff_username] = row.staff_username
data[:staff_id] = row.staff_id
if row.staff_username && row.staff_id
data[:staff_url] = "/admin/users/#{row.staff_id}/#{row.staff_username}"
end
data[:poster_username] = row.poster_username
data[:poster_id] = row.poster_id
data[:poster_url] = "/admin/users/#{row.poster_id}/#{row.poster_username}"
data[:flagger_id] = row.flagger_id
data[:flagger_username] = row.flagger_username
data[:flagger_url] = "/admin/users/#{row.flagger_id}/#{row.flagger_username}"
if row.agreed_by_id
data[:resolution] = I18n.t("reports.flags_status.values.agreed")
elsif row.disagreed_by_id
data[:resolution] = I18n.t("reports.flags_status.values.disagreed")
elsif row.deferred_by_id
data[:resolution] = I18n.t("reports.flags_status.values.deferred")
else
data[:resolution] = I18n.t("reports.flags_status.values.no_action")
end
data[:response_time] = row.responded_at ? row.responded_at - row.created_at : nil
report.data << data
end
end
def self.report_post_edits(report)
report.modes = [:table]
report.labels = [
{ type: :link, properties: [:post_id, :post_url], title: I18n.t("reports.post_edits.labels.post") },
{ type: :link, properties: [:editor_username, :editor_url], title: I18n.t("reports.post_edits.labels.editor") },
{ type: :link, properties: [:author_username, :author_url], title: I18n.t("reports.post_edits.labels.author") },
{ type: :text, properties: [:edit_reason], title: I18n.t("reports.post_edits.labels.edit_reason") }
]
report.data = []
sql = <<~SQL
WITH period_revisions AS (
SELECT pr.user_id AS editor_id,
pr.number AS revision_version,
pr.created_at,
pr.post_id,
u.username AS editor_username
FROM post_revisions pr
JOIN users u
ON u.id = pr.user_id
WHERE pr.created_at >= '#{report.start_date}'
AND pr.created_at <= '#{report.end_date}'
ORDER BY pr.created_at DESC
LIMIT 20
)
SELECT pr.editor_id,
pr.editor_username,
p.user_id AS author_id,
u.username AS author_username,
pr.revision_version,
p.version AS post_version,
pr.post_id,
p.topic_id,
p.post_number,
p.edit_reason,
pr.created_at
FROM period_revisions pr
JOIN posts p
ON p.id = pr.post_id
JOIN users u
ON u.id = p.user_id
SQL
DB.query(sql).each do |r|
revision = {}
revision[:editor_id] = r.editor_id
revision[:editor_username] = r.editor_username
revision[:editor_url] = "/admin/users/#{r.editor_id}/#{r.editor_username}"
revision[:author_id] = r.author_id
revision[:author_username] = r.author_username
revision[:author_url] = "/admin/users/#{r.author_id}/#{r.author_username}"
revision[:edit_reason] = r.revision_version == r.post_version ? r.edit_reason : nil
revision[:created_at] = r.created_at
revision[:post_id] = r.post_id
revision[:post_url] = "/t/-/#{r.topic_id}/#{r.post_number}"
report.data << revision
end
end
end

View File

@ -2777,9 +2777,14 @@ en:
page_views_short: "Pageviews"
show_traffic_report: "Show Detailed Traffic Report"
community_health: Community health
moderators_activity: Moderators activity
whats_new_in_discourse: Whats new in Discourse?
activity_metrics: Activity Metrics
all_reports: "All reports"
general_tab: "General"
moderation_tab: "Moderation"
disabled: Disabled
timeout_error: Sorry, query is taking too long, please pick a shorter interval
reports:
trend_title: "%{percent} change. Currently %{current}, was %{prev} in previous period."
@ -2798,8 +2803,8 @@ en:
end_date: "End Date"
groups: "All groups"
disabled: "This report is disabled"
total_for_period: "Total for period"
total: "Total"
totals_for_sample: "Totals for sample"
total: "All time total"
trending_search:
more: '<a href="/admin/logs/search_logs">Search logs</a>'
disabled: 'Trending search report is disabled. Enable <a href="/admin/site_settings/category/all_results?filter=log%20search%20queries">log search queries</a> to collect data.'

View File

@ -840,6 +840,38 @@ en:
write: "Write all"
reports:
default:
labels:
count: Count
day: Day
post_edits:
title: "Post edits"
labels:
post: Post
editor: Editor
author: Author
edit_reason: Reason
moderators_activity:
title: "Moderators activity"
labels:
moderator: Moderator
flag_count: Flags reviewed
time_read: Time reading
topic_count: Topics
post_count: Posts
flags_status:
title: "Flags status"
values:
agreed: Agreed
disagreed: Disagreed
deferred: Deferred
no_action: No action
labels:
flag: Type
assigned: Assigned
poster: Poster
flagger: Flagger
time_to_resolution: Resolution time
visits:
title: "User Visits"
xaxis: "Day"
@ -898,10 +930,14 @@ en:
title: "Users per Trust Level"
xaxis: "Trust Level"
yaxis: "Number of Users"
labels:
level: Level
users_by_type:
title: "Users per Type"
xaxis: "Type"
yaxis: "Number of Users"
labels:
type: Type
xaxis_labels:
admin: Admin
moderator: Moderator
@ -952,10 +988,15 @@ en:
num_clicks: "Clicks"
num_topics: "Topics"
num_users: "Users"
labels:
domain: Domain
num_clicks: Clicks
num_topics: Topics
top_referred_topics:
title: "Top Referred Topics"
xaxis: "Topic"
num_clicks: "Clicks"
labels:
num_clicks: "Clicks"
topic: "Topic"
page_view_anon_reqs:
title: "Anonymous"
xaxis: "Day"
@ -1018,8 +1059,9 @@ en:
yaxis: "Number of visits"
web_crawlers:
title: "Web Crawler Requests"
xaxis: "User Agent"
yaxis: "Pageviews"
labels:
user_agent: "User Agent"
page_views: "Pageviews"
dashboard:
rails_env_warning: "Your server is running in %{env} mode."

View File

@ -238,6 +238,8 @@ Discourse::Application.routes.draw do
get "dashboard-next" => "dashboard_next#index"
get "dashboard-old" => "dashboard#index"
get "dashboard/moderation" => "dashboard_next#moderation"
get "dashboard/general" => "dashboard_next#general"
resources :dashboard, only: [:index] do
collection do

View File

@ -346,7 +346,7 @@ describe Report do
it "returns a report with data" do
expect(report.data[0][:term]).to eq("ruby")
expect(report.data[0][:unique_searches]).to eq(2)
expect(report.data[0][:ctr]).to eq('33.4%')
expect(report.data[0][:ctr]).to eq(33.4)
expect(report.data[1][:term]).to eq("php")
expect(report.data[1][:unique_searches]).to eq(1)
@ -435,4 +435,173 @@ describe Report do
expect(r.data[0][:y]).to eq(1)
end
end
describe 'flags_status' do
let(:report) { Report.find('flags_status') }
context "no flags" do
it "returns an empty report" do
expect(report.data).to be_blank
end
end
context "with flags" do
let(:flagger) { Fabricate(:user) }
let(:post) { Fabricate(:post) }
before do
PostAction.act(flagger, post, PostActionType.types[:spam], message: 'bad')
end
it "returns a report with data" do
expect(report.data).to be_present
row = report.data[0]
expect(row[:action_type]).to eq("spam")
expect(row[:staff_username]).to eq(nil)
expect(row[:staff_id]).to eq(nil)
expect(row[:staff_url]).to eq(nil)
expect(row[:poster_username]).to eq(post.user.username)
expect(row[:poster_id]).to eq(post.user.id)
expect(row[:poster_url]).to eq("/admin/users/#{post.user.id}/#{post.user.username}")
expect(row[:flagger_id]).to eq(flagger.id)
expect(row[:flagger_username]).to eq(flagger.username)
expect(row[:flagger_url]).to eq("/admin/users/#{flagger.id}/#{flagger.username}")
expect(row[:resolution]).to eq("No action")
expect(row[:response_time]).to eq(nil)
end
end
end
describe 'post_edits' do
let(:report) { Report.find('post_edits') }
context "no edits" do
it "returns an empty report" do
expect(report.data).to be_blank
end
end
context "with edits" do
let(:editor) { Fabricate(:user) }
let(:post) { Fabricate(:post) }
before do
post.revise(editor, raw: 'updated body', edit_reason: 'not cool')
end
it "returns a report with data" do
expect(report.data).to be_present
expect(report.data.count).to be(1)
row = report.data[0]
expect(row[:editor_id]).to eq(editor.id)
expect(row[:editor_username]).to eq(editor.username)
expect(row[:editor_url]).to eq("/admin/users/#{editor.id}/#{editor.username}")
expect(row[:author_id]).to eq(post.user.id)
expect(row[:author_username]).to eq(post.user.username)
expect(row[:author_url]).to eq("/admin/users/#{post.user.id}/#{post.user.username}")
expect(row[:edit_reason]).to eq("not cool")
expect(row[:post_id]).to eq(post.id)
expect(row[:post_url]).to eq("/t/-/#{post.topic.id}/#{post.post_number}")
end
end
end
describe 'moderator activity' do
let(:current_report) { Report.find('moderators_activity', start_date: 1.months.ago.beginning_of_day, end_date: Date.today) }
let(:previous_report) { Report.find('moderators_activity', start_date: 2.months.ago.beginning_of_day, end_date: 1.month.ago.end_of_day) }
context "no moderators" do
it "returns an empty report" do
expect(current_report.data).to be_blank
end
end
context "with moderators" do
before do
freeze_time(Date.today)
bob = Fabricate(:user, moderator: true, username: 'bob')
bob.user_visits.create(visited_at: 2.days.ago, time_read: 200)
bob.user_visits.create(visited_at: 1.day.ago, time_read: 100)
Fabricate(:topic, user: bob, created_at: 1.day.ago)
sally = Fabricate(:user, moderator: true, username: 'sally')
sally.user_visits.create(visited_at: 2.days.ago, time_read: 1000)
sally.user_visits.create(visited_at: 1.day.ago, time_read: 2000)
topic = Fabricate(:topic)
2.times {
Fabricate(:post, user: sally, topic: topic, created_at: 1.day.ago)
}
flag_user = Fabricate(:user)
flag_post = Fabricate(:post, user: flag_user)
action = PostAction.new(user_id: flag_user.id,
post_action_type_id: PostActionType.types[:off_topic],
post_id: flag_post.id,
agreed_by_id: sally.id,
created_at: 1.day.ago,
agreed_at: Time.now)
action.save
bob.user_visits.create(visited_at: 45.days.ago, time_read: 200)
old_topic = Fabricate(:topic, user: bob, created_at: 45.days.ago)
3.times {
Fabricate(:post, user: bob, topic: old_topic, created_at: 45.days.ago)
}
old_flag_user = Fabricate(:user)
old_flag_post = Fabricate(:post, user: old_flag_user, created_at: 45.days.ago)
old_action = PostAction.new(user_id: old_flag_user.id,
post_action_type_id: PostActionType.types[:spam],
post_id: old_flag_post.id,
agreed_by_id: bob.id,
created_at: 44.days.ago,
agreed_at: 44.days.ago)
old_action.save
end
it "returns a report with data" do
expect(current_report.data).to be_present
end
it "returns data for two moderators" do
expect(current_report.data.count).to eq(2)
end
it "returns the correct usernames" do
expect(current_report.data[0][:username]).to eq('bob')
expect(current_report.data[1][:username]).to eq('sally')
end
it "returns the correct read times" do
expect(current_report.data[0][:time_read]).to eq(300)
expect(current_report.data[1][:time_read]).to eq(3000)
end
it "returns the correct agreed flag count" do
expect(current_report.data[0][:flag_count]).to be_blank
expect(current_report.data[1][:flag_count]).to eq(1)
end
it "returns the correct topic count" do
expect(current_report.data[0][:topic_count]).to eq(1)
expect(current_report.data[1][:topic_count]).to be_blank
end
it "returns the correct post count" do
expect(current_report.data[0][:post_count]).to be_blank
expect(current_report.data[1][:post_count]).to eq(2)
end
it "returns the correct data for the time period" do
expect(previous_report.data[0][:flag_count]).to eq(1)
expect(previous_report.data[0][:topic_count]).to eq(1)
expect(previous_report.data[0][:post_count]).to eq(3)
expect(previous_report.data[0][:time_read]).to eq(200)
expect(previous_report.data[1][:flag_count]).to be_blank
expect(previous_report.data[1][:topic_count]).to be_blank
expect(previous_report.data[1][:post_count]).to be_blank
expect(previous_report.data[1][:time_read]).to be_blank
end
end
end
end

View File

@ -7,25 +7,18 @@ acceptance("Dashboard Next", {
QUnit.test("Visit dashboard next page", async assert => {
await visit("/admin");
assert.ok($(".dashboard-next").length, "has dashboard-next class");
assert.ok($(".dashboard-mini-chart.signups").length, "has a signups chart");
assert.ok($(".dashboard-mini-chart.posts").length, "has a posts chart");
assert.ok(exists(".dashboard-next"), "has dashboard-next class");
assert.ok(exists(".admin-report.signups"), "signups report");
assert.ok(exists(".admin-report.posts"), "posts report");
assert.ok(exists(".admin-report.dau-by-mau"), "dau-by-mau report");
assert.ok(
$(".dashboard-mini-chart.dau_by_mau").length,
"has a dau_by_mau chart"
exists(".admin-report.daily-engaged-users"),
"daily-engaged-users report"
);
assert.ok(
$(".dashboard-mini-chart.daily_engaged_users").length,
"has a daily_engaged_users chart"
);
assert.ok(
$(".dashboard-mini-chart.new_contributors").length,
"has a new_contributors chart"
exists(".admin-report.new-contributors"),
"new-contributors report"
);
assert.equal(

View File

@ -0,0 +1,130 @@
import componentTest from "helpers/component-test";
moduleForComponent("admin-report", {
integration: true
});
componentTest("default", {
template: "{{admin-report dataSourceName='signups'}}",
test(assert) {
andThen(() => {
assert.ok(exists(".admin-report.signups"));
assert.ok(
exists(".admin-report.table.signups", "it defaults to table mode")
);
assert.equal(
find(".report-header .title")
.text()
.trim(),
"Signups",
"it has a title"
);
assert.equal(
find(".report-header .info").attr("data-tooltip"),
"New account registrations for this period",
"it has a description"
);
assert.equal(
find(".report-body .report-table thead tr th:first-child")
.text()
.trim(),
"Day",
"it has col headers"
);
assert.equal(
find(".report-body .report-table thead tr th:nth-child(2)")
.text()
.trim(),
"Count",
"it has col headers"
);
assert.equal(
find(".report-body .report-table tbody tr:nth-child(1) td:nth-child(1)")
.text()
.trim(),
"June 16, 2018",
"it has rows"
);
assert.equal(
find(".report-body .report-table tbody tr:nth-child(1) td:nth-child(2)")
.text()
.trim(),
"12",
"it has rows"
);
assert.ok(exists(".totals-sample-table"), "it has totals");
});
click(".admin-report-table-header.y .sort-button");
andThen(() => {
assert.equal(
find(".report-body .report-table tbody tr:nth-child(1) td:nth-child(2)")
.text()
.trim(),
"7",
"it can sort rows"
);
});
}
});
componentTest("options", {
template: "{{admin-report dataSourceName='signups' reportOptions=options}}",
beforeEach() {
this.set("options", {
table: {
perPage: 4,
total: false
}
});
},
test(assert) {
andThen(() => {
assert.ok(exists(".pagination"), "it paginates the results");
assert.equal(
find(".pagination button").length,
3,
"it creates the correct number of pages"
);
assert.notOk(exists(".totals-sample-table"), "it hides totals");
});
}
});
componentTest("switch modes", {
template: "{{admin-report dataSourceName='signups'}}",
test(assert) {
click(".mode-button.chart");
andThen(() => {
assert.notOk(
exists(".admin-report.table.signups"),
"it removes the table"
);
assert.ok(exists(".admin-report.chart.signups"), "it shows the chart");
});
}
});
componentTest("timeout", {
template: "{{admin-report dataSourceName='signups_timeout'}}",
test(assert) {
andThen(() => {
assert.ok(exists(".alert-error"), "it displays a timeout error");
});
}
});

View File

@ -0,0 +1,13 @@
export default {
"/admin/general.json": {
reports: [],
last_backup_taken_at: "2018-04-13T12:51:19.926Z",
updated_at: "2018-04-25T08:06:11.292Z",
disk_space: {
uploads_used: "74.5 KB",
uploads_free: "117 GB",
backups_used: "4.24 GB",
backups_free: "117 GB"
}
}
};

View File

@ -1,20 +1,7 @@
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,
report_key: ""
report_key: "daily_engaged_users"
}
}
};

View File

@ -1,13 +1,5 @@
export default {
"/admin/dashboard-next.json": {
reports: [],
last_backup_taken_at: "2018-04-13T12:51:19.926Z",
updated_at: "2018-04-25T08:06:11.292Z",
disk_space: {
uploads_used: "74.5 KB",
uploads_free: "117 GB",
backups_used: "4.24 GB",
backups_free: "117 GB"
}
updated_at: "2018-04-25T08:06:11.292Z"
}
};

View File

@ -1,20 +1,7 @@
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,
report_key: ""
report_key: "dau_by_mau"
}
}
};

View File

@ -1,60 +1,7 @@
export default {
"/admin/reports/new_contributors": {
report: {
type: "new_contributors",
title: "New Contributors",
xaxis: "",
yaxis: "",
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,
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,
report_key: ""
report_key: "new_contributors"
}
}
};

View File

@ -1,19 +1,7 @@
export default {
"/admin/reports/posts": {
report: {
type: "topics",
title: "Topics",
xaxis: "Day",
yaxis: "Number of new posts",
data: null,
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: 0,
labels: null,
report_key: ""
report_key: "posts"
}
}
};

View File

@ -4,53 +4,76 @@ export default {
type: "signups",
title: "Signups",
xaxis: "Day",
yaxis: "Number of new users",
yaxis: "Number of signups",
description: "New account registrations for this period",
data: [
{
x: "2018-04-11",
y: 10
},
{
x: "2018-04-12",
y: 10
},
{
x: "2018-04-13",
y: 22
},
{
x: "2018-04-14",
y: 58
},
{
x: "2018-04-15",
y: 10
},
{
x: "2018-04-16",
y: 10
},
{
x: "2018-04-17",
y: 19
},
{
x: "2018-04-18",
y: 12
},
{
x: "2018-04-19",
y: 19
}
{ x: "2018-06-16", y: 12 },
{ x: "2018-06-17", y: 16 },
{ x: "2018-06-18", y: 42 },
{ x: "2018-06-19", y: 38 },
{ x: "2018-06-20", y: 41 },
{ x: "2018-06-21", y: 32 },
{ x: "2018-06-22", y: 23 },
{ x: "2018-06-23", y: 23 },
{ x: "2018-06-24", y: 17 },
{ x: "2018-06-25", y: 27 },
{ x: "2018-06-26", y: 32 },
{ x: "2018-06-27", y: 7 }
],
total: 136,
start_date: "2018-03-26T00:00:00.000Z",
end_date: "2018-04-25T23:59:59.999Z",
start_date: "2018-06-16T00:00:00Z",
end_date: "2018-07-16T23:59:59Z",
prev_data: [
{ x: "2018-05-17", y: 32 },
{ x: "2018-05-18", y: 30 },
{ x: "2018-05-19", y: 12 },
{ x: "2018-05-20", y: 23 },
{ x: "2018-05-21", y: 50 },
{ x: "2018-05-22", y: 39 },
{ x: "2018-05-23", y: 51 },
{ x: "2018-05-24", y: 48 },
{ x: "2018-05-25", y: 37 },
{ x: "2018-05-26", y: 17 },
{ x: "2018-05-27", y: 6 },
{ x: "2018-05-28", y: 20 },
{ x: "2018-05-29", y: 37 },
{ x: "2018-05-30", y: 37 },
{ x: "2018-05-31", y: 37 },
{ x: "2018-06-01", y: 38 },
{ x: "2018-06-02", y: 23 },
{ x: "2018-06-03", y: 18 },
{ x: "2018-06-04", y: 39 },
{ x: "2018-06-05", y: 26 },
{ x: "2018-06-06", y: 39 },
{ x: "2018-06-07", y: 52 },
{ x: "2018-06-08", y: 35 },
{ x: "2018-06-09", y: 19 },
{ x: "2018-06-10", y: 15 },
{ x: "2018-06-11", y: 31 },
{ x: "2018-06-12", y: 38 },
{ x: "2018-06-13", y: 30 },
{ x: "2018-06-14", y: 45 },
{ x: "2018-06-15", y: 37 },
{ x: "2018-06-16", y: 12 }
],
prev_start_date: "2018-05-17T00:00:00Z",
prev_end_date: "2018-06-17T00:00:00Z",
category_id: null,
group_id: null,
prev30Days: 0,
labels: null,
report_key: ""
prev30Days: null,
dates_filtering: true,
report_key: "reports:signups::20180616:20180716::[:prev_period]:",
labels: [
{ type: "date", properties: ["x"], title: "Day" },
{ type: "number", properties: ["y"], title: "Count" }
],
processing: false,
average: false,
percent: false,
higher_is_better: true,
category_filtering: false,
group_filtering: true,
modes: ["table", "chart"],
prev_period: 961
}
}
};

View File

@ -0,0 +1,35 @@
export default {
"/admin/reports/signups_timeout": {
report: {
type: "signups",
title: "Signups",
xaxis: "Day",
yaxis: "Number of signups",
description: "New account registrations for this period",
data: null,
start_date: "2018-06-16T00:00:00Z",
end_date: "2018-07-16T23:59:59Z",
prev_data: null,
prev_start_date: "2018-05-17T00:00:00Z",
prev_end_date: "2018-06-17T00:00:00Z",
category_id: null,
group_id: null,
prev30Days: null,
dates_filtering: true,
report_key: "reports:signups_timeout::20180616:20180716::[:prev_period]:",
labels: [
{ type: "date", properties: ["x"], title: "Day" },
{ type: "number", properties: ["y"], title: "Count" }
],
processing: false,
average: false,
percent: false,
higher_is_better: true,
category_filtering: false,
group_filtering: true,
modes: ["table", "chart"],
prev_period: 961,
timeout: true
}
}
};

View File

@ -1,18 +1,7 @@
export default {
"/admin/reports/top_referred_topics": {
report: {
type: "top_referred_topics",
title: "Trending search",
xaxis: "",
yaxis: "",
data: null,
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: ["Topic", "Visits"]
report_key: "top_referred_topics"
}
}
};

View File

@ -1,19 +1,7 @@
export default {
"/admin/reports/topics": {
report: {
type: "topics",
title: "Topics",
xaxis: "Day",
yaxis: "Number of new topics",
data: null,
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: 0,
labels: null,
report_key: ""
report_key: "topics"
}
}
};

View File

@ -1,19 +1,7 @@
export default {
"/admin/reports/trending_search": {
report: {
type: "trending_search",
title: "Trending search",
xaxis: "",
yaxis: "",
data: null,
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"],
report_key: ""
report_key: "trending_search"
}
}
};

View File

@ -1,21 +1,7 @@
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,
report_key: ""
report_key: "users_by_trust_level"
}
}
};

View File

@ -1,20 +1,7 @@
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,
report_key: ""
report_key: "users_by_type"
}
}
};

View File

@ -391,3 +391,74 @@ QUnit.test("average", assert => {
report.set("average", false);
assert.ok(report.get("lastSevenDaysCount") === 35);
});
QUnit.test("computed labels", assert => {
const data = [
{
username: "joffrey",
user_url: "/admin/users/1/joffrey",
flag_count: 1876,
time_read: 287362,
note: "This is a long note"
}
];
const labels = [
{
type: "link",
properties: ["username", "user_url"],
title: "Username"
},
{ properties: ["flag_count"], title: "Flag count" },
{ type: "seconds", properties: ["time_read"], title: "Time read" },
{ type: "text", properties: ["note"], title: "Note" }
];
const report = Report.create({
type: "topics",
labels,
data
});
const row = report.get("data.0");
const computedLabels = report.get("computedLabels");
const usernameLabel = computedLabels[0];
assert.equal(usernameLabel.property, "username");
assert.equal(usernameLabel.sort_property, "username");
assert.equal(usernameLabel.title, "Username");
const computedUsernameLabel = usernameLabel.compute(row);
assert.equal(
computedUsernameLabel.formatedValue,
'<a href="/admin/users/1/joffrey">joffrey</a>'
);
assert.equal(computedUsernameLabel.type, "link");
assert.equal(computedUsernameLabel.value, "joffrey");
const flagCountLabel = computedLabels[1];
assert.equal(flagCountLabel.property, "flag_count");
assert.equal(flagCountLabel.sort_property, "flag_count");
assert.equal(flagCountLabel.title, "Flag count");
const computedFlagCountLabel = flagCountLabel.compute(row);
assert.equal(computedFlagCountLabel.formatedValue, "1.9k");
assert.equal(computedFlagCountLabel.type, "number");
assert.equal(computedFlagCountLabel.value, 1876);
const timeReadLabel = computedLabels[2];
assert.equal(timeReadLabel.property, "time_read");
assert.equal(timeReadLabel.sort_property, "time_read");
assert.equal(timeReadLabel.title, "Time read");
const computedTimeReadLabel = timeReadLabel.compute(row);
assert.equal(computedTimeReadLabel.formatedValue, "3d");
assert.equal(computedTimeReadLabel.type, "seconds");
assert.equal(computedTimeReadLabel.value, 287362);
const noteLabel = computedLabels[3];
assert.equal(noteLabel.property, "note");
assert.equal(noteLabel.sort_property, "note");
assert.equal(noteLabel.title, "Note");
const computedNoteLabel = noteLabel.compute(row);
assert.equal(computedNoteLabel.formatedValue, "This is a long note");
assert.equal(computedNoteLabel.type, "text");
assert.equal(computedNoteLabel.value, "This is a long note");
});