FEATURE: part 2 of dashboard improvements

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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