1
0
mirror of https://github.com/flarum/core.git synced 2025-08-07 08:56:38 +02:00

perf(statistics): rewrite for performance on very large communities (#3531)

Co-authored-by: Sami Mazouz <ilyasmazouz@gmail.com>
This commit is contained in:
David Wheatley
2022-07-18 19:07:38 +01:00
committed by GitHub
parent 6dde236d77
commit af3116bce9
13 changed files with 707 additions and 309 deletions

View File

@@ -8,13 +8,13 @@
*/
use Flarum\Extend;
use Flarum\Statistics\AddStatisticsData;
return [
(new Extend\Frontend('admin'))
->js(__DIR__.'/js/dist/admin.js')
->css(__DIR__.'/less/admin.less')
->content(AddStatisticsData::class),
->css(__DIR__.'/less/admin.less'),
new Extend\Locales(__DIR__.'/locale'),
(new Extend\Routes('api'))
->get('/statistics', 'flarum-statistics.get-statistics', Flarum\Statistics\Api\Controller\ShowStatisticsData::class),
];

View File

@@ -7,15 +7,15 @@
"frappe-charts": "^1.6.2"
},
"devDependencies": {
"@types/mithril": "^2.0.8",
"prettier": "^2.5.1",
"@types/mithril": "^2.0.11",
"prettier": "^2.7.1",
"flarum-webpack-config": "^2.0.0",
"webpack": "^5.65.0",
"webpack-cli": "^4.9.1",
"webpack": "^5.73.0",
"webpack-cli": "^4.10.0",
"@flarum/prettier-config": "^1.0.0",
"flarum-tsconfig": "^1.0.2",
"typescript": "^4.5.4",
"typescript-coverage-report": "^0.6.1"
"typescript": "^4.7.4",
"typescript-coverage-report": "^0.6.4"
},
"scripts": {
"dev": "webpack --mode development --watch",

View File

@@ -0,0 +1,82 @@
import app from 'flarum/admin/app';
import DashboardWidget, { IDashboardWidgetAttrs } from 'flarum/admin/components/DashboardWidget';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import Link from 'flarum/common/components/Link';
import abbreviateNumber from 'flarum/common/utils/abbreviateNumber';
import type Mithril from 'mithril';
export default class MiniStatisticsWidget extends DashboardWidget {
entities = ['users', 'discussions', 'posts'];
lifetimeData: any;
loadingLifetime = true;
oncreate(vnode: Mithril.VnodeDOM<IDashboardWidgetAttrs, this>) {
super.oncreate(vnode);
this.loadLifetimeData();
}
async loadLifetimeData() {
this.loadingLifetime = true;
m.redraw();
const data = await app.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/statistics',
params: {
period: 'lifetime',
},
});
this.lifetimeData = data;
this.loadingLifetime = false;
m.redraw();
}
className() {
return 'StatisticsWidget StatisticsWidget--mini';
}
content() {
return (
<div className="StatisticsWidget-table">
<h4 className="StatisticsWidget-title">{app.translator.trans('flarum-statistics.admin.statistics.mini_heading')}</h4>
<div className="StatisticsWidget-entities">
<div className="StatisticsWidget-labels">
<div className="StatisticsWidget-label">{app.translator.trans('flarum-statistics.admin.statistics.total_label')}</div>
</div>
{this.entities.map((entity) => {
const totalCount = this.loadingLifetime ? app.translator.trans('flarum-statistics.admin.statistics.loading') : this.getTotalCount(entity);
return (
<div className="StatisticsWidget-entity">
<h3 className="StatisticsWidget-heading">{app.translator.trans('flarum-statistics.admin.statistics.' + entity + '_heading')}</h3>
<div className="StatisticsWidget-total" title={totalCount}>
{this.loadingLifetime ? <LoadingIndicator display="inline" /> : abbreviateNumber(totalCount as number)}
</div>
</div>
);
})}
</div>
<div className="StatisticsWidget-viewFull">
<Link href={app.route('extension', { id: 'flarum-statistics' })}>
{app.translator.trans('flarum-statistics.admin.statistics.view_full')}
</Link>
</div>
</div>
);
}
getTotalCount(entity: string): number {
return this.lifetimeData[entity];
}
}

