mirror of
https://github.com/discourse/discourse.git
synced 2024-12-08 02:13:39 +08:00
3c29a84d98
Reports can have the radar type, which will get rendered by the `admin-report-radar` component.
708 lines
19 KiB
JavaScript
708 lines
19 KiB
JavaScript
import EmberObject from "@ember/object";
|
||
import { isEmpty } from "@ember/utils";
|
||
import { renderAvatar } from "discourse/helpers/user-avatar";
|
||
import { ajax } from "discourse/lib/ajax";
|
||
import { durationTiny, number } from "discourse/lib/formatter";
|
||
import round from "discourse/lib/round";
|
||
import {
|
||
escapeExpression,
|
||
fillMissingDates,
|
||
formatUsername,
|
||
toNumber,
|
||
} from "discourse/lib/utilities";
|
||
import getURL from "discourse-common/lib/get-url";
|
||
import { makeArray } from "discourse-common/lib/helpers";
|
||
import discourseComputed from "discourse-common/utils/decorators";
|
||
import I18n from "discourse-i18n";
|
||
|
||
// Change this line each time report format change
|
||
// and you want to ensure cache is reset
|
||
export const SCHEMA_VERSION = 4;
|
||
|
||
export default class Report extends EmberObject {
|
||
static groupingForDatapoints(count) {
|
||
if (count < DAILY_LIMIT_DAYS) {
|
||
return "daily";
|
||
}
|
||
|
||
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
|
||
return "weekly";
|
||
}
|
||
|
||
if (count >= WEEKLY_LIMIT_DAYS) {
|
||
return "monthly";
|
||
}
|
||
}
|
||
|
||
static unitForDatapoints(count) {
|
||
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
|
||
return "week";
|
||
} else if (count >= WEEKLY_LIMIT_DAYS) {
|
||
return "month";
|
||
} else {
|
||
return "day";
|
||
}
|
||
}
|
||
|
||
static unitForGrouping(grouping) {
|
||
switch (grouping) {
|
||
case "monthly":
|
||
return "month";
|
||
case "weekly":
|
||
return "week";
|
||
default:
|
||
return "day";
|
||
}
|
||
}
|
||
|
||
static collapse(model, data, grouping) {
|
||
grouping = grouping || Report.groupingForDatapoints(data.length);
|
||
|
||
if (grouping === "daily") {
|
||
return data;
|
||
} else if (grouping === "weekly" || grouping === "monthly") {
|
||
const isoKind = grouping === "weekly" ? "isoWeek" : "month";
|
||
const kind = grouping === "weekly" ? "week" : "month";
|
||
const startMoment = moment(model.start_date, "YYYY-MM-DD");
|
||
|
||
let currentIndex = 0;
|
||
let currentStart = startMoment.clone().startOf(isoKind);
|
||
let currentEnd = startMoment.clone().endOf(isoKind);
|
||
const transformedData = [
|
||
{
|
||
x: currentStart.format("YYYY-MM-DD"),
|
||
y: 0,
|
||
},
|
||
];
|
||
|
||
let appliedAverage = false;
|
||
data.forEach((d) => {
|
||
const date = moment(d.x, "YYYY-MM-DD");
|
||
|
||
if (
|
||
!date.isSame(currentStart) &&
|
||
!date.isBetween(currentStart, currentEnd)
|
||
) {
|
||
if (model.average) {
|
||
transformedData[currentIndex].y = applyAverage(
|
||
transformedData[currentIndex].y,
|
||
currentStart,
|
||
currentEnd
|
||
);
|
||
|
||
appliedAverage = true;
|
||
}
|
||
|
||
currentIndex += 1;
|
||
currentStart = currentStart.add(1, kind).startOf(isoKind);
|
||
currentEnd = currentEnd.add(1, kind).endOf(isoKind);
|
||
} else {
|
||
appliedAverage = false;
|
||
}
|
||
|
||
if (transformedData[currentIndex]) {
|
||
transformedData[currentIndex].y += d.y;
|
||
} else {
|
||
transformedData[currentIndex] = {
|
||
x: d.x,
|
||
y: d.y,
|
||
};
|
||
}
|
||
});
|
||
|
||
if (model.average && !appliedAverage) {
|
||
transformedData[currentIndex].y = applyAverage(
|
||
transformedData[currentIndex].y,
|
||
currentStart,
|
||
moment(model.end_date).subtract(1, "day") // remove 1 day as model end date is at 00:00 of next day
|
||
);
|
||
}
|
||
|
||
return transformedData;
|
||
}
|
||
|
||
// ensure we return something if grouping is unknown
|
||
return data;
|
||
}
|
||
|
||
static 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 (Array.isArray(report[dataField])) {
|
||
const startDateFormatted = moment
|
||
.utc(report[startDate])
|
||
.locale("en")
|
||
.format("YYYY-MM-DD");
|
||
const endDateFormatted = moment
|
||
.utc(report[endDate])
|
||
.locale("en")
|
||
.format("YYYY-MM-DD");
|
||
|
||
if (report.modes[0] === "stacked_chart") {
|
||
report[filledField] = report[dataField].map((rep) => {
|
||
return {
|
||
req: rep.req,
|
||
label: rep.label,
|
||
color: rep.color,
|
||
data: fillMissingDates(
|
||
JSON.parse(JSON.stringify(rep.data)),
|
||
startDateFormatted,
|
||
endDateFormatted
|
||
),
|
||
};
|
||
});
|
||
} else if (report.modes[0] !== "radar") {
|
||
report[filledField] = fillMissingDates(
|
||
JSON.parse(JSON.stringify(report[dataField])),
|
||
startDateFormatted,
|
||
endDateFormatted
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
static find(type, startDate, endDate, categoryId, groupId) {
|
||
return ajax("/admin/reports/" + type, {
|
||
data: {
|
||
start_date: startDate,
|
||
end_date: endDate,
|
||
category_id: categoryId,
|
||
group_id: groupId,
|
||
},
|
||
}).then((json) => {
|
||
// don’t fill for large multi column tables
|
||
// which are not date based
|
||
const modes = json.report.modes;
|
||
if (modes.length !== 1 && modes[0] !== "table") {
|
||
Report.fillMissingDates(json.report);
|
||
}
|
||
|
||
const model = Report.create({ type });
|
||
model.setProperties(json.report);
|
||
|
||
if (json.report.related_report) {
|
||
// TODO: fillMissingDates if xaxis is date
|
||
const related = Report.create({
|
||
type: json.report.related_report.type,
|
||
});
|
||
related.setProperties(json.report.related_report);
|
||
model.set("relatedReport", related);
|
||
}
|
||
|
||
return model;
|
||
});
|
||
}
|
||
|
||
average = false;
|
||
percent = false;
|
||
higher_is_better = true;
|
||
description_link = null;
|
||
description = null;
|
||
|
||
@discourseComputed("type", "start_date", "end_date")
|
||
reportUrl(type, start_date, end_date) {
|
||
start_date = moment.utc(start_date).locale("en").format("YYYY-MM-DD");
|
||
|
||
end_date = moment.utc(end_date).locale("en").format("YYYY-MM-DD");
|
||
|
||
return getURL(
|
||
`/admin/reports/${type}?start_date=${start_date}&end_date=${end_date}`
|
||
);
|
||
}
|
||
|
||
valueAt(numDaysAgo) {
|
||
if (this.data) {
|
||
const wantedDate = moment()
|
||
.subtract(numDaysAgo, "days")
|
||
.locale("en")
|
||
.format("YYYY-MM-DD");
|
||
const item = this.data.find((d) => d.x === wantedDate);
|
||
if (item) {
|
||
return item.y;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
valueFor(startDaysAgo, endDaysAgo) {
|
||
if (this.data) {
|
||
const earliestDate = moment().subtract(endDaysAgo, "days").startOf("day");
|
||
const latestDate = moment().subtract(startDaysAgo, "days").startOf("day");
|
||
let d,
|
||
sum = 0,
|
||
count = 0;
|
||
this.data.forEach((datum) => {
|
||
d = moment(datum.x);
|
||
if (d >= earliestDate && d <= latestDate) {
|
||
sum += datum.y;
|
||
count++;
|
||
}
|
||
});
|
||
if (this.method === "average" && count > 0) {
|
||
sum /= count;
|
||
}
|
||
return round(sum, -2);
|
||
}
|
||
}
|
||
|
||
@discourseComputed("data", "average")
|
||
todayCount() {
|
||
return this.valueAt(0);
|
||
}
|
||
|
||
@discourseComputed("data", "average")
|
||
yesterdayCount() {
|
||
return this.valueAt(1);
|
||
}
|
||
|
||
@discourseComputed("data", "average")
|
||
sevenDaysAgoCount() {
|
||
return this.valueAt(7);
|
||
}
|
||
|
||
@discourseComputed("data", "average")
|
||
thirtyDaysAgoCount() {
|
||
return this.valueAt(30);
|
||
}
|
||
|
||
@discourseComputed("data", "average")
|
||
lastSevenDaysCount() {
|
||
return this.averageCount(7, this.valueFor(1, 7));
|
||
}
|
||
|
||
@discourseComputed("data", "average")
|
||
lastThirtyDaysCount() {
|
||
return this.averageCount(30, this.valueFor(1, 30));
|
||
}
|
||
|
||
averageCount(count, value) {
|
||
return this.average ? value / count : value;
|
||
}
|
||
|
||
@discourseComputed("yesterdayCount", "higher_is_better")
|
||
yesterdayTrend(yesterdayCount, higherIsBetter) {
|
||
return this._computeTrend(this.valueAt(2), yesterdayCount, higherIsBetter);
|
||
}
|
||
|
||
@discourseComputed("lastSevenDaysCount", "higher_is_better")
|
||
sevenDaysTrend(lastSevenDaysCount, higherIsBetter) {
|
||
return this._computeTrend(
|
||
this.valueFor(8, 14),
|
||
lastSevenDaysCount,
|
||
higherIsBetter
|
||
);
|
||
}
|
||
|
||
@discourseComputed("data")
|
||
currentTotal(data) {
|
||
return data.reduce((cur, pair) => cur + pair.y, 0);
|
||
}
|
||
|
||
@discourseComputed("data", "currentTotal")
|
||
currentAverage(data, total) {
|
||
return makeArray(data).length === 0
|
||
? 0
|
||
: parseFloat((total / parseFloat(data.length)).toFixed(1));
|
||
}
|
||
|
||
@discourseComputed("trend", "higher_is_better")
|
||
trendIcon(trend, higherIsBetter) {
|
||
return this._iconForTrend(trend, higherIsBetter);
|
||
}
|
||
|
||
@discourseComputed("sevenDaysTrend", "higher_is_better")
|
||
sevenDaysTrendIcon(sevenDaysTrend, higherIsBetter) {
|
||
return this._iconForTrend(sevenDaysTrend, higherIsBetter);
|
||
}
|
||
|
||
@discourseComputed("thirtyDaysTrend", "higher_is_better")
|
||
thirtyDaysTrendIcon(thirtyDaysTrend, higherIsBetter) {
|
||
return this._iconForTrend(thirtyDaysTrend, higherIsBetter);
|
||
}
|
||
|
||
@discourseComputed("yesterdayTrend", "higher_is_better")
|
||
yesterdayTrendIcon(yesterdayTrend, higherIsBetter) {
|
||
return this._iconForTrend(yesterdayTrend, higherIsBetter);
|
||
}
|
||
|
||
@discourseComputed(
|
||
"prev_period",
|
||
"currentTotal",
|
||
"currentAverage",
|
||
"higher_is_better"
|
||
)
|
||
trend(prev, currentTotal, currentAverage, higherIsBetter) {
|
||
const total = this.average ? currentAverage : currentTotal;
|
||
return this._computeTrend(prev, total, higherIsBetter);
|
||
}
|
||
|
||
@discourseComputed(
|
||
"prev30Days",
|
||
"prev_period",
|
||
"lastThirtyDaysCount",
|
||
"higher_is_better"
|
||
)
|
||
thirtyDaysTrend(
|
||
prev30Days,
|
||
prev_period,
|
||
lastThirtyDaysCount,
|
||
higherIsBetter
|
||
) {
|
||
return this._computeTrend(
|
||
prev30Days ?? prev_period,
|
||
lastThirtyDaysCount,
|
||
higherIsBetter
|
||
);
|
||
}
|
||
|
||
@discourseComputed("type")
|
||
method(type) {
|
||
if (type === "time_to_first_response") {
|
||
return "average";
|
||
} else {
|
||
return "sum";
|
||
}
|
||
}
|
||
|
||
percentChangeString(val1, val2) {
|
||
const change = this._computeChange(val1, val2);
|
||
|
||
if (isNaN(change) || !isFinite(change)) {
|
||
return null;
|
||
} else if (change > 0) {
|
||
return "+" + change.toFixed(0) + "%";
|
||
} else {
|
||
return change.toFixed(0) + "%";
|
||
}
|
||
}
|
||
|
||
@discourseComputed("prev_period", "currentTotal", "currentAverage")
|
||
trendTitle(prev, currentTotal, currentAverage) {
|
||
let current = this.average ? currentAverage : currentTotal;
|
||
let percent = this.percentChangeString(prev, current);
|
||
|
||
if (this.average) {
|
||
prev = prev ? prev.toFixed(1) : "0";
|
||
if (this.percent) {
|
||
current += "%";
|
||
prev += "%";
|
||
}
|
||
} else {
|
||
prev = number(prev);
|
||
current = number(current);
|
||
}
|
||
|
||
return I18n.t("admin.dashboard.reports.trend_title", {
|
||
percent,
|
||
prev,
|
||
current,
|
||
});
|
||
}
|
||
|
||
changeTitle(valAtT1, valAtT2, prevPeriodString) {
|
||
const change = this.percentChangeString(valAtT1, valAtT2);
|
||
let title = "";
|
||
if (change) {
|
||
title += `${change} change. `;
|
||
}
|
||
title += `Was ${number(valAtT1)} ${prevPeriodString}.`;
|
||
return title;
|
||
}
|
||
|
||
@discourseComputed("yesterdayCount")
|
||
yesterdayCountTitle(yesterdayCount) {
|
||
return this.changeTitle(this.valueAt(2), yesterdayCount, "two days ago");
|
||
}
|
||
|
||
@discourseComputed("lastSevenDaysCount")
|
||
sevenDaysCountTitle(lastSevenDaysCount) {
|
||
return this.changeTitle(
|
||
this.valueFor(8, 14),
|
||
lastSevenDaysCount,
|
||
"two weeks ago"
|
||
);
|
||
}
|
||
|
||
@discourseComputed("prev30Days", "prev_period")
|
||
canDisplayTrendIcon(prev30Days, prev_period) {
|
||
return prev30Days ?? prev_period;
|
||
}
|
||
|
||
@discourseComputed("prev30Days", "prev_period", "lastThirtyDaysCount")
|
||
thirtyDaysCountTitle(prev30Days, prev_period, lastThirtyDaysCount) {
|
||
return this.changeTitle(
|
||
prev30Days ?? prev_period,
|
||
lastThirtyDaysCount,
|
||
"in the previous 30 day period"
|
||
);
|
||
}
|
||
|
||
@discourseComputed("data")
|
||
sortedData(data) {
|
||
return this.xAxisIsDate ? data.toArray().reverse() : data.toArray();
|
||
}
|
||
|
||
@discourseComputed("data")
|
||
xAxisIsDate() {
|
||
if (!this.data[0]) {
|
||
return false;
|
||
}
|
||
return this.data && this.data[0].x.match(/\d{4}-\d{1,2}-\d{1,2}/);
|
||
}
|
||
|
||
@discourseComputed("labels")
|
||
computedLabels(labels) {
|
||
return labels.map((label) => {
|
||
const type = label.type || "string";
|
||
|
||
let mainProperty;
|
||
if (label.property) {
|
||
mainProperty = label.property;
|
||
} else if (type === "user") {
|
||
mainProperty = label.properties["username"];
|
||
} else if (type === "topic") {
|
||
mainProperty = label.properties["title"];
|
||
} else if (type === "post") {
|
||
mainProperty = label.properties["truncated_raw"];
|
||
} else {
|
||
mainProperty = label.properties[0];
|
||
}
|
||
|
||
return {
|
||
title: label.title,
|
||
htmlTitle: label.html_title,
|
||
sortProperty: label.sort_property || mainProperty,
|
||
mainProperty,
|
||
type,
|
||
compute: (row, opts = {}) => {
|
||
let value = null;
|
||
|
||
if (opts.useSortProperty) {
|
||
value = row[label.sort_property || mainProperty];
|
||
} else {
|
||
value = row[mainProperty];
|
||
}
|
||
|
||
if (type === "user") {
|
||
return this._userLabel(label.properties, row);
|
||
}
|
||
if (type === "post") {
|
||
return this._postLabel(label.properties, row);
|
||
}
|
||
if (type === "topic") {
|
||
return this._topicLabel(label.properties, row);
|
||
}
|
||
if (type === "seconds") {
|
||
return this._secondsLabel(value);
|
||
}
|
||
if (type === "link") {
|
||
return this._linkLabel(label.properties, row);
|
||
}
|
||
if (type === "percent") {
|
||
return this._percentLabel(value);
|
||
}
|
||
if (type === "bytes") {
|
||
return this._bytesLabel(value);
|
||
}
|
||
if (type === "number") {
|
||
return this._numberLabel(value, opts);
|
||
}
|
||
if (type === "date") {
|
||
const date = moment(value);
|
||
if (date.isValid()) {
|
||
return this._dateLabel(value, date);
|
||
}
|
||
}
|
||
if (type === "precise_date") {
|
||
const date = moment(value);
|
||
if (date.isValid()) {
|
||
return this._dateLabel(value, date, "LLL");
|
||
}
|
||
}
|
||
if (type === "text") {
|
||
return this._textLabel(value);
|
||
}
|
||
|
||
return {
|
||
value,
|
||
type,
|
||
property: mainProperty,
|
||
formattedValue: value ? escapeExpression(value) : "—",
|
||
};
|
||
},
|
||
};
|
||
});
|
||
}
|
||
|
||
_userLabel(properties, row) {
|
||
const username = row[properties.username];
|
||
|
||
const formattedValue = () => {
|
||
const userId = row[properties.id];
|
||
|
||
const user = EmberObject.create({
|
||
username,
|
||
name: formatUsername(username),
|
||
avatar_template: row[properties.avatar],
|
||
});
|
||
|
||
const href = getURL(`/admin/users/${userId}/${username}`);
|
||
|
||
const avatarImg = renderAvatar(user, {
|
||
imageSize: "tiny",
|
||
ignoreTitle: true,
|
||
siteSettings: this.siteSettings,
|
||
});
|
||
|
||
return `<a href='${href}'>${avatarImg}<span class='username'>${user.name}</span></a>`;
|
||
};
|
||
|
||
return {
|
||
value: username,
|
||
formattedValue: username ? formattedValue() : "—",
|
||
};
|
||
}
|
||
|
||
_topicLabel(properties, row) {
|
||
const topicTitle = row[properties.title];
|
||
|
||
const formattedValue = () => {
|
||
const topicId = row[properties.id];
|
||
const href = getURL(`/t/-/${topicId}`);
|
||
return `<a href='${href}'>${escapeExpression(topicTitle)}</a>`;
|
||
};
|
||
|
||
return {
|
||
value: topicTitle,
|
||
formattedValue: topicTitle ? formattedValue() : "—",
|
||
};
|
||
}
|
||
|
||
_postLabel(properties, row) {
|
||
const postTitle = row[properties.truncated_raw];
|
||
const postNumber = row[properties.number];
|
||
const topicId = row[properties.topic_id];
|
||
const href = getURL(`/t/-/${topicId}/${postNumber}`);
|
||
|
||
return {
|
||
property: properties.title,
|
||
value: postTitle,
|
||
formattedValue:
|
||
postTitle && href
|
||
? `<a href='${href}'>${escapeExpression(postTitle)}</a>`
|
||
: "—",
|
||
};
|
||
}
|
||
|
||
_secondsLabel(value) {
|
||
return {
|
||
value: toNumber(value),
|
||
formattedValue: durationTiny(value),
|
||
};
|
||
}
|
||
|
||
_percentLabel(value) {
|
||
return {
|
||
value: toNumber(value),
|
||
formattedValue: value ? `${value}%` : "—",
|
||
};
|
||
}
|
||
|
||
_numberLabel(value, options = {}) {
|
||
const formatNumbers = isEmpty(options.formatNumbers)
|
||
? true
|
||
: options.formatNumbers;
|
||
|
||
const formattedValue = () => (formatNumbers ? number(value) : value);
|
||
|
||
return {
|
||
value: toNumber(value),
|
||
formattedValue: value ? formattedValue() : "—",
|
||
};
|
||
}
|
||
|
||
_bytesLabel(value) {
|
||
return {
|
||
value: toNumber(value),
|
||
formattedValue: I18n.toHumanSize(value),
|
||
};
|
||
}
|
||
|
||
_dateLabel(value, date, format = "LL") {
|
||
return {
|
||
value,
|
||
formattedValue: value ? date.format(format) : "—",
|
||
};
|
||
}
|
||
|
||
_textLabel(value) {
|
||
const escaped = escapeExpression(value);
|
||
|
||
return {
|
||
value,
|
||
formattedValue: value ? escaped : "—",
|
||
};
|
||
}
|
||
|
||
_linkLabel(properties, row) {
|
||
const property = properties[0];
|
||
const value = getURL(row[property]);
|
||
const formattedValue = (href, anchor) => {
|
||
return `<a href="${escapeExpression(href)}">${escapeExpression(
|
||
anchor
|
||
)}</a>`;
|
||
};
|
||
|
||
return {
|
||
value,
|
||
formattedValue: value ? formattedValue(value, row[properties[1]]) : "—",
|
||
};
|
||
}
|
||
|
||
_computeChange(valAtT1, valAtT2) {
|
||
return ((valAtT2 - valAtT1) / valAtT1) * 100;
|
||
}
|
||
|
||
_computeTrend(valAtT1, valAtT2, higherIsBetter) {
|
||
const change = this._computeChange(valAtT1, valAtT2);
|
||
|
||
if (change > 50) {
|
||
return higherIsBetter ? "high-trending-up" : "high-trending-down";
|
||
} else if (change > 2) {
|
||
return higherIsBetter ? "trending-up" : "trending-down";
|
||
} else if (change <= 2 && change >= -2) {
|
||
return "no-change";
|
||
} else if (change < -50) {
|
||
return higherIsBetter ? "high-trending-down" : "high-trending-up";
|
||
} else if (change < -2) {
|
||
return higherIsBetter ? "trending-down" : "trending-up";
|
||
}
|
||
}
|
||
|
||
_iconForTrend(trend, higherIsBetter) {
|
||
switch (trend) {
|
||
case "trending-up":
|
||
return higherIsBetter ? "angle-up" : "angle-down";
|
||
case "trending-down":
|
||
return higherIsBetter ? "angle-down" : "angle-up";
|
||
case "high-trending-up":
|
||
return higherIsBetter ? "angle-double-up" : "angle-double-down";
|
||
case "high-trending-down":
|
||
return higherIsBetter ? "angle-double-down" : "angle-double-up";
|
||
default:
|
||
return "minus";
|
||
}
|
||
}
|
||
}
|
||
|
||
export const WEEKLY_LIMIT_DAYS = 365;
|
||
export const DAILY_LIMIT_DAYS = 34;
|
||
|
||
function applyAverage(value, start, end) {
|
||
const count = end.diff(start, "day") + 1; // 1 to include start
|
||
return parseFloat((value / count).toFixed(2));
|
||
}
|