diff --git a/app/assets/javascripts/admin/components/admin-report-chart.js.es6 b/app/assets/javascripts/admin/components/admin-report-chart.js.es6
new file mode 100644
index 00000000000..830daf11a5c
--- /dev/null
+++ b/app/assets/javascripts/admin/components/admin-report-chart.js.es6
@@ -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;
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/components/admin-report-inline-table.js.es6 b/app/assets/javascripts/admin/components/admin-report-inline-table.js.es6
new file mode 100644
index 00000000000..7e4933381ce
--- /dev/null
+++ b/app/assets/javascripts/admin/components/admin-report-inline-table.js.es6
@@ -0,0 +1,3 @@
+export default Ember.Component.extend({
+ classNames: ["admin-report-inline-table"]
+});
diff --git a/app/assets/javascripts/admin/components/admin-report-table-header.js.es6 b/app/assets/javascripts/admin/components/admin-report-table-header.js.es6
new file mode 100644
index 00000000000..b7c569cc25f
--- /dev/null
+++ b/app/assets/javascripts/admin/components/admin-report-table-header.js.es6
@@ -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";
+ }
+});
diff --git a/app/assets/javascripts/admin/components/admin-report-table-row.js.es6 b/app/assets/javascripts/admin/components/admin-report-table-row.js.es6
new file mode 100644
index 00000000000..3c4de4970e3
--- /dev/null
+++ b/app/assets/javascripts/admin/components/admin-report-table-row.js.es6
@@ -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);
+ });
+ }
+});
diff --git a/app/assets/javascripts/admin/components/admin-report-table.js.es6 b/app/assets/javascripts/admin/components/admin-report-table.js.es6
new file mode 100644
index 00000000000..b39afc8a898
--- /dev/null
+++ b/app/assets/javascripts/admin/components/admin-report-table.js.es6
@@ -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);
+ }
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/components/admin-report.js.es6 b/app/assets/javascripts/admin/components/admin-report.js.es6
new file mode 100644
index 00000000000..1688432962d
--- /dev/null
+++ b/app/assets/javascripts/admin/components/admin-report.js.es6
@@ -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);
+ }
+});
diff --git a/app/assets/javascripts/admin/components/admin-table-report.js.es6 b/app/assets/javascripts/admin/components/admin-table-report.js.es6
deleted file mode 100644
index aa7efce66af..00000000000
--- a/app/assets/javascripts/admin/components/admin-table-report.js.es6
+++ /dev/null
@@ -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);
- }
-});
diff --git a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 b/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6
deleted file mode 100644
index 0435f8237e9..00000000000
--- a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6
+++ /dev/null
@@ -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));
- });
- })
- );
- }
-});
diff --git a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 b/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6
deleted file mode 100644
index 1f0d4b7cba1..00000000000
--- a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6
+++ /dev/null
@@ -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"
- }
- }
- ]
- }
- }
- };
- }
-});
diff --git a/app/assets/javascripts/admin/components/dashboard-table.js.es6 b/app/assets/javascripts/admin/components/dashboard-table.js.es6
deleted file mode 100644
index fad090efacb..00000000000
--- a/app/assets/javascripts/admin/components/dashboard-table.js.es6
+++ /dev/null
@@ -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));
- });
- })
- );
- }
-});
diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-next-general.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-next-general.js.es6
new file mode 100644
index 00000000000..413213b36d9
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin-dashboard-next-general.js.es6
@@ -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}`);
+ }
+});
diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-next-moderation.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-next-moderation.js.es6
new file mode 100644
index 00000000000..95075ef906c
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin-dashboard-next-moderation.js.es6
@@ -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}`);
+ }
+});
diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6
index e809df69513..1be0a8a7fa1 100644
--- a/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6
+++ b/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6
@@ -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}`);
}
});
diff --git a/app/assets/javascripts/admin/controllers/admin-reports-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-reports-show.js.es6
index bd6ff8c49c8..0288b3f7d0e 100644
--- a/app/assets/javascripts/admin/controllers/admin-reports-show.js.es6
+++ b/app/assets/javascripts/admin/controllers/admin-reports-show.js.es6
@@ -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);
}
}
});
diff --git a/app/assets/javascripts/admin/mixins/async-report.js.es6 b/app/assets/javascripts/admin/mixins/async-report.js.es6
deleted file mode 100644
index e788284208f..00000000000
--- a/app/assets/javascripts/admin/mixins/async-report.js.es6
+++ /dev/null
@@ -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);
- }
-});
diff --git a/app/assets/javascripts/admin/mixins/period-computation.js.es6 b/app/assets/javascripts/admin/mixins/period-computation.js.es6
new file mode 100644
index 00000000000..8ed2346cd53
--- /dev/null
+++ b/app/assets/javascripts/admin/mixins/period-computation.js.es6
@@ -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));
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6
index 7e755a18582..bec0c139bae 100644
--- a/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6
+++ b/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6
@@ -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;
});
diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6
index c8f4cf3be02..cd31cc779a2 100644
--- a/app/assets/javascripts/admin/models/report.js.es6
+++ b/app/assets/javascripts/admin/models/report.js.es6
@@ -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: `${escapedValue}`
+ });
+ }
+
+ 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);
+ // don’t 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);
diff --git a/app/assets/javascripts/admin/routes/admin-dashboard-next-general.js.es6 b/app/assets/javascripts/admin/routes/admin-dashboard-next-general.js.es6
new file mode 100644
index 00000000000..e73b7247f37
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin-dashboard-next-general.js.es6
@@ -0,0 +1,5 @@
+export default Discourse.Route.extend({
+ activate() {
+ this.controllerFor("admin-dashboard-next-general").fetchDashboard();
+ }
+});
diff --git a/app/assets/javascripts/admin/routes/admin-dashboard-next-moderation.js.es6 b/app/assets/javascripts/admin/routes/admin-dashboard-next-moderation.js.es6
new file mode 100644
index 00000000000..6c6dfc7864a
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin-dashboard-next-moderation.js.es6
@@ -0,0 +1,5 @@
+export default Discourse.Route.extend({
+ activate() {
+ this.controllerFor("admin-dashboard-next-moderation").fetchDashboard();
+ }
+});
diff --git a/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6
index 48d9430f154..5aa907b55ce 100644
--- a/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6
+++ b/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6
@@ -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");
+ }
+ }
}
});
diff --git a/app/assets/javascripts/admin/routes/admin-reports-show.js.es6 b/app/assets/javascripts/admin/routes/admin-reports-show.js.es6
index 9c485c41ef1..fbcd9106439 100644
--- a/app/assets/javascripts/admin/routes/admin-reports-show.js.es6
+++ b/app/assets/javascripts/admin/routes/admin-reports-show.js.es6
@@ -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"));
+ }
}
});
diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6
index 2bf0591af98..bdcdcd4a96f 100644
--- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6
+++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6
@@ -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 },
diff --git a/app/assets/javascripts/admin/templates/components/admin-report-chart.hbs b/app/assets/javascripts/admin/templates/components/admin-report-chart.hbs
new file mode 100644
index 00000000000..dfc603d0cd9
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/admin-report-chart.hbs
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/assets/javascripts/admin/templates/components/admin-report-inline-table.hbs b/app/assets/javascripts/admin/templates/components/admin-report-inline-table.hbs
new file mode 100644
index 00000000000..70138f829db
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/admin-report-inline-table.hbs
@@ -0,0 +1,15 @@
+
diff --git a/app/assets/javascripts/admin/templates/components/admin-report-table-header.hbs b/app/assets/javascripts/admin/templates/components/admin-report-table-header.hbs
new file mode 100644
index 00000000000..4e8f2736c7a
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/admin-report-table-header.hbs
@@ -0,0 +1,5 @@
+{{#if showSortingUI}}
+ {{d-button action=sortByLabel icon=sortIcon class="sort-button"}}
+{{/if}}
+
+{{label.title}}
diff --git a/app/assets/javascripts/admin/templates/components/admin-report-table-row.hbs b/app/assets/javascripts/admin/templates/components/admin-report-table-row.hbs
new file mode 100644
index 00000000000..b9d0c950227
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/admin-report-table-row.hbs
@@ -0,0 +1,5 @@
+{{#each cells as |cell|}}
+
+ {{{cell.formatedValue}}}
+ |
+{{/each}}
diff --git a/app/assets/javascripts/admin/templates/components/admin-report-table.hbs b/app/assets/javascripts/admin/templates/components/admin-report-table.hbs
new file mode 100644
index 00000000000..330dc6f8679
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/admin-report-table.hbs
@@ -0,0 +1,60 @@
+
+
+
+ {{#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|}}
+ {{data.x}} |
+ {{/each}}
+ {{/if}}
+
+
+
+ {{#each paginatedData as |data|}}
+ {{admin-report-table-row data=data labels=model.computedLabels}}
+ {{/each}}
+
+
+
+{{#if showTotalForSample}}
+ {{i18n 'admin.dashboard.reports.totals_for_sample'}}
+
+
+
+ {{#each totalsForSample as |total|}}
+ {{total.formatedValue}} |
+ {{/each}}
+
+
+
+{{/if}}
+
+{{#if showTotal}}
+ {{i18n 'admin.dashboard.reports.total'}}
+
+
+
+ - |
+ {{number model.total}} |
+
+
+
+{{/if}}
+
+
diff --git a/app/assets/javascripts/admin/templates/components/admin-report.hbs b/app/assets/javascripts/admin/templates/components/admin-report.hbs
new file mode 100644
index 00000000000..0c4c4ee24e0
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/admin-report.hbs
@@ -0,0 +1,154 @@
+{{#if isEnabled}}
+ {{#conditional-loading-section isLoading=isLoading}}
+ {{#if showTimeoutError}}
+
+ {{i18n "admin.dashboard.timeout_error"}}
+
+ {{/if}}
+
+ {{#if showHeader}}
+
+ {{/if}}
+
+
+ {{#unless showTimeoutError}}
+ {{#if currentMode}}
+ {{component modeComponent model=model options=options}}
+ {{/if}}
+ {{/unless}}
+
+ {{#if showFilteringUI}}
+ {{#if hasFilteringActions}}
+
+ {{#if showDatesOptions}}
+
+
+ {{i18n 'admin.dashboard.reports.start_date'}}
+
+
+
+ {{date-picker-past
+ value=startDate
+ defaultDate=startDate
+ onSelect=onSelectStartDate}}
+
+
+
+
+
+ {{i18n 'admin.dashboard.reports.end_date'}}
+
+
+
+ {{date-picker-past
+ value=endDate
+ defaultDate=endDate
+ onSelect=onSelectEndDate}}
+
+
+ {{/if}}
+
+ {{#if showCategoryOptions}}
+
+
+ {{combo-box
+ onSelect=onSelectCategory
+ filterable=true
+ valueAttribute="value"
+ content=categoryOptions
+ castInteger=true
+ value=categoryId}}
+
+
+ {{/if}}
+
+ {{#if showGroupOptions}}
+
+
+ {{combo-box
+ onSelect=onSelectGroup
+ castInteger=true
+ filterable=true
+ valueAttribute="value"
+ content=groupOptions
+ value=groupId}}
+
+
+ {{/if}}
+
+ {{#if showExport}}
+
+
+ {{d-button class="export-btn" action="exportCsv" label="admin.export_csv.button_text" icon="download"}}
+
+
+ {{/if}}
+
+ {{/if}}
+ {{/if}}
+
+
+ {{#if model.relatedReport}}
+ {{admin-report dataSourceName=model.relatedReport.type}}
+ {{/if}}
+ {{/conditional-loading-section}}
+{{else}}
+
+ {{{i18n disabledLabel}}}
+
+{{/if}}
diff --git a/app/assets/javascripts/admin/templates/components/admin-table-report.hbs b/app/assets/javascripts/admin/templates/components/admin-table-report.hbs
deleted file mode 100644
index 50dfc7526da..00000000000
--- a/app/assets/javascripts/admin/templates/components/admin-table-report.hbs
+++ /dev/null
@@ -1,37 +0,0 @@
-{{#if model.sortedData}}
-
-
- {{model.xaxis}} |
- {{model.yaxis}} |
-
-
- {{#each model.sortedData as |row|}}
-
- {{row.x}} |
-
- {{row.y}}
- |
-
- {{/each}}
-
-
-
- {{i18n 'admin.dashboard.reports.total_for_period'}}
- |
-
- {{totalForPeriod}}
- |
-
-
- {{#if model.total}}
-
-
- {{i18n 'admin.dashboard.reports.total'}}
- |
-
- {{model.total}}
- |
-
- {{/if}}
-
-{{/if}}
diff --git a/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs b/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs
deleted file mode 100644
index 9e976f46d26..00000000000
--- a/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs
+++ /dev/null
@@ -1,27 +0,0 @@
-{{#conditional-loading-section isLoading=isLoading}}
-
-
{{title}}
-
-
- {{#each reportsForPeriod as |report|}}
-
- {{/each}}
-{{/conditional-loading-section}}
diff --git a/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs b/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs
deleted file mode 100644
index 328e0829ff5..00000000000
--- a/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs
+++ /dev/null
@@ -1,33 +0,0 @@
-{{#conditional-loading-section isLoading=isLoading}}
- {{#each reportsForPeriod as |report|}}
-
-
-
- {{report.title}}
-
-
-
- {{d-icon "question-circle"}}
-
-
-
-
-
- {{#if report.average}}
- {{number report.currentAverage}}{{#if report.percent}}%{{/if}}
- {{else}}
- {{number report.currentTotal noTitle="true"}}{{#if report.percent}}%{{/if}}
- {{/if}}
-
-
- {{#if report.trendIcon}}
- {{d-icon report.trendIcon class="trend-icon"}}
- {{/if}}
-
-
- {{/each}}
-
-
-
-
-{{/conditional-loading-section}}
diff --git a/app/assets/javascripts/admin/templates/components/dashboard-table.hbs b/app/assets/javascripts/admin/templates/components/dashboard-table.hbs
deleted file mode 100644
index 29ab8332cb7..00000000000
--- a/app/assets/javascripts/admin/templates/components/dashboard-table.hbs
+++ /dev/null
@@ -1,36 +0,0 @@
-{{#conditional-loading-section isLoading=isLoading}}
-
-
{{title}}
-
-
- {{#each reportsForPeriod as |report|}}
-
-
-
-
- {{#if report.labels}}
- {{#each report.labels as |label|}}
- {{label}} |
- {{/each}}
- {{else}}
- {{#each report.data as |data|}}
- {{data.x}} |
- {{/each}}
- {{/if}}
-
-
-
- {{#unless hasBlock}}
- {{#each report.data as |data|}}
-
- {{number data.y}} |
-
- {{/each}}
- {{else}}
- {{yield (hash report=report)}}
- {{/unless}}
-
-
-
- {{/each}}
-{{/conditional-loading-section}}
diff --git a/app/assets/javascripts/admin/templates/dashboard_next.hbs b/app/assets/javascripts/admin/templates/dashboard_next.hbs
index e6db196eb96..737593ea3e6 100644
--- a/app/assets/javascripts/admin/templates/dashboard_next.hbs
+++ b/app/assets/javascripts/admin/templates/dashboard_next.hbs
@@ -3,179 +3,26 @@
{{#if showVersionChecks}}
- {{partial 'admin/templates/version-checks'}}
+ {{partial "admin/templates/version-checks"}}
{{/if}}
-{{partial 'admin/templates/dashboard-problems'}}
+{{partial "admin/templates/dashboard-problems"}}
-
-
-
-
-
- {{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.activity_metrics")}}
-
-
{{i18n "admin.dashboard.activity_metrics"}}
-
-
-
-
-
-
- |
- {{i18n 'admin.dashboard.reports.today'}} |
- {{i18n 'admin.dashboard.reports.yesterday'}} |
- {{i18n 'admin.dashboard.reports.last_7_days'}} |
- {{i18n 'admin.dashboard.reports.last_30_days'}} |
-
-
-
- {{#each reports as |report|}}
- {{admin-report-counts report=report allTime=false}}
- {{/each}}
-
-
-
- {{/conditional-loading-section}}
-
- {{#link-to "adminReports"}}
- {{i18n "admin.dashboard.all_reports"}}
+
+ -
+ {{#link-to "admin.dashboardNext.general" class="navigation-link"}}
+ {{i18n "admin.dashboard.general_tab"}}
{{/link-to}}
+
+ -
+ {{#link-to "admin.dashboardNext.moderation" class="navigation-link"}}
+ {{i18n "admin.dashboard.moderation_tab"}}
+ {{/link-to}}
+
+
-
- {{dashboard-inline-table dataSourceNames="users_by_type" lastRefreshedAt=lastRefreshedAt}}
+{{outlet}}
- {{dashboard-inline-table dataSourceNames="users_by_trust_level" lastRefreshedAt=lastRefreshedAt}}
-
- {{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.backups")}}
-
- {{#if shouldDisplayDurability}}
-
- {{#if currentUser.admin}}
-
-
-
- {{diskSpace.backups_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.backups_free}})
-
- {{{i18n "admin.dashboard.lastest_backup" date=backupTimestamp}}}
-
-
- {{/if}}
-
-
-
{{d-icon "upload"}} {{i18n "admin.dashboard.uploads"}}
-
- {{diskSpace.uploads_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.uploads_free}})
-
-
-
- {{/if}}
-
-
-
-
-
- {{i18n 'admin.dashboard.find_old'}} {{#link-to 'admin.dashboard'}}{{i18n "admin.dashboard.old_link"}}{{/link-to}}
-
- {{/conditional-loading-section}}
-
-
-
-
- {{#dashboard-table
- dataSourceNames="top_referred_topics"
- lastRefreshedAt=lastRefreshedAt
- limit=8
- as |context|}}
- {{#each context.report.data as |data|}}
-
-
-
- {{data.topic_title}}
-
- |
-
- {{data.num_clicks}}
- |
-
- {{/each}}
- {{/dashboard-table}}
-
-
-
- {{#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|}}
-
-
- {{data.term}}
- |
-
- {{number data.unique_searches}}
- |
-
- {{data.ctr}}
- |
-
- {{/each}}
- {{/dashboard-table}}
- {{{i18n "admin.dashboard.reports.trending_search.more"}}}
-
-
-
+{{plugin-outlet name="admin-dashboard-bottom"}}
diff --git a/app/assets/javascripts/admin/templates/dashboard_next_general.hbs b/app/assets/javascripts/admin/templates/dashboard_next_general.hbs
new file mode 100644
index 00000000000..ea05c6cc9ec
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/dashboard_next_general.hbs
@@ -0,0 +1,180 @@
+{{#conditional-loading-spinner condition=isLoading}}
+ {{plugin-outlet name="admin-dashboard-general-top"}}
+
+
+
+
+
+
+ {{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.activity_metrics")}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{#each activityMetricsReports as |report|}}
+ {{admin-report-counts report=report allTime=false class="admin-report-table-row"}}
+ {{/each}}
+
+
+
+
+ {{/conditional-loading-section}}
+
+ {{#link-to "adminReports"}}
+ {{i18n "admin.dashboard.all_reports"}}
+ {{/link-to}}
+
+
+ {{admin-report
+ forcedModes="inline-table"
+ report=usersByTypeReport
+ lastRefreshedAt=lastRefreshedAt}}
+
+ {{admin-report
+ forcedModes="inline-table"
+ report=usersByTrustLevelReport
+ lastRefreshedAt=lastRefreshedAt}}
+
+
+ {{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.backups")}}
+
+
+ {{#if shouldDisplayDurability}}
+
+ {{#if currentUser.admin}}
+
+
+
+ {{diskSpace.backups_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.backups_free}})
+
+ {{{i18n "admin.dashboard.lastest_backup" date=backupTimestamp}}}
+
+
+ {{/if}}
+
+
+
{{d-icon "upload"}} {{i18n "admin.dashboard.uploads"}}
+
+ {{diskSpace.uploads_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.uploads_free}})
+
+
+
+ {{/if}}
+
+
+
+
+
+ {{i18n 'admin.dashboard.find_old'}} {{#link-to 'admin.dashboard'}}{{i18n "admin.dashboard.old_link"}}{{/link-to}}
+
+ {{/conditional-loading-section}}
+
+
+
+ {{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"}}}
+
+
+
+ {{plugin-outlet name="admin-dashboard-general-bottom"}}
+{{/conditional-loading-spinner}}
diff --git a/app/assets/javascripts/admin/templates/dashboard_next_moderation.hbs b/app/assets/javascripts/admin/templates/dashboard_next_moderation.hbs
new file mode 100644
index 00000000000..950d2a7176f
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/dashboard_next_moderation.hbs
@@ -0,0 +1,40 @@
+{{#conditional-loading-spinner condition=isLoading}}
+
+ {{plugin-outlet name="admin-dashboard-moderation-top"}}
+
+
+
+
{{i18n "admin.dashboard.moderators_activity"}}
+ {{period-chooser
+ period=period
+ action="changePeriod"
+ content=availablePeriods
+ fullDay=true}}
+
+
+
+ {{admin-report
+ startDate=startDate
+ endDate=endDate
+ showHeader=false
+ dataSourceName="moderators_activity"}}
+
+
+
+
+ {{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"}}
+
+
+
+{{/conditional-loading-spinner}}
diff --git a/app/assets/javascripts/admin/templates/reports-show.hbs b/app/assets/javascripts/admin/templates/reports-show.hbs
index bfd8781989b..6be7401f8b8 100644
--- a/app/assets/javascripts/admin/templates/reports-show.hbs
+++ b/app/assets/javascripts/admin/templates/reports-show.hbs
@@ -1,60 +1,16 @@
-
- {{#link-to "adminReports"}}
- {{i18n "admin.dashboard.all_reports"}}
- {{/link-to}}
-
- |
-
- {{model.title}}
-
-
-{{#if model.description}}
- {{model.description}}
-{{/if}}
-
- {{#conditional-loading-spinner condition=refreshing}}
-
-
- {{#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}}
-
-
-
-
- {{i18n 'admin.dashboard.reports.start_date'}} {{date-picker-past value=startDate defaultDate=startDate}}
-
-
- {{i18n 'admin.dashboard.reports.end_date'}} {{date-picker-past value=endDate defaultDate=endDate}}
-
- {{#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")}}
diff --git a/app/assets/javascripts/discourse/components/date-picker-past.js.es6 b/app/assets/javascripts/discourse/components/date-picker-past.js.es6
index d858580d04a..027798d2eaf 100644
--- a/app/assets/javascripts/discourse/components/date-picker-past.js.es6
+++ b/app/assets/javascripts/discourse/components/date-picker-past.js.es6
@@ -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()
};
diff --git a/app/assets/javascripts/discourse/components/date-picker.js.es6 b/app/assets/javascripts/discourse/components/date-picker.js.es6
index 982221fd906..35241ae67b0 100644
--- a/app/assets/javascripts/discourse/components/date-picker.js.es6
+++ b/app/assets/javascripts/discourse/components/date-picker.js.es6
@@ -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()));
diff --git a/app/assets/javascripts/discourse/lib/utilities.js.es6 b/app/assets/javascripts/discourse/lib/utilities.js.es6
index be5ef8cd9af..708b6639566 100644
--- a/app/assets/javascripts/discourse/lib/utilities.js.es6
+++ b/app/assets/javascripts/discourse/lib/utilities.js.es6
@@ -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");
diff --git a/app/assets/javascripts/select-kit/components/select-kit.js.es6 b/app/assets/javascripts/select-kit/components/select-kit.js.es6
index 150b5442540..18e45adfa93 100644
--- a/app/assets/javascripts/select-kit/components/select-kit.js.es6
+++ b/app/assets/javascripts/select-kit/components/select-kit.js.es6
@@ -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));
}
},
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index 9572c81aa9e..b424d4dac53 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -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";
diff --git a/app/assets/stylesheets/common/admin/admin_report.scss b/app/assets/stylesheets/common/admin/admin_report.scss
new file mode 100644
index 00000000000..7ca934f8b40
--- /dev/null
+++ b/app/assets/stylesheets/common/admin/admin_report.scss
@@ -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;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/common/admin/admin_report_chart.scss b/app/assets/stylesheets/common/admin/admin_report_chart.scss
new file mode 100644
index 00000000000..b3c3489c4ea
--- /dev/null
+++ b/app/assets/stylesheets/common/admin/admin_report_chart.scss
@@ -0,0 +1,2 @@
+.admin-report-chart {
+}
diff --git a/app/assets/stylesheets/common/admin/admin_report_inline_table.scss b/app/assets/stylesheets/common/admin/admin_report_inline_table.scss
new file mode 100644
index 00000000000..4c906fa7a37
--- /dev/null
+++ b/app/assets/stylesheets/common/admin/admin_report_inline_table.scss
@@ -0,0 +1,7 @@
+.admin-report-inline-table {
+ .table-container {
+ display: flex;
+ flex-wrap: wrap;
+ flex: 1 1 auto;
+ }
+}
diff --git a/app/assets/stylesheets/common/admin/admin_report_table.scss b/app/assets/stylesheets/common/admin/admin_report_table.scss
new file mode 100644
index 00000000000..4f042e08650
--- /dev/null
+++ b/app/assets/stylesheets/common/admin/admin_report_table.scss
@@ -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;
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/common/admin/admin_reports.scss b/app/assets/stylesheets/common/admin/admin_reports.scss
index 8fab3da17ff..137bf814e57 100644
--- a/app/assets/stylesheets/common/admin/admin_reports.scss
+++ b/app/assets/stylesheets/common/admin/admin_reports.scss
@@ -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 {
diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss
index e49893f894a..846d89c20bf 100644
--- a/app/assets/stylesheets/common/admin/dashboard_next.scss
+++ b/app/assets/stylesheets/common/admin/dashboard_next.scss
@@ -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;
+ }
+}
diff --git a/app/controllers/admin/dashboard_next_controller.rb b/app/controllers/admin/dashboard_next_controller.rb
index a564fd64ee2..4ce90949535 100644
--- a/app/controllers/admin/dashboard_next_controller.rb
+++ b/app/controllers/admin/dashboard_next_controller.rb
@@ -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
diff --git a/app/jobs/scheduled/dashboard_stats.rb b/app/jobs/scheduled/dashboard_stats.rb
index d475075c4df..9fa0056ff71 100644
--- a/app/jobs/scheduled/dashboard_stats.rb
+++ b/app/jobs/scheduled/dashboard_stats.rb
@@ -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
diff --git a/app/models/admin_dashboard_next_data.rb b/app/models/admin_dashboard_next_data.rb
index 210fff51cd1..292f78a63be 100644
--- a/app/models/admin_dashboard_next_data.rb
+++ b/app/models/admin_dashboard_next_data.rb
@@ -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
diff --git a/app/models/admin_dashboard_next_general_data.rb b/app/models/admin_dashboard_next_general_data.rb
new file mode 100644
index 00000000000..36c5d90cbb2
--- /dev/null
+++ b/app/models/admin_dashboard_next_general_data.rb
@@ -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
diff --git a/app/models/admin_dashboard_next_index_data.rb b/app/models/admin_dashboard_next_index_data.rb
new file mode 100644
index 00000000000..d5eb0677eb8
--- /dev/null
+++ b/app/models/admin_dashboard_next_index_data.rb
@@ -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
diff --git a/app/models/admin_dashboard_next_moderation_data.rb b/app/models/admin_dashboard_next_moderation_data.rb
new file mode 100644
index 00000000000..b97375b048f
--- /dev/null
+++ b/app/models/admin_dashboard_next_moderation_data.rb
@@ -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
diff --git a/app/models/incoming_links_report.rb b/app/models/incoming_links_report.rb
index 897bf3e50ac..38e58d0e531 100644
--- a/app/models/incoming_links_report.rb
+++ b/app/models/incoming_links_report.rb
@@ -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
diff --git a/app/models/report.rb b/app/models/report.rb
index 218a614136f..d0366d517cd 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -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
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index ff1e33ac11c..cb8c382cf7b 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -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: What’s 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: 'Search logs'
disabled: 'Trending search report is disabled. Enable log search queries to collect data.'
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index de9dbd06363..7a0b01f3cd0 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -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."
diff --git a/config/routes.rb b/config/routes.rb
index f969cdd3e1d..936ea26cc80 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb
index 1f36ce5ff37..95ba43485b0 100644
--- a/spec/models/report_spec.rb
+++ b/spec/models/report_spec.rb
@@ -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
diff --git a/test/javascripts/acceptance/dashboard-next-test.js.es6 b/test/javascripts/acceptance/dashboard-next-test.js.es6
index 34d69d77ba6..fa8fef4bfb5 100644
--- a/test/javascripts/acceptance/dashboard-next-test.js.es6
+++ b/test/javascripts/acceptance/dashboard-next-test.js.es6
@@ -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(
diff --git a/test/javascripts/components/admin-report-test.js.es6 b/test/javascripts/components/admin-report-test.js.es6
new file mode 100644
index 00000000000..5aa62740a68
--- /dev/null
+++ b/test/javascripts/components/admin-report-test.js.es6
@@ -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");
+ });
+ }
+});
diff --git a/test/javascripts/fixtures/admin-general.js.es6 b/test/javascripts/fixtures/admin-general.js.es6
new file mode 100644
index 00000000000..6e57a8d0bc8
--- /dev/null
+++ b/test/javascripts/fixtures/admin-general.js.es6
@@ -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"
+ }
+ }
+};
diff --git a/test/javascripts/fixtures/daily-engaged-users.js.es6 b/test/javascripts/fixtures/daily-engaged-users.js.es6
index b734c47b74b..462e3898553 100644
--- a/test/javascripts/fixtures/daily-engaged-users.js.es6
+++ b/test/javascripts/fixtures/daily-engaged-users.js.es6
@@ -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"
}
}
};
diff --git a/test/javascripts/fixtures/dashboard-next.js.es6 b/test/javascripts/fixtures/dashboard-next.js.es6
index f1dfb0b7d03..257c435089a 100644
--- a/test/javascripts/fixtures/dashboard-next.js.es6
+++ b/test/javascripts/fixtures/dashboard-next.js.es6
@@ -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"
}
};
diff --git a/test/javascripts/fixtures/dau-by-mau.js.es6 b/test/javascripts/fixtures/dau-by-mau.js.es6
index 221486bcf6d..77b5a25f2e4 100644
--- a/test/javascripts/fixtures/dau-by-mau.js.es6
+++ b/test/javascripts/fixtures/dau-by-mau.js.es6
@@ -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"
}
}
};
diff --git a/test/javascripts/fixtures/new-contributors.js.es6 b/test/javascripts/fixtures/new-contributors.js.es6
index 5b89bc059b4..8d688a1a1c2 100644
--- a/test/javascripts/fixtures/new-contributors.js.es6
+++ b/test/javascripts/fixtures/new-contributors.js.es6
@@ -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"
}
}
};
diff --git a/test/javascripts/fixtures/posts.js.es6 b/test/javascripts/fixtures/posts.js.es6
index caa70dc3370..0f690625fee 100644
--- a/test/javascripts/fixtures/posts.js.es6
+++ b/test/javascripts/fixtures/posts.js.es6
@@ -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"
}
}
};
diff --git a/test/javascripts/fixtures/signups.js.es6 b/test/javascripts/fixtures/signups.js.es6
index 93d6cdd9cc2..f6319902fed 100644
--- a/test/javascripts/fixtures/signups.js.es6
+++ b/test/javascripts/fixtures/signups.js.es6
@@ -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
}
}
};
diff --git a/test/javascripts/fixtures/signups_timeout.js.es6 b/test/javascripts/fixtures/signups_timeout.js.es6
new file mode 100644
index 00000000000..0c92485b111
--- /dev/null
+++ b/test/javascripts/fixtures/signups_timeout.js.es6
@@ -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
+ }
+ }
+};
diff --git a/test/javascripts/fixtures/top_referred_topics.js.es6 b/test/javascripts/fixtures/top_referred_topics.js.es6
index a944c59fc78..231736a92d7 100644
--- a/test/javascripts/fixtures/top_referred_topics.js.es6
+++ b/test/javascripts/fixtures/top_referred_topics.js.es6
@@ -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"
}
}
};
diff --git a/test/javascripts/fixtures/topics.js.es6 b/test/javascripts/fixtures/topics.js.es6
index 7af8f8e008d..00257dc13bf 100644
--- a/test/javascripts/fixtures/topics.js.es6
+++ b/test/javascripts/fixtures/topics.js.es6
@@ -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"
}
}
};
diff --git a/test/javascripts/fixtures/trending-search.js.es6 b/test/javascripts/fixtures/trending-search.js.es6
index 8258466673b..906867eb7c9 100644
--- a/test/javascripts/fixtures/trending-search.js.es6
+++ b/test/javascripts/fixtures/trending-search.js.es6
@@ -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"
}
}
};
diff --git a/test/javascripts/fixtures/users-by-trust-level.js.es6 b/test/javascripts/fixtures/users-by-trust-level.js.es6
index 597f44a90a5..0d59c577c26 100644
--- a/test/javascripts/fixtures/users-by-trust-level.js.es6
+++ b/test/javascripts/fixtures/users-by-trust-level.js.es6
@@ -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"
}
}
};
diff --git a/test/javascripts/fixtures/users-by-type.js.es6 b/test/javascripts/fixtures/users-by-type.js.es6
index 1cc9dec287b..56d3e611834 100644
--- a/test/javascripts/fixtures/users-by-type.js.es6
+++ b/test/javascripts/fixtures/users-by-type.js.es6
@@ -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"
}
}
};
diff --git a/test/javascripts/models/report-test.js.es6 b/test/javascripts/models/report-test.js.es6
index 96b05d94b76..8d12e8680eb 100644
--- a/test/javascripts/models/report-test.js.es6
+++ b/test/javascripts/models/report-test.js.es6
@@ -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,
+ 'joffrey'
+ );
+ 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");
+});