View File

@@ -0,0 +1,15 @@
import ExtensionPage from 'flarum/admin/components/ExtensionPage';
import StatisticsWidget from './StatisticsWidget';
export default class StatisticsPage extends ExtensionPage {
content() {
return (
<div className="StatisticsPage">
<div className="container">
<StatisticsWidget />
</div>
</div>
);
}
}

View File

@@ -1,198 +0,0 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import app from 'flarum/admin/app';
import DashboardWidget from 'flarum/admin/components/DashboardWidget';
import SelectDropdown from 'flarum/common/components/SelectDropdown';
import Button from 'flarum/common/components/Button';
import icon from 'flarum/common/helpers/icon';
import abbreviateNumber from 'flarum/common/utils/abbreviateNumber';
import { Chart } from 'frappe-charts/dist/frappe-charts.esm.js';
export default class StatisticsWidget extends DashboardWidget {
oninit(vnode) {
super.oninit(vnode);
// Create a Date object which represents the start of the day in the
// configured timezone. To do this we convert a UTC time into that timezone,
// reset to the first hour of the day, and then convert back into UTC time.
// We'll be working with seconds rather than milliseconds throughout too.
let today = new Date();
today.setTime(today.getTime() + app.data.statistics.timezoneOffset * 1000);
today.setUTCHours(0, 0, 0, 0);
today.setTime(today.getTime() - app.data.statistics.timezoneOffset * 1000);
today = today / 1000;
this.entities = ['users', 'discussions', 'posts'];
this.periods = {
today: { start: today, end: today + 86400, step: 3600 },
last_7_days: { start: today - 86400 * 7, end: today, step: 86400 },
last_28_days: { start: today - 86400 * 28, end: today, step: 86400 },
last_12_months: { start: today - 86400 * 364, end: today, step: 86400 * 7 },
};
this.selectedEntity = 'users';
this.selectedPeriod = 'last_7_days';
}
className() {
return 'StatisticsWidget';
}
content() {
const thisPeriod = this.periods[this.selectedPeriod];
return (
<div className="StatisticsWidget-table">
<div className="StatisticsWidget-labels">
<div className="StatisticsWidget-label">{app.translator.trans('flarum-statistics.admin.statistics.total_label')}</div>
<div className="StatisticsWidget-label">
<SelectDropdown buttonClassName="Button Button--text" caretIcon="fas fa-caret-down">
{Object.keys(this.periods).map((period) => (
<Button
active={period === this.selectedPeriod}
onclick={this.changePeriod.bind(this, period)}
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
>
{app.translator.trans(`flarum-statistics.admin.statistics.${period}_label`)}
</Button>
))}
</SelectDropdown>
</div>
</div>
{this.entities.map((entity) => {
const totalCount = this.getTotalCount(entity);
const thisPeriodCount = this.getPeriodCount(entity, thisPeriod);
const lastPeriodCount = this.getPeriodCount(entity, this.getLastPeriod(thisPeriod));
const periodChange = lastPeriodCount > 0 && ((thisPeriodCount - lastPeriodCount) / lastPeriodCount) * 100;
return (
<a
className={'StatisticsWidget-entity' + (this.selectedEntity === entity ? ' active' : '')}
onclick={this.changeEntity.bind(this, entity)}
>
<h3 className="StatisticsWidget-heading">{app.translator.trans('flarum-statistics.admin.statistics.' + entity + '_heading')}</h3>
<div className="StatisticsWidget-total" title={totalCount}>
{abbreviateNumber(totalCount)}
</div>
<div className="StatisticsWidget-period" title={thisPeriodCount}>
{abbreviateNumber(thisPeriodCount)}{' '}
{periodChange ? (
<span className={'StatisticsWidget-change StatisticsWidget-change--' + (periodChange > 0 ? 'up' : 'down')}>
{icon('fas fa-arrow-' + (periodChange > 0 ? 'up' : 'down'))}
{Math.abs(periodChange.toFixed(1))}%
</span>
) : (
''
)}
</div>
</a>
);
})}
<div className="StatisticsWidget-chart" oncreate={this.drawChart.bind(this)} onupdate={this.drawChart.bind(this)} />
</div>
);
}
drawChart(vnode) {
if (this.chart && this.entity === this.selectedEntity && this.period === this.selectedPeriod) {
return;
}
const offset = app.data.statistics.timezoneOffset;
const period = this.periods[this.selectedPeriod];
const periodLength = period.end - period.start;
const labels = [];
const thisPeriod = [];
const lastPeriod = [];
for (let i = period.start; i < period.end; i += period.step) {
let label;
if (period.step < 86400) {
label = dayjs.unix(i + offset).format('h A');
} else {
label = dayjs.unix(i + offset).format('D MMM');
if (period.step > 86400) {
label += ' - ' + dayjs.unix(i + offset + period.step - 1).format('D MMM');
}
}
labels.push(label);
thisPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i, end: i + period.step }));
lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength + period.step }));
}
const datasets = [{ values: lastPeriod }, { values: thisPeriod }];
const data = {
labels,
datasets,
};
if (!this.chart) {
this.chart = new Chart(vnode.dom, {
data,
type: 'line',
height: 280,
axisOptions: {
xAxisMode: 'tick',
yAxisMode: 'span',
xIsSeries: true,
},
lineOptions: {
hideDots: 1,
},
colors: ['black', app.forum.attribute('themePrimaryColor')],
});
} else {
this.chart.update(data);
}
this.entity = this.selectedEntity;
this.period = this.selectedPeriod;
}
changeEntity(entity) {
this.selectedEntity = entity;
}
changePeriod(period) {
this.selectedPeriod = period;
}
getTotalCount(entity) {
return app.data.statistics[entity].total;
}
getPeriodCount(entity, period) {
const timed = app.data.statistics[entity].timed;
let count = 0;
for (const time in timed) {
if (time >= period.start && time < period.end) {
count += parseInt(timed[time]);
}
}
return count;
}
getLastPeriod(thisPeriod) {
return {
start: thisPeriod.start - (thisPeriod.end - thisPeriod.start),
end: thisPeriod.start,
};
}
}

