import Component from "@ember/component"; import EmberObject, { action, computed } from "@ember/object"; import { alias, and, equal, notEmpty, or } from "@ember/object/computed"; import { next } from "@ember/runloop"; import { isPresent } from "@ember/utils"; import { classNameBindings, classNames } from "@ember-decorators/component"; import { exportEntity } from "discourse/lib/export-csv"; import { outputExportResult } from "discourse/lib/export-result"; import ReportLoader from "discourse/lib/reports-loader"; import { isTesting } from "discourse-common/config/environment"; import { makeArray } from "discourse-common/lib/helpers"; import discourseComputed from "discourse-common/utils/decorators"; import I18n from "discourse-i18n"; import Report, { DAILY_LIMIT_DAYS, SCHEMA_VERSION } from "admin/models/report"; const TABLE_OPTIONS = { perPage: 8, total: true, limit: 20, formatNumbers: true, }; const CHART_OPTIONS = {}; @classNameBindings( "isHidden:hidden", "isHidden::is-visible", "isEnabled", "isLoading", "dasherizedDataSourceName" ) @classNames("admin-report") export default class AdminReport extends Component { isEnabled = true; disabledLabel = I18n.t("admin.dashboard.disabled"); isLoading = false; rateLimitationString = null; dataSourceName = null; report = null; model = null; reportOptions = null; forcedModes = null; showAllReportsLink = false; filters = null; showTrend = false; showHeader = true; showTitle = true; showFilteringUI = false; @alias("model.dates_filtering") showDatesOptions; @or("showDatesOptions", "model.available_filters.length") showRefresh; @and("showTrend", "model.prev_period") shouldDisplayTrend; endDate = null; startDate = null; @or("showTimeoutError", "showExceptionError", "showNotFoundError") showError; @equal("model.error", "not_found") showNotFoundError; @equal("model.error", "timeout") showTimeoutError; @equal("model.error", "exception") showExceptionError; @notEmpty("model.data") hasData; _reports = []; @computed("siteSettings.dashboard_hidden_reports") get isHidden() { return (this.siteSettings.dashboard_hidden_reports || "") .split("|") .filter(Boolean) .includes(this.dataSourceName); } didReceiveAttrs() { super.didReceiveAttrs(...arguments); let startDate = moment(); if (this.filters && isPresent(this.filters.startDate)) { startDate = moment(this.filters.startDate, "YYYY-MM-DD"); } this.set("startDate", startDate); let endDate = moment(); if (this.filters && isPresent(this.filters.endDate)) { endDate = moment(this.filters.endDate, "YYYY-MM-DD"); } this.set("endDate", endDate); if (this.filters) { this.set("currentMode", this.filters.mode); } if (this.report) { this._renderReport(this.report, this.forcedModes, this.currentMode); } else if (this.dataSourceName) { this._fetchReport(); } } @discourseComputed("dataSourceName", "model.type") dasherizedDataSourceName(dataSourceName, type) { return (dataSourceName || type || "undefined").replace(/_/g, "-"); } @discourseComputed("dataSourceName", "model.type") dataSource(dataSourceName, type) { dataSourceName = dataSourceName || type; return `/admin/reports/${dataSourceName}`; } @discourseComputed("displayedModes.length") showModes(displayedModesLength) { return displayedModesLength > 1; } @discourseComputed("currentMode") isChartMode(currentMode) { return currentMode === "chart"; } @action changeGrouping(grouping) { this.send("refreshReport", { chartGrouping: grouping, }); } @discourseComputed("currentMode", "model.modes", "forcedModes") displayedModes(currentMode, reportModes, forcedModes) { const modes = forcedModes ? forcedModes.split(",") : reportModes; return makeArray(modes).map((mode) => { const base = `btn-default mode-btn ${mode}`; const cssClass = currentMode === mode ? `${base} btn-primary` : base; return { mode, cssClass, icon: mode === "table" ? "table" : "signal", }; }); } @discourseComputed("currentMode") modeComponent(currentMode) { return `admin-report-${currentMode.replace(/_/g, "-")}`; } @discourseComputed( "dataSourceName", "startDate", "endDate", "filters.customFilters" ) reportKey(dataSourceName, startDate, endDate, customFilters) { if (!dataSourceName || !startDate || !endDate) { return null; } startDate = startDate.toISOString(true).split("T")[0]; endDate = endDate.toISOString(true).split("T")[0]; let reportKey = "reports:"; reportKey += [ dataSourceName, isTesting() ? "start" : startDate.replace(/-/g, ""), isTesting() ? "end" : endDate.replace(/-/g, ""), "[:prev_period]", this.get("reportOptions.table.limit"), // Convert all filter values to strings to ensure unique serialization customFilters ? JSON.stringify(customFilters, (k, v) => (k ? `${v}` : v)) : null, SCHEMA_VERSION, ] .filter((x) => x) .map((x) => x.toString()) .join(":"); return reportKey; } @discourseComputed("options.chartGrouping", "model.chartData.length") chartGroupings(grouping, count) { const options = ["daily", "weekly", "monthly"]; return options.map((id) => { return { id, disabled: id === "daily" && count >= DAILY_LIMIT_DAYS, label: `admin.dashboard.reports.${id}`, class: `chart-grouping ${grouping === id ? "active" : "inactive"}`, }; }); } @action onChangeDateRange(range) { this.setProperties({ startDate: range.from, endDate: range.to, }); } @action applyFilter(id, value) { let customFilters = this.get("filters.customFilters") || {}; if (typeof value === "undefined") { delete customFilters[id]; } else { customFilters[id] = value; } this.send("refreshReport", { filters: customFilters, }); } @action refreshReport(options = {}) { if (!this.onRefresh) { return; } this.onRefresh({ type: this.get("model.type"), mode: this.currentMode, chartGrouping: options.chartGrouping, startDate: typeof options.startDate === "undefined" ? this.startDate : options.startDate, endDate: typeof options.endDate === "undefined" ? this.endDate : options.endDate, filters: typeof options.filters === "undefined" ? this.get("filters.customFilters") : options.filters, }); } @action exportCsv() { const args = { name: this.get("model.type"), start_date: this.startDate.toISOString(true).split("T")[0], end_date: this.endDate.toISOString(true).split("T")[0], }; const customFilters = this.get("filters.customFilters"); if (customFilters) { Object.assign(args, customFilters); } exportEntity("report", args).then(outputExportResult); } @action onChangeMode(mode) { this.set("currentMode", mode); this.send("refreshReport", { chartGrouping: null, }); } _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.dataSourceName); } else { return r; } }; if (!this.startDate || !this.endDate) { report = sort(filteredReports)[0]; } else { report = sort( filteredReports.filter((r) => r.report_key.includes(this.reportKey)) )[0]; if (!report) { return; } } if (report.error === "not_found") { this.set("showFilteringUI", false); } this._renderReport(report, this.forcedModes, this.currentMode); } _renderReport(report, forcedModes, currentMode) { const modes = forcedModes ? forcedModes.split(",") : report.modes; currentMode = currentMode || (modes ? modes[0] : null); this.setProperties({ model: report, currentMode, options: this._buildOptions(currentMode, report), }); } _fetchReport() { this.setProperties({ isLoading: true, rateLimitationString: null }); next(() => { let payload = this._buildPayload(["prev_period"]); const callback = (response) => { if (!this.element || this.isDestroying || this.isDestroyed) { return; } this.set("isLoading", false); if (response === 429) { this.set( "rateLimitationString", I18n.t("admin.dashboard.too_many_requests") ); } else if (response === 500) { this.set("model.error", "exception"); } else if (response) { this._reports.push(this._loadReport(response)); this._computeReport(); } }; ReportLoader.enqueue(this.dataSourceName, payload.data, callback); }); } _buildPayload(facets) { let payload = { data: { facets } }; if (this.startDate) { payload.data.start_date = moment(this.startDate) .toISOString(true) .split("T")[0]; } if (this.endDate) { payload.data.end_date = moment(this.endDate) .toISOString(true) .split("T")[0]; } if (this.get("reportOptions.table.limit")) { payload.data.limit = this.get("reportOptions.table.limit"); } if (this.get("filters.customFilters")) { payload.data.filters = this.get("filters.customFilters"); } return payload; } _buildOptions(mode, report) { if (mode === "table") { const tableOptions = JSON.parse(JSON.stringify(TABLE_OPTIONS)); return EmberObject.create( Object.assign(tableOptions, this.get("reportOptions.table") || {}) ); } else if (mode === "chart") { const chartOptions = JSON.parse(JSON.stringify(CHART_OPTIONS)); return EmberObject.create( Object.assign(chartOptions, this.get("reportOptions.chart") || {}, { chartGrouping: this.get("reportOptions.chartGrouping") || Report.groupingForDatapoints(report.chartData.length), }) ); } else if (mode === "stacked-chart") { return this.get("reportOptions.stackedChart") || {}; } } _loadReport(jsonReport) { Report.fillMissingDates(jsonReport, { filledField: "chartData" }); if (jsonReport.chartData && jsonReport.modes[0] === "stacked_chart") { jsonReport.chartData = jsonReport.chartData.map((chartData) => { if (chartData.length > 40) { return { data: chartData.data, req: chartData.req, label: chartData.label, color: chartData.color, }; } else { return chartData; } }); } if (jsonReport.prev_data) { Report.fillMissingDates(jsonReport, { filledField: "prevChartData", dataField: "prev_data", starDate: jsonReport.prev_startDate, endDate: jsonReport.prev_endDate, }); } return Report.create(jsonReport); } }