DEV: Convert AdminReport component to gjs (#31011)

This commit converts the `AdminReport` component, which is quite
high complexity, to gjs. After this initial round, ideally this
component would be broken up into smaller components because it is
getting quite big now.

Also in this commit:

* Add an option to display the report description in a tooltip, which
was
   the main way the description was shown until recently. We want to use
   this on the dashboard view mostly.
* Move admin report "mode" definitions to the server-side Report model,
inside a `Report::MODES` constant, collecting the modes defined in
various
   places in the UI into one place
* Refactor report code to refer to mode definitions
* Add a `REPORT_MODES` constant in JS via javascript.rake and refactor
  JS to refer to the modes
* Delete old admin report components that are no longer used
  (trust-level-counts, counts, per-day-counts) which were replaced
  by admin-report-counters a while ago
* Add a new `registerReportModeComponent` plugin API, some plugins
   introduce their own modes (like AI's `emotion`) and components and
   we need a way to render them
This commit is contained in:
Martin Brennan
2025-01-29 10:33:43 +10:00
committed by GitHub
parent 5a64d0e932
commit 15838aa756
48 changed files with 924 additions and 792 deletions

View File

@ -1,36 +0,0 @@
<td class="title">
{{#if this.report.icon}}
{{d-icon this.report.icon}}
{{/if}}
<a href={{this.report.reportUrl}}>{{this.report.title}}</a>
</td>
<td class="value">{{number this.report.todayCount}}</td>
<td
class="value {{this.report.yesterdayTrend}}"
title={{this.report.yesterdayCountTitle}}
>
{{number this.report.yesterdayCount}}
{{d-icon this.report.yesterdayTrendIcon}}
</td>
<td
class="value {{this.report.sevenDaysTrend}}"
title={{this.report.sevenDaysCountTitle}}
>
{{number this.report.lastSevenDaysCount}}
{{d-icon this.report.sevenDaysTrendIcon}}
</td>
<td
class="value {{this.report.thirtyDaysTrend}}"
title={{this.report.thirtyDaysCountTitle}}
>
{{number this.report.lastThirtyDaysCount}}
{{d-icon this.report.thirtyDaysTrendIcon}}
</td>
{{#if this.allTime}}
<td class="value">{{number this.report.total}}</td>
{{/if}}

View File

@ -1,12 +0,0 @@
import Component from "@ember/component";
import { match } from "@ember/object/computed";
import { classNameBindings, tagName } from "@ember-decorators/component";
@tagName("tr")
@classNameBindings("reverseColors")
export default class AdminReportCounts extends Component {
allTime = true;
@match("report.type", /^(time_to_first_response|topics_with_no_response)$/)
reverseColors;
}

View File

@ -1,8 +0,0 @@
<td class="title"><a
href={{this.report.reportUrl}}
>{{this.report.title}}</a></td>
<td class="value">{{this.report.todayCount}}</td>
<td class="value">{{this.report.yesterdayCount}}</td>
<td class="value">{{this.report.sevenDaysAgoCount}}</td>
<td class="value">{{this.report.thirtyDaysAgoCount}}</td>
<td class="value"></td>

View File

@ -1,5 +0,0 @@
import Component from "@ember/component";
import { tagName } from "@ember-decorators/component";
@tagName("tr")
export default class AdminReportPerDayCounts extends Component {}

View File

@ -1,26 +0,0 @@
<td class="title">{{this.report.title}}</td>
<td class="value">
<LinkTo @route="adminUsersList.show" @model="newuser">
{{number (value-at-tl this.report.data level="0")}}
</LinkTo>
</td>
<td class="value">
<LinkTo @route="adminUsersList.show" @model="basic">
{{number (value-at-tl this.report.data level="1")}}
</LinkTo>
</td>
<td class="value">
<LinkTo @route="adminUsersList.show" @model="member">
{{number (value-at-tl this.report.data level="2")}}
</LinkTo>
</td>
<td class="value">
<LinkTo @route="adminUsersList.show" @model="regular">
{{number (value-at-tl this.report.data level="3")}}
</LinkTo>
</td>
<td class="value">
<LinkTo @route="adminUsersList.show" @model="leader">
{{number (value-at-tl this.report.data level="4")}}
</LinkTo>
</td>

View File

@ -1,5 +0,0 @@
import Component from "@ember/component";
import { tagName } from "@ember-decorators/component";
@tagName("tr")
export default class AdminReportTrustLevelCounts extends Component {}

View File

@ -0,0 +1,784 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { concat, fn } from "@ember/helper";
import EmberObject, { action } from "@ember/object";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { next } from "@ember/runloop";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { isPresent } from "@ember/utils";
import ConditionalLoadingSection from "discourse/components/conditional-loading-section";
import DButton from "discourse/components/d-button";
import DPageSubheader from "discourse/components/d-page-subheader";
import DateTimeInputRange from "discourse/components/date-time-input-range";
import concatClass from "discourse/helpers/concat-class";
import dIcon from "discourse/helpers/d-icon";
import number from "discourse/helpers/number";
import { reportModeComponent } from "discourse/lib/admin-report-additional-modes";
import { REPORT_MODES } from "discourse/lib/constants";
import { bind } from "discourse/lib/decorators";
import { isTesting } from "discourse/lib/environment";
import { exportEntity } from "discourse/lib/export-csv";
import { outputExportResult } from "discourse/lib/export-result";
import { makeArray } from "discourse/lib/helpers";
import ReportLoader from "discourse/lib/reports-loader";
import { i18n } from "discourse-i18n";
import AdminReportChart from "admin/components/admin-report-chart";
import AdminReportCounters from "admin/components/admin-report-counters";
import AdminReportInlineTable from "admin/components/admin-report-inline-table";
import AdminReportRadar from "admin/components/admin-report-radar";
import AdminReportStackedChart from "admin/components/admin-report-stacked-chart";
import AdminReportStackedLineChart from "admin/components/admin-report-stacked-line-chart";
import AdminReportStorageStats from "admin/components/admin-report-storage-stats";
import AdminReportTable from "admin/components/admin-report-table";
import ReportFilterBoolComponent from "admin/components/report-filters/bool";
import ReportFilterCategoryComponent from "admin/components/report-filters/category";
import ReportFilterGroupComponent from "admin/components/report-filters/group";
import ReportFilterListComponent from "admin/components/report-filters/list";
import Report, { DAILY_LIMIT_DAYS, SCHEMA_VERSION } from "admin/models/report";
import DTooltip from "float-kit/components/d-tooltip";
const TABLE_OPTIONS = {
perPage: 8,
total: true,
limit: 20,
formatNumbers: true,
};
const CHART_OPTIONS = {};
export default class AdminReport extends Component {
@service siteSettings;
@tracked isEnabled = true;
@tracked isLoading = false;
@tracked rateLimitationString = null;
@tracked report = null;
@tracked model = null;
@tracked showTitle = true;
@tracked currentMode = this.args.filters?.mode;
@tracked options = null;
@tracked dateRangeFrom = null;
@tracked dateRangeTo = null;
showHeader = this.args.showHeader ?? true;
showFilteringUI = this.args.showFilteringUI ?? false;
showDescriptionInTooltip = this.args.showDescriptionInTooltip ?? true;
_reports = [];
constructor() {
super(...arguments);
this.fetchOrRender();
}
get startDate() {
if (this.dateRangeFrom) {
return moment(this.dateRangeFrom);
}
let startDate = moment();
if (this.args.filters && isPresent(this.args.filters.startDate)) {
startDate = moment(this.args.filters.startDate, "YYYY-MM-DD");
}
return startDate;
}
get endDate() {
if (this.dateRangeTo) {
return moment(this.dateRangeTo);
}
let endDate = moment();
if (this.args.filters && isPresent(this.args.filters.endDate)) {
endDate = moment(this.args.filters.endDate, "YYYY-MM-DD");
}
return endDate;
}
get reportClasses() {
const builtReportClasses = [];
if (this.isHidden) {
builtReportClasses.push("hidden");
}
if (!this.isHidden) {
builtReportClasses.push("is-visible");
}
if (this.isEnabled) {
builtReportClasses.push("is-enabled");
}
if (this.isLoading) {
builtReportClasses.push("is-loading");
}
if (this.showDescriptionInTooltip) {
builtReportClasses.push("description-in-tooltip");
}
builtReportClasses.push(this.dasherizedDataSourceName);
return builtReportClasses.join(" ");
}
get showDatesOptions() {
return this.model?.dates_filtering;
}
get showRefresh() {
return this.showDatesOptions || this.model?.available_filters.length > 0;
}
get shouldDisplayTrend() {
return this.args.showTrend && this.model?.prev_period;
}
get showError() {
return (
this.showTimeoutError || this.showExceptionError || this.showNotFoundError
);
}
get showNotFoundError() {
return this.model?.error === "not_found";
}
get showTimeoutError() {
return this.model?.error === "timeout";
}
get showExceptionError() {
return this.model?.error === "exception";
}
get hasData() {
return this.model?.data?.length > 0;
}
get disabledLabel() {
return this.args.disabledLabel || i18n("admin.dashboard.disabled");
}
get isHidden() {
return (this.siteSettings.dashboard_hidden_reports || "")
.split("|")
.filter(Boolean)
.includes(this.args.dataSourceName);
}
get dasherizedDataSourceName() {
return (this.args.dataSourceName || this.model.type || "undefined").replace(
/_/g,
"-"
);
}
get dataSource() {
let dataSourceName = this.args.dataSourceName || this.model.type;
return `/admin/reports/${dataSourceName}`;
}
get showModes() {
return this.displayedModes.length > 1;
}
get isChartMode() {
return this.currentMode === REPORT_MODES.chart;
}
@action
changeGrouping(grouping) {
this.refreshReport({ chartGrouping: grouping });
}
get displayedModes() {
const modes = this.args.forcedModes
? this.args.forcedModes.split(",")
: this.model?.modes;
return makeArray(modes).map((mode) => {
const base = `btn-default mode-btn ${mode}`;
const cssClass = this.currentMode === mode ? `${base} btn-primary` : base;
return {
mode,
cssClass,
icon: mode === REPORT_MODES.table ? "table" : "signal",
};
});
}
reportFilterComponent(filter) {
switch (filter.type) {
case "bool":
return ReportFilterBoolComponent;
case "category":
return ReportFilterCategoryComponent;
case "group":
return ReportFilterGroupComponent;
case "list":
return ReportFilterListComponent;
}
}
get modeComponent() {
const reportMode = this.currentMode.replace(/-/g, "_");
switch (reportMode) {
case REPORT_MODES.table:
return AdminReportTable;
case REPORT_MODES.inline_table:
return AdminReportInlineTable;
case REPORT_MODES.chart:
return AdminReportChart;
case REPORT_MODES.stacked_chart:
return AdminReportStackedChart;
case REPORT_MODES.stacked_line_chart:
return AdminReportStackedLineChart;
case REPORT_MODES.counters:
return AdminReportCounters;
case REPORT_MODES.radar:
return AdminReportRadar;
case REPORT_MODES.storage_stats:
return AdminReportStorageStats;
default:
if (reportModeComponent(reportMode)) {
return reportModeComponent(reportMode);
}
return null;
}
}
get reportKey() {
if (!this.args.dataSourceName || !this.startDate || !this.endDate) {
return null;
}
const formattedStartDate = this.startDate.toISOString(true).split("T")[0];
const formattedEndDate = this.endDate.toISOString(true).split("T")[0];
let reportKey = "reports:";
reportKey += [
this.args.dataSourceName,
isTesting() ? "start" : formattedStartDate.replace(/-/g, ""),
isTesting() ? "end" : formattedEndDate.replace(/-/g, ""),
"[:prev_period]",
this.args.reportOptions?.table?.limit,
// Convert all filter values to strings to ensure unique serialization
this.args.filters?.customFilters
? JSON.stringify(this.args.filters?.customFilters, (k, v) =>
k ? `${v}` : v
)
: null,
SCHEMA_VERSION,
]
.filter((x) => x)
.map((x) => x.toString())
.join(":");
return reportKey;
}
get chartGroupings() {
const chartGrouping = this.options?.chartGrouping;
const options = ["daily", "weekly", "monthly"];
return options.map((id) => {
return {
id,
disabled:
id === "daily" && this.model.chartData.length >= DAILY_LIMIT_DAYS,
label: `admin.dashboard.reports.${id}`,
class: `chart-grouping ${chartGrouping === id ? "active" : "inactive"}`,
};
});
}
@action
onChangeDateRange(range) {
this.dateRangeFrom = range.from;
this.dateRangeTo = range.to;
}
@action
applyFilter(id, value) {
let customFilters = this.args.filters?.customFilters || {};
if (typeof value === "undefined") {
delete customFilters[id];
} else {
customFilters[id] = value;
}
this.refreshReport({ filters: customFilters });
}
@action
refreshReport(options = {}) {
if (!this.args.onRefresh) {
return;
}
this.args.onRefresh({
type: this.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.args.filters?.customFilters
: options.filters,
});
}
@action
exportCsv() {
const args = {
name: this.model.type,
start_date: this.startDate.toISOString(true).split("T")[0],
end_date: this.endDate.toISOString(true).split("T")[0],
};
const customFilters = this.args.filters?.customFilters;
if (customFilters) {
Object.assign(args, customFilters);
}
exportEntity("report", args).then(outputExportResult);
}
@action
onChangeMode(mode) {
this.currentMode = mode;
this.refreshReport({ chartGrouping: null });
}
@bind
fetchOrRender() {
if (this.report) {
this._renderReport(this.report);
} else if (this.args.dataSourceName) {
this._fetchReport();
}
}
@bind
_computeReport() {
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 foundReport;
const sort = (report) => {
if (report.length > 1) {
return report.findBy("type", this.args.dataSourceName);
} else {
return report;
}
};
if (!this.startDate || !this.endDate) {
foundReport = sort(filteredReports)[0];
} else {
const reportKey = this.reportKey;
foundReport = sort(
filteredReports.filter((report) =>
report.report_key.includes(reportKey)
)
)[0];
if (!foundReport) {
return;
}
}
if (foundReport.error === "not_found") {
this.showFilteringUI = false;
}
this._renderReport(foundReport);
}
@bind
_renderReport(report) {
const modes = this.args.forcedModes?.split(",") || report.modes;
const currentMode = this.currentMode || modes?.[0];
this.model = report;
this.currentMode = currentMode;
this.options = this._buildOptions(currentMode, report);
}
@bind
_fetchReport() {
this.isLoading = true;
this.rateLimitationString = null;
next(() => {
let payload = this._buildPayload(["prev_period"]);
const callback = (response) => {
if (this.isDestroying || this.isDestroyed) {
return;
}
this.isLoading = false;
if (response === 429) {
this.rateLimitationString = i18n("admin.dashboard.too_many_requests");
} else if (response === 500) {
this.model?.set("error", "exception");
} else if (response) {
this._reports.push(this._loadReport(response));
this._computeReport();
}
};
ReportLoader.enqueue(this.args.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.args.reportOptions?.table?.limit) {
payload.data.limit = this.args.reportOptions?.table?.limit;
}
if (this.args.filters?.customFilters) {
payload.data.filters = this.args.filters?.customFilters;
}
return payload;
}
_buildOptions(mode, report) {
if (mode === REPORT_MODES.table) {
const tableOptions = JSON.parse(JSON.stringify(TABLE_OPTIONS));
return EmberObject.create(
Object.assign(tableOptions, this.args.reportOptions?.table || {})
);
} else if (mode === REPORT_MODES.chart) {
const chartOptions = JSON.parse(JSON.stringify(CHART_OPTIONS));
return EmberObject.create(
Object.assign(chartOptions, this.args.reportOptions?.chart || {}, {
chartGrouping:
this.args.reportOptions?.chartGrouping ||
Report.groupingForDatapoints(report.chartData.length),
})
);
} else if (mode === REPORT_MODES.stacked_chart) {
return this.args.reportOptions?.stackedChart || {};
}
}
_loadReport(jsonReport) {
Report.fillMissingDates(jsonReport, { filledField: "chartData" });
if (
jsonReport.chartData &&
jsonReport.modes[0] === REPORT_MODES.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);
}
<template>
<div
class={{concatClass "admin-report" this.reportClasses}}
{{didUpdate this.fetchOrRender @filters.startDate @filters.endDate}}
>
{{#unless this.isHidden}}
{{#if this.isEnabled}}
<ConditionalLoadingSection @isLoading={{this.isLoading}}>
{{#if this.showHeader}}
<div class="header">
{{#if this.showTitle}}
{{#unless this.showNotFoundError}}
<DPageSubheader
@titleLabel={{this.model.title}}
@titleUrl={{this.model.reportUrl}}
@descriptionLabel={{unless
this.showDescriptionInTooltip
this.model.description
}}
@learnMoreUrl={{this.model.description_link}}
/>
{{#if this.showDescriptionInTooltip}}
{{#if this.model.description}}
<DTooltip
@interactive={{this.model.description_link.length}}
>
<:trigger>
{{dIcon "circle-question"}}
</:trigger>
<:content>
{{#if this.model.description_link}}
<a
target="_blank"
rel="noopener noreferrer"
href={{this.model.description_link}}
class="info"
>
{{this.model.description}}
</a>
{{else}}
<span>{{this.model.description}}</span>
{{/if}}
</:content>
</DTooltip>
{{/if}}
{{/if}}
{{/unless}}
{{/if}}
{{#if this.shouldDisplayTrend}}
<div class="trend {{this.model.trend}}">
<span class="value" title={{this.model.trendTitle}}>
{{#if this.model.average}}
{{number this.model.currentAverage}}{{#if
this.model.percent
}}%{{/if}}
{{else}}
{{number this.model.currentTotal noTitle="true"}}{{#if
this.model.percent
}}%{{/if}}
{{/if}}
{{#if this.model.trendIcon}}
{{dIcon this.model.trendIcon class="icon"}}
{{/if}}
</span>
</div>
{{/if}}
</div>
{{/if}}
<div class="body">
<div class="main">
{{#if this.showError}}
{{#if this.showTimeoutError}}
<div class="alert alert-error report-alert timeout">
{{dIcon "triangle-exclamation"}}
<span>{{i18n "admin.dashboard.timeout_error"}}</span>
</div>
{{/if}}
{{#if this.showExceptionError}}
<div class="alert alert-error report-alert exception">
{{dIcon "triangle-exclamation"}}
<span>{{i18n "admin.dashboard.exception_error"}}</span>
</div>
{{/if}}
{{#if this.showNotFoundError}}
<div class="alert alert-error report-alert not-found">
{{dIcon "triangle-exclamation"}}
<span>{{i18n "admin.dashboard.not_found_error"}}</span>
</div>
{{/if}}
{{else}}
{{#if this.hasData}}
{{#if this.currentMode}}
{{component
this.modeComponent
model=this.model
options=this.options
}}
{{#if this.model.relatedReport}}
<AdminReport
@showFilteringUI={{false}}
@dataSourceName={{this.model.relatedReport.type}}
/>
{{/if}}
{{/if}}
{{else}}
{{#if this.rateLimitationString}}
<div class="alert alert-error report-alert rate-limited">
{{dIcon "temperature-three-quarters"}}
<span>{{this.rateLimitationString}}</span>
</div>
{{else}}
<div class="alert alert-info report-alert no-data">
{{dIcon "chart-pie"}}
{{#if this.model.reportUrl}}
<a href={{this.model.reportUrl}} class="report-url">
<span>
{{#if this.model.title}}
{{this.model.title}}
{{/if}}
{{i18n "admin.dashboard.reports.no_data"}}
</span>
</a>
{{else}}
<span>{{i18n
"admin.dashboard.reports.no_data"
}}</span>
{{/if}}
</div>
{{/if}}
{{/if}}
{{/if}}
</div>
{{#if this.showFilteringUI}}
<div class="filters">
{{#if this.showModes}}
<div class="modes">
{{#each this.displayedModes as |displayedMode|}}
<DButton
@action={{fn this.onChangeMode displayedMode.mode}}
@icon={{displayedMode.icon}}
class={{displayedMode.cssClass}}
/>
{{/each}}
</div>
{{/if}}
{{#if this.isChartMode}}
{{#if this.model.average}}
<span class="average-chart">
{{i18n "admin.dashboard.reports.average_chart_label"}}
</span>
{{/if}}
<div class="chart-groupings">
{{#each this.chartGroupings as |chartGrouping|}}
<DButton
@label={{chartGrouping.label}}
@action={{fn this.changeGrouping chartGrouping.id}}
@disabled={{chartGrouping.disabled}}
class={{chartGrouping.class}}
/>
{{/each}}
</div>
{{/if}}
{{#if this.showDatesOptions}}
<div class="control">
<span class="label">
{{i18n "admin.dashboard.reports.dates"}}
</span>
<div class="input">
<DateTimeInputRange
@from={{this.startDate}}
@to={{this.endDate}}
@onChange={{this.onChangeDateRange}}
@showFromTime={{false}}
@showToTime={{false}}
/>
</div>
</div>
{{/if}}
{{#each this.model.available_filters as |filter|}}
<div class="control">
<span class="label">
{{i18n
(concat
"admin.dashboard.reports.filters."
filter.id
".label"
)
}}
</span>
<div class="input">
{{component
(this.reportFilterComponent filter)
model=this.model
filter=filter
applyFilter=this.applyFilter
}}
</div>
</div>
{{/each}}
<div class="control">
<div class="input">
<DButton
@action={{this.exportCsv}}
@label="admin.export_csv.button_text"
@icon="download"
class="btn-default export-csv-btn"
/>
</div>
</div>
{{#if this.showRefresh}}
<div class="control">
<div class="input">
<DButton
@action={{this.refreshReport}}
@label="admin.dashboard.reports.refresh_report"
@icon="arrows-rotate"
class="refresh-report-btn btn-primary"
/>
</div>
</div>
{{/if}}
</div>
{{/if}}
</div>
</ConditionalLoadingSection>
{{else}}
<div class="alert alert-info">
{{htmlSafe this.disabledLabel}}
</div>
{{/if}}
{{/unless}}
</div>
</template>
}

View File

@ -1,209 +0,0 @@
{{#unless this.isHidden}}
{{#if this.isEnabled}}
<ConditionalLoadingSection @isLoading={{this.isLoading}}>
{{#if this.showHeader}}
<div class="header">
{{#if this.showTitle}}
{{#unless this.showNotFoundError}}
<DPageSubheader
@titleLabel={{this.model.title}}
@titleUrl={{this.model.reportUrl}}
@descriptionLabel={{this.model.description}}
@learnMoreUrl={{this.model.description_link}}
/>
{{/unless}}
{{/if}}
{{#if this.shouldDisplayTrend}}
<div class="trend {{this.model.trend}}">
<span class="value" title={{this.model.trendTitle}}>
{{#if this.model.average}}
{{number this.model.currentAverage}}{{#if
this.model.percent
}}%{{/if}}
{{else}}
{{number this.model.currentTotal noTitle="true"}}{{#if
this.model.percent
}}%{{/if}}
{{/if}}
{{#if this.model.trendIcon}}
{{d-icon this.model.trendIcon class="icon"}}
{{/if}}
</span>
</div>
{{/if}}
</div>
{{/if}}
<div class="body">
<div class="main">
{{#if this.showError}}
{{#if this.showTimeoutError}}
<div class="alert alert-error report-alert timeout">
{{d-icon "triangle-exclamation"}}
<span>{{i18n "admin.dashboard.timeout_error"}}</span>
</div>
{{/if}}
{{#if this.showExceptionError}}
<div class="alert alert-error report-alert exception">
{{d-icon "triangle-exclamation"}}
<span>{{i18n "admin.dashboard.exception_error"}}</span>
</div>
{{/if}}
{{#if this.showNotFoundError}}
<div class="alert alert-error report-alert not-found">
{{d-icon "triangle-exclamation"}}
<span>{{i18n "admin.dashboard.not_found_error"}}</span>
</div>
{{/if}}
{{else}}
{{#if this.hasData}}
{{#if this.currentMode}}
{{component
this.modeComponent
model=this.model
options=this.options
}}
{{#if this.model.relatedReport}}
<AdminReport
@showFilteringUI={{false}}
@dataSourceName={{this.model.relatedReport.type}}
/>
{{/if}}
{{/if}}
{{else}}
{{#if this.rateLimitationString}}
<div class="alert alert-error report-alert rate-limited">
{{d-icon "temperature-three-quarters"}}
<span>{{this.rateLimitationString}}</span>
</div>
{{else}}
<div class="alert alert-info report-alert no-data">
{{d-icon "chart-pie"}}
{{#if this.model.reportUrl}}
<a href={{this.model.reportUrl}} class="report-url">
<span>
{{#if this.model.title}}
{{this.model.title}}
{{/if}}
{{i18n "admin.dashboard.reports.no_data"}}
</span>
</a>
{{else}}
<span>{{i18n "admin.dashboard.reports.no_data"}}</span>
{{/if}}
</div>
{{/if}}
{{/if}}
{{/if}}
</div>
{{#if this.showFilteringUI}}
<div class="filters">
{{#if this.showModes}}
<div class="modes">
{{#each this.displayedModes as |displayedMode|}}
<DButton
@action={{fn this.onChangeMode displayedMode.mode}}
@icon={{displayedMode.icon}}
class={{displayedMode.cssClass}}
/>
{{/each}}
</div>
{{/if}}
{{#if this.isChartMode}}
{{#if this.model.average}}
<span class="average-chart">
{{i18n "admin.dashboard.reports.average_chart_label"}}
</span>
{{/if}}
<div class="chart-groupings">
{{#each this.chartGroupings as |chartGrouping|}}
<DButton
@label={{chartGrouping.label}}
@action={{fn this.changeGrouping chartGrouping.id}}
@disabled={{chartGrouping.disabled}}
class={{chartGrouping.class}}
/>
{{/each}}
</div>
{{/if}}
{{#if this.showDatesOptions}}
<div class="control">
<span class="label">
{{i18n "admin.dashboard.reports.dates"}}
</span>
<div class="input">
<DateTimeInputRange
@from={{this.startDate}}
@to={{this.endDate}}
@onChange={{this.onChangeDateRange}}
@showFromTime={{false}}
@showToTime={{false}}
/>
</div>
</div>
{{/if}}
{{#each this.model.available_filters as |filter|}}
<div class="control">
<span class="label">
{{i18n
(concat
"admin.dashboard.reports.filters." filter.id ".label"
)
}}
</span>
<div class="input">
{{component
(concat "report-filters/" filter.type)
model=this.model
filter=filter
applyFilter=this.applyFilter
}}
</div>
</div>
{{/each}}
<div class="control">
<div class="input">
<DButton
@action={{this.exportCsv}}
@label="admin.export_csv.button_text"
@icon="download"
class="btn-default export-csv-btn"
/>
</div>
</div>
{{#if this.showRefresh}}
<div class="control">
<div class="input">
<DButton
@action={{this.refreshReport}}
@label="admin.dashboard.reports.refresh_report"
@icon="arrows-rotate"
class="refresh-report-btn btn-primary"
/>
</div>
</div>
{{/if}}
</div>
{{/if}}
</div>
</ConditionalLoadingSection>
{{else}}
<div class="alert alert-info">
{{html-safe this.disabledLabel}}
</div>
{{/if}}
{{/unless}}

View File

@ -1,427 +0,0 @@
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 discourseComputed from "discourse/lib/decorators";
import { isTesting } from "discourse/lib/environment";
import { exportEntity } from "discourse/lib/export-csv";
import { outputExportResult } from "discourse/lib/export-result";
import { makeArray } from "discourse/lib/helpers";
import ReportLoader from "discourse/lib/reports-loader";
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("admin.dashboard.disabled");
isLoading = false;
rateLimitationString = null;
dataSourceName = null;
report = null;
model = null;
reportOptions = null;
forcedModes = null;
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("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" || 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);
}
}

View File

@ -2,6 +2,7 @@ import { inject as controller } from "@ember/controller";
import { computed } from "@ember/object";
import { service } from "@ember/service";
import { setting } from "discourse/lib/computed";
import { REPORT_MODES } from "discourse/lib/constants";
import discourseComputed from "discourse/lib/decorators";
import getURL from "discourse/lib/get-url";
import { makeArray } from "discourse/lib/helpers";
@ -30,6 +31,10 @@ export default class AdminDashboardGeneralController extends AdminDashboardTabCo
@staticReport("users_by_trust_level") usersByTrustLevelReport;
@staticReport("storage_report") storageReport;
get reportModes() {
return REPORT_MODES;
}
@discourseComputed("siteSettings.dashboard_general_tab_activity_metrics")
activityMetrics(metrics) {
return (metrics || "").split("|").filter(Boolean);
@ -53,7 +58,7 @@ export default class AdminDashboardGeneralController extends AdminDashboardTabCo
@computed("hiddenReports")
get isSearchReportsVisible() {
return ["top_referred_topics", "trending_search"].some(
(x) => !this.hiddenReports.includes(x)
(report) => !this.hiddenReports.includes(report)
);
}
@ -68,7 +73,7 @@ export default class AdminDashboardGeneralController extends AdminDashboardTabCo
"dau_by_mau",
"daily_engaged_users",
"new_contributors",
].some((x) => !this.hiddenReports.includes(x));
].some((report) => !this.hiddenReports.includes(report));
}
@discourseComputed
@ -76,11 +81,6 @@ export default class AdminDashboardGeneralController extends AdminDashboardTabCo
return moment().locale("en").utc().endOf("day");
}
@computed("startDate", "endDate")
get filters() {
return { startDate: this.startDate, endDate: this.endDate };
}
@discourseComputed
activityMetricsFilters() {
const lastMonth = moment()

View File

@ -1,23 +1,25 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action, computed } from "@ember/object";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { TrackedObject } from "@ember-compat/tracked-built-ins";
import CustomDateRangeModal from "../components/modal/custom-date-range";
export default class AdminDashboardTabController extends Controller {
@service modal;
@tracked endDate = moment().locale("en").utc().endOf("day");
@tracked startDate = this.calculateStartDate();
@tracked
filters = new TrackedObject({
startDate: this.startDate,
endDate: this.endDate,
});
queryParams = ["period"];
period = "monthly";
endDate = moment().locale("en").utc().endOf("day");
_startDate;
@computed("_startDate", "period")
get startDate() {
if (this._startDate) {
return this._startDate;
}
calculateStartDate() {
const fullDay = moment().locale("en").utc().endOf("day");
switch (this.period) {
@ -35,13 +37,19 @@ export default class AdminDashboardTabController extends Controller {
}
@action
setCustomDateRange(_startDate, endDate) {
this.setProperties({ _startDate, endDate });
setCustomDateRange(startDate, endDate) {
this.startDate = startDate;
this.endDate = endDate;
this.filters.startDate = this.startDate;
this.filters.endDate = this.endDate;
}
@action
setPeriod(period) {
this.setProperties({ period, _startDate: null });
this.set("period", period);
this.startDate = this.calculateStartDate();
this.filters.startDate = this.startDate;
this.filters.endDate = this.endDate;
}
@action

View File

@ -17,6 +17,7 @@
@startDate={{this.startDate}}
@endDate={{this.endDate}}
@setCustomDateRange={{this.setCustomDateRange}}
@onDateChange={{this.onDateChange}}
/>
</div>
@ -25,13 +26,13 @@
{{#if this.siteSettings.use_legacy_pageviews}}
<AdminReport
@dataSourceName="consolidated_page_views"
@forcedModes="stacked-chart"
@forcedModes={{this.reportModes.stacked_chart}}
@filters={{this.filters}}
/>
{{else}}
<AdminReport
@dataSourceName="site_traffic"
@forcedModes="stacked-chart"
@forcedModes={{this.reportModes.stacked_chart}}
@reportOptions={{this.siteTrafficOptions}}
@filters={{this.filters}}
/>
@ -40,42 +41,42 @@
<AdminReport
@dataSourceName="signups"
@showTrend={{true}}
@forcedModes="chart"
@forcedModes={{this.reportModes.chart}}
@filters={{this.filters}}
/>
<AdminReport
@dataSourceName="topics"
@showTrend={{true}}
@forcedModes="chart"
@forcedModes={{this.reportModes.chart}}
@filters={{this.filters}}
/>
<AdminReport
@dataSourceName="posts"
@showTrend={{true}}
@forcedModes="chart"
@forcedModes={{this.reportModes.chart}}
@filters={{this.filters}}
/>
<AdminReport
@dataSourceName="dau_by_mau"
@showTrend={{true}}
@forcedModes="chart"
@forcedModes={{this.reportModes.chart}}
@filters={{this.filters}}
/>
<AdminReport
@dataSourceName="daily_engaged_users"
@showTrend={{true}}
@forcedModes="chart"
@forcedModes={{this.reportModes.chart}}
@filters={{this.filters}}
/>
<AdminReport
@dataSourceName="new_contributors"
@showTrend={{true}}
@forcedModes="chart"
@forcedModes={{this.reportModes.chart}}
@filters={{this.filters}}
/>
</div>
@ -120,7 +121,7 @@
<AdminReport
@showHeader={{false}}
@filters={{this.activityMetricsFilters}}
@forcedModes="counters"
@forcedModes={{this.reportModes.counters}}
@dataSourceName={{metric}}
/>
{{/each}}
@ -133,12 +134,12 @@
<div class="user-metrics">
<ConditionalLoadingSection @isLoading={{this.isLoading}}>
<AdminReport
@forcedModes="inline-table"
@forcedModes={{this.reportModes.inline_table}}
@dataSourceName="users_by_type"
/>
<AdminReport
@forcedModes="inline-table"
@forcedModes={{this.reportModes.inline_table}}
@dataSourceName="users_by_trust_level"
/>
</ConditionalLoadingSection>
@ -146,7 +147,7 @@
<div class="misc">
<AdminReport
@forcedModes="storage-stats"
@forcedModes={{this.reportModes.storage_stats}}
@dataSourceName="storage_stats"
@showHeader={{false}}
/>

View File

@ -6,6 +6,7 @@
@filters={{this.model}}
@reportOptions={{this.reportOptions}}
@showFilteringUI={{true}}
@showDescriptionInTooltip={{false}}
@onRefresh={{route-action "onParamsChange"}}
/>
</div>

View File

@ -0,0 +1,10 @@
let additionalReportModes = new Map();
export function registerReportModeComponent(mode, componentClass) {
additionalReportModes.set(mode, componentClass);
}
export function resetAdditionalReportModes() {
additionalReportModes.clear();
}
export function reportModeComponent(mode) {
return additionalReportModes.get(mode);
}

View File

@ -106,3 +106,14 @@ export const USER_FIELD_FLAGS = [
"show_on_user_card",
"searchable",
];
export const REPORT_MODES = {
table: "table",
chart: "chart",
stacked_chart: "stacked_chart",
stacked_line_chart: "stacked_line_chart",
radar: "radar",
counters: "counters",
inline_table: "inline_table",
storage_stats: "storage_stats",
};

View File

@ -3,7 +3,7 @@
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
// using the format described at https://keepachangelog.com/en/1.0.0/.
export const PLUGIN_API_VERSION = "2.0.0";
export const PLUGIN_API_VERSION = "2.0.1";
import $ from "jquery";
import { h } from "virtual-dom";
@ -61,6 +61,7 @@ import {
registerAdminPluginConfigNav,
} from "discourse/lib/admin-plugin-config-nav";
import { registerPluginHeaderActionComponent } from "discourse/lib/admin-plugin-header-actions";
import { registerReportModeComponent } from "discourse/lib/admin-report-additional-modes";
import classPrepend, {
withPrependsRolledBack,
} from "discourse/lib/class-prepend";
@ -3375,6 +3376,19 @@ class PluginApi {
registeredTabs.push(tab);
}
/**
* Registers a report mode and an associated component, which will be rendered
* by the AdminReport component. A mode is a different way of displaying the
* report data, core modes are things like "table" and "chart". For all core modes
* see Admin::Report::MODES.
*
* @param {String} mode - The identifier of the mode to register
* @param {Class} componentClass - The class of the component to render
*/
registerReportModeComponent(mode, componentClass) {
registerReportModeComponent(mode, componentClass);
}
#deprecatedWidgetOverride(widgetName, override) {
// insert here the code to handle widget deprecations, e.g. for the header widgets we used:
// if (DEPRECATED_HEADER_WIDGETS.includes(widgetName)) {

View File

@ -36,6 +36,7 @@ import { resetUsernameDecorators } from "discourse/helpers/decorate-username-sel
import { resetBeforeAuthCompleteCallbacks } from "discourse/instance-initializers/auth-complete";
import { resetAdminPluginConfigNav } from "discourse/lib/admin-plugin-config-nav";
import { clearPluginHeaderActionComponents } from "discourse/lib/admin-plugin-header-actions";
import { resetAdditionalReportModes } from "discourse/lib/admin-report-additional-modes";
import { rollbackAllPrepends } from "discourse/lib/class-prepend";
import { clearPopupMenuOptions } from "discourse/lib/composer/custom-popup-menu-options";
import deprecated from "discourse/lib/deprecated";
@ -201,6 +202,7 @@ export function testCleanup(container, app) {
User.resetCurrent();
resetMobile();
resetAdditionalReportModes();
resetExtraClasses();
clearOutletCache();
clearHTMLCache();

View File

@ -8,7 +8,9 @@ module("Integration | Component | admin-report", function (hooks) {
setupRenderingTest(hooks);
test("default", async function (assert) {
await render(hbs`<AdminReport @dataSourceName="signups" />`);
await render(
hbs`<AdminReport @dataSourceName="signups" @showDescriptionInTooltip={{false}} />`
);
assert.dom(".admin-report.signups").exists();
assert.dom(".admin-report-table").exists("defaults to table mode");

View File

@ -80,7 +80,6 @@
.body {
display: flex;
flex-direction: row;
margin-top: var(--space-3);
}
.main {

View File

@ -137,6 +137,19 @@
margin-bottom: 1em;
}
.admin-report.description-in-tooltip .header {
.d-page-subheader {
.d-page-subheader__title-row {
margin-bottom: 0;
}
}
.fk-d-tooltip__trigger {
margin-left: 0.5em;
max-width: 20px;
}
}
.charts {
display: grid;
grid-template-columns: repeat(12, 1fr);
@ -153,7 +166,7 @@
.header {
display: grid;
grid-template-areas: "title trend" "description description";
grid-template-areas: "title tooltip trend" "description description description";
grid-template-columns: auto 1fr;
.d-page-subheader {

View File

@ -227,7 +227,7 @@ module Jobs
end
end
if report.modes == [:stacked_chart]
if report.modes == [Report::MODES[:stacked_chart]]
header = [:x]
data = {}

View File

@ -7,7 +7,7 @@ module Reports::ConsolidatedApiRequests
def report_consolidated_api_requests(report)
filters = %w[api user_api]
report.modes = [:stacked_chart]
report.modes = [Report::MODES[:stacked_chart]]
requests =
filters.map do |filter|

View File

@ -11,7 +11,7 @@ module Reports::ConsolidatedPageViews
def report_consolidated_page_views(report)
filters = %w[page_view_logged_in page_view_anon page_view_crawler]
report.modes = [:stacked_chart]
report.modes = [Report::MODES[:stacked_chart]]
requests =
filters.map do |filter|

View File

@ -5,7 +5,7 @@ module Reports::FlagsStatus
class_methods do
def report_flags_status(report)
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.labels = [
{

View File

@ -47,7 +47,7 @@ module Reports::ModeratorsActivity
},
]
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.data = []
query = <<~SQL

View File

@ -8,7 +8,7 @@ module Reports::PostEdits
category_id, include_subcategories = report.add_category_filter
editor_username = report.filters["editor"]
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.labels = [
{

View File

@ -5,8 +5,6 @@ module Reports::Posts
class_methods do
def report_posts(report)
report.modes = %i[table chart]
category_id, include_subcategories = report.add_category_filter
basic_report_about report,

View File

@ -5,7 +5,7 @@ module Reports::SiteTraffic
class_methods do
def report_site_traffic(report)
report.modes = [:stacked_chart]
report.modes = [Report::MODES[:stacked_chart]]
first_browser_pageview_date =
DB.query_single(

View File

@ -5,7 +5,7 @@ module Reports::StaffLogins
class_methods do
def report_staff_logins(report)
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.data = []

View File

@ -5,7 +5,7 @@ module Reports::SuspiciousLogins
class_methods do
def report_suspicious_logins(report)
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.labels = [
{

View File

@ -5,7 +5,7 @@ module Reports::TopIgnoredUsers
class_methods do
def report_top_ignored_users(report)
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.labels = [
{

View File

@ -7,7 +7,7 @@ module Reports::TopReferredTopics
def report_top_referred_topics(report)
category_id, include_subcategories = report.add_category_filter
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.labels = [
{

View File

@ -5,7 +5,7 @@ module Reports::TopReferrers
class_methods do
def report_top_referrers(report)
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.labels = [
{

View File

@ -7,7 +7,7 @@ module Reports::TopTrafficSources
def report_top_traffic_sources(report)
category_id, include_subcategories = report.add_category_filter
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.labels = [
{ property: :domain, title: I18n.t("reports.top_traffic_sources.labels.domain") },

View File

@ -5,7 +5,7 @@ module Reports::TopUploads
class_methods do
def report_top_uploads(report)
report.modes = [:table]
report.modes = [Report::MODES[:table]]
extension_filter = report.filters.dig(:file_extension)
report.add_filter(

View File

@ -8,7 +8,7 @@ module Reports::TopUsersByLikesReceived
report.icon = "heart"
report.data = []
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.dates_filtering = true

View File

@ -8,7 +8,7 @@ module Reports::TopUsersByLikesReceivedFromAVarietyOfPeople
report.icon = "heart"
report.data = []
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.dates_filtering = true

View File

@ -8,7 +8,7 @@ module Reports::TopUsersByLikesReceivedFromInferiorTrustLevel
report.icon = "heart"
report.data = []
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.dates_filtering = true

View File

@ -5,7 +5,7 @@ module Reports::TopicViewStats
class_methods do
def report_topic_view_stats(report)
report.modes = [:table]
report.modes = [Report::MODES[:table]]
category_id, include_subcategories = report.add_category_filter

View File

@ -21,7 +21,7 @@ module Reports::TrendingSearch
report.data = []
report.modes = [:table]
report.modes = [Report::MODES[:table]]
trends =
SearchLog.trending_from(report.start_date, end_date: report.end_date, limit: report.limit)

View File

@ -5,7 +5,7 @@ module Reports::TrustLevelGrowth
class_methods do
def report_trust_level_growth(report)
report.modes = [:stacked_chart]
report.modes = [Report::MODES[:stacked_chart]]
filters = %w[tl1_reached tl2_reached tl3_reached tl4_reached]

View File

@ -7,7 +7,7 @@ module Reports::UserFlaggingRatio
def report_user_flagging_ratio(report)
report.data = []
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.dates_filtering = true

View File

@ -7,7 +7,7 @@ module Reports::UsersByTrustLevel
def report_users_by_trust_level(report)
report.data = []
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.dates_filtering = false

View File

@ -7,7 +7,7 @@ module Reports::UsersByType
def report_users_by_type(report)
report.data = []
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.dates_filtering = false

View File

@ -18,7 +18,7 @@ module Reports::WebCrawlers
},
]
report.modes = [:table]
report.modes = [Report::MODES[:table]]
report.data =
WebCrawlerRequest

View File

@ -16,6 +16,17 @@ class Report
include_subcategories
]
MODES = {
table: :table,
chart: :chart,
stacked_chart: :stacked_chart,
stacked_line_chart: :stacked_line_chart,
radar: :radar,
counters: :counters,
inline_table: :inline_table,
storage_stats: :storage_stats,
}
include Reports::Bookmarks
include Reports::ConsolidatedApiRequests
include Reports::ConsolidatedPageViews
@ -106,7 +117,7 @@ class Report
@average = false
@percent = false
@higher_is_better = true
@modes = %i[table chart]
@modes = [MODES[:chart], MODES[:table]]
@prev_data = nil
@dates_filtering = true
@available_filters = {}
@ -374,7 +385,7 @@ class Report
end
def self.add_prev_data(report, subject_class, report_method, *args)
if report.modes.include?(:chart) && report.facets.include?(:prev_period)
if report.modes.include?(Report::MODES[:chart]) && report.facets.include?(:prev_period)
prev_data = subject_class.public_send(report_method, *args)
report.prev_data = prev_data.map { |k, v| { x: k, y: v } }
end

View File

@ -7,6 +7,10 @@ in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.0.1] - 2025-01-29
- Added `registerReportModeComponent`. This allows plugins to register different report display modes in addition to the built-in core ones like `chart`, `table`, and so on defined in `Report::MODES`.
## [2.0.0] - 2025-01-07
- Removed `decorateTopicTitle`. This has been deprecated for more than a year, and we are not aware of any remaining uses in the ecosystem.

View File

@ -167,6 +167,8 @@ task "javascript:update_constants" => :environment do
export const MAX_UNOPTIMIZED_CATEGORIES = #{CategoryList::MAX_UNOPTIMIZED_CATEGORIES};
export const USER_FIELD_FLAGS = #{UserField::FLAG_ATTRIBUTES};
export const REPORT_MODES = #{Report::MODES.to_json};
JS
pretty_notifications = Notification.types.map { |n| " #{n[0]}: #{n[1]}," }.join("\n")