View File

@@ -0,0 +1,278 @@
import app from 'flarum/admin/app';
import SelectDropdown from 'flarum/common/components/SelectDropdown';
import Button from 'flarum/common/components/Button';
import abbreviateNumber from 'flarum/common/utils/abbreviateNumber';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import icon from 'flarum/common/helpers/icon';
import DashboardWidget, { IDashboardWidgetAttrs } from 'flarum/admin/components/DashboardWidget';
import type Mithril from 'mithril';
// @ts-expect-error No typings available
import { Chart } from 'frappe-charts';
interface IPeriodDeclaration {
start: number;
end: number;
step: number;
}
export default class StatisticsWidget extends DashboardWidget {
entities = ['users', 'discussions', 'posts'];
periods: undefined | Record<string, IPeriodDeclaration>;
chart: any;
timedData: any;
lifetimeData: any;
loadingLifetime = true;
loadingTimed = true;
selectedEntity = 'users';
selectedPeriod: undefined | string;
chartEntity?: string;
chartPeriod?: string;
oncreate(vnode: Mithril.VnodeDOM<IDashboardWidgetAttrs, this>) {
super.oncreate(vnode);
this.loadLifetimeData();
this.loadTimedData();
}
async loadLifetimeData() {
this.loadingLifetime = true;
m.redraw();
const data = await app.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/statistics',
params: {
period: 'lifetime',
},
});
this.lifetimeData = data;
this.loadingLifetime = false;
m.redraw();
}
async loadTimedData() {
this.loadingTimed = true;
m.redraw();
const data = await app.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/statistics',
});
this.timedData = data;
this.loadingTimed = false;
// Create a Date object which represents the start of the day in the
// configured timezone. To do this we convert a UTC time into that timezone,
// reset to the first hour of the day, and then convert back into UTC time.
// We'll be working with seconds rather than milliseconds throughout too.
let todayDate = new Date();
todayDate.setTime(todayDate.getTime() + this.timedData.timezoneOffset * 1000);
todayDate.setUTCHours(0, 0, 0, 0);
todayDate.setTime(todayDate.getTime() - this.timedData.timezoneOffset * 1000);
const today = todayDate.getTime() / 1000;
this.periods = {
today: { start: today, end: today + 86400, step: 3600 },
last_7_days: { start: today - 86400 * 7, end: today, step: 86400 },
previous_7_days: { start: today - 86400 * 14, end: today - 86400 * 7, step: 86400 },
last_28_days: { start: today - 86400 * 28, end: today, step: 86400 },
previous_28_days: { start: today - 86400 * 28 * 2, end: today - 86400 * 28, step: 86400 },
last_12_months: { start: today - 86400 * 364, end: today, step: 86400 * 7 },
};
this.selectedPeriod = 'last_7_days';
m.redraw();
}
className() {
return 'StatisticsWidget';
}
content() {
const thisPeriod = this.loadingTimed ? null : this.periods![this.selectedPeriod!];
return (
<div className="StatisticsWidget-table">
<div className="StatisticsWidget-entities">
<div className="StatisticsWidget-labels">
<div className="StatisticsWidget-label">{app.translator.trans('flarum-statistics.admin.statistics.total_label')}</div>
<div className="StatisticsWidget-label">
{this.loadingTimed ? (
<LoadingIndicator size="small" display="inline" />
) : (
<SelectDropdown disabled={this.loadingTimed} buttonClassName="Button Button--text" caretIcon="fas fa-caret-down">
{Object.keys(this.periods!).map((period) => (
<Button
key={period}
active={period === this.selectedPeriod}
onclick={this.changePeriod.bind(this, period)}
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
>
{app.translator.trans(`flarum-statistics.admin.statistics.${period}_label`)}
</Button>
))}
</SelectDropdown>
)}
</div>
</div>
{this.entities.map((entity) => {
const totalCount = this.loadingLifetime ? app.translator.trans('flarum-statistics.admin.statistics.loading') : this.getTotalCount(entity);
const thisPeriodCount = this.loadingTimed
? app.translator.trans('flarum-statistics.admin.statistics.loading')
: this.getPeriodCount(entity, thisPeriod!);
const lastPeriodCount = this.loadingTimed
? app.translator.trans('flarum-statistics.admin.statistics.loading')
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
const periodChange =
this.loadingTimed || lastPeriodCount === 0
? 0
: (((thisPeriodCount as number) - (lastPeriodCount as number)) / (lastPeriodCount as number)) * 100;
return (
<button
className={'Button--ua-reset StatisticsWidget-entity' + (this.selectedEntity === entity ? ' active' : '')}
onclick={this.changeEntity.bind(this, entity)}
>
<h3 className="StatisticsWidget-heading">{app.translator.trans('flarum-statistics.admin.statistics.' + entity + '_heading')}</h3>
<div className="StatisticsWidget-total" title={totalCount}>
{this.loadingLifetime ? <LoadingIndicator display="inline" /> : abbreviateNumber(totalCount as number)}
</div>
<div className="StatisticsWidget-period" title={thisPeriodCount}>
{this.loadingTimed ? <LoadingIndicator display="inline" /> : abbreviateNumber(thisPeriodCount as number)}
{periodChange !== 0 && (
<>
{' '}
<span className={'StatisticsWidget-change StatisticsWidget-change--' + (periodChange > 0 ? 'up' : 'down')}>
{icon('fas fa-arrow-' + (periodChange > 0 ? 'up' : 'down'))}
{Math.abs(periodChange).toFixed(1)}%
</span>
</>
)}
</div>
</button>
);
})}
</div>
{this.loadingTimed ? (
<div className="StatisticsWidget-chart">
<LoadingIndicator size="large" />
</div>
) : (
<div className="StatisticsWidget-chart" oncreate={this.drawChart.bind(this)} onupdate={this.drawChart.bind(this)} />
)}
</div>
);
}
drawChart(vnode: Mithril.VnodeDOM<any, any>) {
if (this.chart && this.chartEntity === this.selectedEntity && this.chartPeriod === this.selectedPeriod) {
return;
}
const offset = this.timedData.timezoneOffset;
const period = this.periods![this.selectedPeriod!];
const periodLength = period.end - period.start;
const labels = [];
const thisPeriod = [];
const lastPeriod = [];
for (let i = period.start; i < period.end; i += period.step) {
let label;
if (period.step < 86400) {
label = dayjs.unix(i + offset).format('h A');
} else {
label = dayjs.unix(i + offset).format('D MMM');
if (period.step > 86400) {
label += ' - ' + dayjs.unix(i + offset + period.step - 1).format('D MMM');
}
}
labels.push(label);
thisPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i, end: i + period.step }));
lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength + period.step }));
}
const datasets = [{ values: lastPeriod }, { values: thisPeriod }];
const data = {
labels,
datasets,
};
if (!this.chart) {
this.chart = new Chart(vnode.dom, {
data,
type: 'line',
height: 280,
axisOptions: {
xAxisMode: 'tick',
yAxisMode: 'span',
xIsSeries: true,
},
lineOptions: {
hideDots: 1,
},
colors: ['black', app.forum.attribute('themePrimaryColor')],
});
} else {
this.chart.update(data);
}
this.chartEntity = this.selectedEntity;
this.chartPeriod = this.selectedPeriod;
}
changeEntity(entity: string) {
this.selectedEntity = entity;
}
changePeriod(period: string) {
this.selectedPeriod = period;
}
getTotalCount(entity: string): number {
return this.lifetimeData[entity];
}
getPeriodCount(entity: string, period: { start: number; end: number }) {
const timed: Record<string, number> = this.timedData[entity];
let count = 0;
for (const t in timed) {
const time = parseInt(t);
if (time >= period.start && time < period.end) {
count += timed[time];
}
}
return count;
}
getLastPeriod(thisPeriod: { start: number; end: number }) {
return {
start: thisPeriod.start - (thisPeriod.end - thisPeriod.start),
end: thisPeriod.start,
};
}
}

View File

@@ -3,12 +3,13 @@ import { extend } from 'flarum/common/extend';
import DashboardPage from 'flarum/admin/components/DashboardPage';
import StatisticsWidget from './components/StatisticsWidget';
import ItemList from 'flarum/common/utils/ItemList';
import type Mithril from 'mithril';
import MiniStatisticsWidget from './components/MiniStatisticsWidget';
import StatisticsPage from './components/StatisticsPage';
app.initializers.add('flarum-statistics', () => {
extend(DashboardPage.prototype, 'availableWidgets', function (widgets: ItemList<Mithril.Children>) {
widgets.add('statistics', <StatisticsWidget />, 20);
extend(DashboardPage.prototype, 'availableWidgets', function (widgets) {
widgets.add('statistics', <MiniStatisticsWidget />, 20);
});
app.extensionData.for('flarum-statistics').registerPage(StatisticsPage);
});

View File

@@ -1,78 +1,113 @@
.StatisticsWidget-table {
margin-top: -20px;
.StatisticsPage {
margin-top: 38px;
}
.StatisticsWidget-labels {
float: left;
min-width: 130px;
padding-right: 10px;
padding-top: 45px;
font-size: 12px;
font-weight: bold;
color: @muted-color;
}
.StatisticsWidget-label {
padding-top: 8px;
}
.StatisticsWidget-entity {
float: left;
min-width: 130px;
padding: 15px 20px;
color: @text-color;
font-size: 20px;
&:hover {
background: mix(@control-bg, @body-bg, 50%);
text-decoration: none;
}
&.active {
border-top: 4px solid @primary-color;
padding-top: 15 - 4px;
}
}
.StatisticsWidget-change {
font-size: 11px;
}
.StatisticsWidget-change--up {
color: #00a502;
}
.StatisticsWidget-change--down {
color: #d0011b;
}
.StatisticsWidget-heading {
height: 30px;
padding-top: 5px;
margin: 0;
font-weight: bold;
text-transform: uppercase;
font-size: 12px;
color: @muted-color;
.StatisticsWidget {
padding: 0;
.active & {
color: @primary-color;
&--mini {
padding-top: 20px;
}
}
.StatisticsWidget-total,
.StatisticsWidget-period,
.StatisticsWidget-label {
height: 35px;
}
.StatisticsWidget-total {
font-weight: bold;
}
.StatisticsWidget-chart {
clear: left;
margin: -20px -10px;
}
.StatisticsWidget-chart .chart-container {
.dataset-0 {
opacity: 0.2;
&-title {
margin: 0 20px;
color: @muted-color;
}
.chart-legend {
display: none;
&-entities {
display: flex;
align-items: flex-end;
margin: 0 20px;
& > :not(:last-child) {
margin-right: 10px;
}
}
// Hide the "last period" data from the tooltip
.graph-svg-tip ul.data-point-list > li:first-child {
display: none;
&-labels {
padding-bottom: 15px;
min-width: 130px;
font-size: 12px;
font-weight: bold;
color: @muted-color;
}
&-label {
padding-top: 8px;
}
&-entity {
min-width: 130px;
padding: 15px 20px;
color: @text-color;
font-size: 20px;
.StatisticsWidget:not(.StatisticsWidget--mini) & {
cursor: pointer;
&:hover,
&:focus-visible {
background: mix(@control-bg, @body-bg, 50%);
text-decoration: none;
}
&.active {
border-top: 4px solid @primary-color;
padding-top: 11px;
}
}
}
&-change {
font-size: 11px;
&--up {
color: #00a502;
}
&--down {
color: #d0011b;
}
}
&-heading {
height: 30px;
padding-top: 5px;
margin: 0;
font-weight: bold;
text-transform: uppercase;
font-size: 12px;
color: @muted-color;
.active & {
color: @primary-color;
}
}
&-total,
&-period,
&-label {
height: 35px;
}
&-total {
font-weight: bold;
}
.chart-container {
.dataset-0 {
opacity: 0.2;
}
.chart-legend {
display: none;
}
// Hide the "last period" data from the tooltip
.graph-svg-tip ul.data-point-list > li:first-child {
display: none;
}
}
&-viewFull {
padding: 12px 16px;
text-align: center;
}
}
@@ -88,7 +123,8 @@
'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
.axis, .chart-label {
.axis,
.chart-label {
fill: #555b51;
line {
@@ -168,7 +204,7 @@
position: absolute;
height: 5px;
margin: 0 0 0 -5px;
content: ' ';
content: " ";
border: 5px solid transparent;
border-top-color: rgba(0, 0, 0, 0.8);
}

View File

@@ -9,12 +9,16 @@ flarum-statistics:
# These translations are used in the Statistics dashboard widget.
statistics:
active_users_text: "{count} active"
discussions_heading: => core.ref.discussions
last_12_months_label: Last 12 months
last_28_days_label: Last 28 days
last_7_days_label: Last 7 days
mini_heading: Forum statistics
previous_28_days_label: Previous 28 days
previous_7_days_label: Previous 7 days
loading: => core.ref.loading
posts_heading: => core.ref.posts
today_label: Today
total_label: Total
users_heading: => core.ref.users
view_full: View more statistics

View File

@@ -7,54 +7,101 @@
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Statistics;
namespace Flarum\Statistics\Api\Controller;
use DateTime;
use DateTimeZone;
use Flarum\Discussion\Discussion;
use Flarum\Frontend\Document;
use Flarum\Http\RequestUtil;
use Flarum\Post\Post;
use Flarum\Post\RegisteredTypesScope;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class AddStatisticsData
class ShowStatisticsData implements RequestHandlerInterface
{
/**
* The amount of time to cache lifetime statistics data for in seconds.
*/
public static $lifetimeStatsCacheTtl = 300;
/**
* The amount of time to cache timed statistics data for in seconds.
*/
public static $timedStatsCacheTtl = 900;
protected $entities = [];
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
/**
* @param SettingsRepositoryInterface $settings
* @var CacheRepository
*/
public function __construct(SettingsRepositoryInterface $settings)
protected $cache;
public function __construct(SettingsRepositoryInterface $settings, CacheRepository $cache)
{
$this->settings = $settings;
$this->cache = $cache;
$this->entities = [
'users' => [User::query(), 'joined_at'],
'discussions' => [Discussion::query(), 'created_at'],
'posts' => [Post::where('type', 'comment')->withoutGlobalScope(RegisteredTypesScope::class), 'created_at']
];
}
public function __invoke(Document $view)
public function handle(ServerRequestInterface $request): ResponseInterface
{
$view->payload['statistics'] = array_merge(
$this->getStatistics(),
$actor = RequestUtil::getActor($request);
// Must be an admin to get statistics data -- this is only visible on the admin
// control panel.
$actor->assertAdmin();
$reportingPeriod = Arr::get($request->getQueryParams(), 'period');
return new JsonResponse($this->getResponse($reportingPeriod));
}
private function getResponse(?string $period): array
{
if ($period === 'lifetime') {
return $this->getLifetimeStatistics();
}
return array_merge(
$this->getTimedStatistics(),
['timezoneOffset' => $this->getUserTimezone()->getOffset(new DateTime)]
);
}
private function getStatistics()
private function getLifetimeStatistics()
{
$entities = [
'users' => [User::query(), 'joined_at'],
'discussions' => [Discussion::query(), 'created_at'],
'posts' => [Post::where('type', 'comment'), 'created_at']
];
return $this->cache->remember('flarum-subscriptions.lifetime_stats', self::$lifetimeStatsCacheTtl, function () {
return array_map(function ($entity) {
return $entity[0]->count();
}, $this->entities);
});
}
return array_map(function ($entity) {
return [
'total' => $entity[0]->count(),
'timed' => $this->getTimedCounts($entity[0], $entity[1])
];
}, $entities);
private function getTimedStatistics()
{
return $this->cache->remember('flarum-subscriptions.timed_stats', self::$lifetimeStatsCacheTtl, function () {
return array_map(function ($entity) {
return $this->getTimedCounts($entity[0], $entity[1]);
}, $this->entities);
});
}
private function getTimedCounts(Builder $query, $column)
@@ -69,12 +116,12 @@ class AddStatisticsData
->selectRaw(
'DATE_FORMAT(
@date := DATE_ADD('.$column.', INTERVAL ? SECOND), -- convert to user timezone
IF(@date > ?, \'%Y-%m-%d %H:00:00\', \'%Y-%m-%d\') -- if within the last 48 hours, group by hour
IF(@date > ?, \'%Y-%m-%d %H:00:00\', \'%Y-%m-%d\') -- if within the last 24 hours, group by hour
) as time_group',
[$offset, new DateTime('-48 hours')]
[$offset, new DateTime('-25 hours')]
)
->selectRaw('COUNT(id) as count')
->where($column, '>', new DateTime('-24 months'))
->where($column, '>', new DateTime('-365 days'))
->groupBy('time_group')
->pluck('count', 'time_group');