mirror of
https://github.com/flarum/core.git
synced 2025-07-21 16:51:34 +02:00
perf(statistics): split timed data into per-model XHR requests (#3601)
* chore: kill off timeset offset from statistics extension * perf: split timed data into per-model requests
This commit is contained in:
@@ -25,11 +25,14 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
|
|
||||||
chart: any;
|
chart: any;
|
||||||
|
|
||||||
timedData: any;
|
timedData: Record<string, undefined | any> = {};
|
||||||
lifetimeData: any;
|
lifetimeData: any;
|
||||||
|
|
||||||
loadingLifetime = true;
|
loadingLifetime = true;
|
||||||
loadingTimed = true;
|
loadingTimed: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'> = this.entities.reduce((acc, curr) => {
|
||||||
|
acc[curr] = 'unloaded';
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>);
|
||||||
|
|
||||||
selectedEntity = 'users';
|
selectedEntity = 'users';
|
||||||
selectedPeriod: undefined | string;
|
selectedPeriod: undefined | string;
|
||||||
@@ -41,7 +44,6 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
super.oncreate(vnode);
|
super.oncreate(vnode);
|
||||||
|
|
||||||
this.loadLifetimeData();
|
this.loadLifetimeData();
|
||||||
this.loadTimedData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadLifetimeData() {
|
async loadLifetimeData() {
|
||||||
@@ -62,26 +64,26 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
m.redraw();
|
m.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadTimedData() {
|
async loadTimedData(model: string) {
|
||||||
this.loadingTimed = true;
|
this.loadingTimed[model] = 'loading';
|
||||||
m.redraw();
|
m.redraw();
|
||||||
|
|
||||||
|
try {
|
||||||
const data = await app.request({
|
const data = await app.request({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: app.forum.attribute('apiUrl') + '/statistics',
|
url: app.forum.attribute('apiUrl') + '/statistics',
|
||||||
|
params: {
|
||||||
|
period: 'timed',
|
||||||
|
model,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.timedData = data;
|
this.timedData[model] = data;
|
||||||
this.loadingTimed = false;
|
this.loadingTimed[model] = 'loaded';
|
||||||
|
|
||||||
// Create a Date object which represents the start of the day in the
|
// Create a Date object which represents the start of the day.
|
||||||
// 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();
|
let todayDate = new Date();
|
||||||
todayDate.setTime(todayDate.getTime() + this.timedData.timezoneOffset * 1000);
|
|
||||||
todayDate.setUTCHours(0, 0, 0, 0);
|
todayDate.setUTCHours(0, 0, 0, 0);
|
||||||
todayDate.setTime(todayDate.getTime() - this.timedData.timezoneOffset * 1000);
|
|
||||||
|
|
||||||
const today = todayDate.getTime() / 1000;
|
const today = todayDate.getTime() / 1000;
|
||||||
|
|
||||||
@@ -95,6 +97,10 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.selectedPeriod = 'last_7_days';
|
this.selectedPeriod = 'last_7_days';
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
this.loadingTimed[model] = 'fail';
|
||||||
|
}
|
||||||
|
|
||||||
m.redraw();
|
m.redraw();
|
||||||
}
|
}
|
||||||
@@ -104,7 +110,13 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
content() {
|
||||||
const thisPeriod = this.loadingTimed ? null : this.periods![this.selectedPeriod!];
|
const loadingSelectedEntity = this.loadingTimed[this.selectedEntity] !== 'loaded';
|
||||||
|
|
||||||
|
const thisPeriod = loadingSelectedEntity ? null : this.periods![this.selectedPeriod!];
|
||||||
|
|
||||||
|
if (!this.timedData[this.selectedEntity] && this.loadingTimed[this.selectedEntity] === 'unloaded') {
|
||||||
|
this.loadTimedData(this.selectedEntity);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="StatisticsWidget-table">
|
<div className="StatisticsWidget-table">
|
||||||
@@ -112,10 +124,10 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
<div className="StatisticsWidget-labels">
|
<div className="StatisticsWidget-labels">
|
||||||
<div className="StatisticsWidget-label">{app.translator.trans('flarum-statistics.admin.statistics.total_label')}</div>
|
<div className="StatisticsWidget-label">{app.translator.trans('flarum-statistics.admin.statistics.total_label')}</div>
|
||||||
<div className="StatisticsWidget-label">
|
<div className="StatisticsWidget-label">
|
||||||
{this.loadingTimed ? (
|
{loadingSelectedEntity ? (
|
||||||
<LoadingIndicator size="small" display="inline" />
|
<LoadingIndicator size="small" display="inline" />
|
||||||
) : (
|
) : (
|
||||||
<SelectDropdown disabled={this.loadingTimed} buttonClassName="Button Button--text" caretIcon="fas fa-caret-down">
|
<SelectDropdown disabled={loadingSelectedEntity} buttonClassName="Button Button--text" caretIcon="fas fa-caret-down">
|
||||||
{Object.keys(this.periods!).map((period) => (
|
{Object.keys(this.periods!).map((period) => (
|
||||||
<Button
|
<Button
|
||||||
key={period}
|
key={period}
|
||||||
@@ -133,14 +145,14 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
|
|
||||||
{this.entities.map((entity) => {
|
{this.entities.map((entity) => {
|
||||||
const totalCount = this.loadingLifetime ? app.translator.trans('flarum-statistics.admin.statistics.loading') : this.getTotalCount(entity);
|
const totalCount = this.loadingLifetime ? app.translator.trans('flarum-statistics.admin.statistics.loading') : this.getTotalCount(entity);
|
||||||
const thisPeriodCount = this.loadingTimed
|
const thisPeriodCount = loadingSelectedEntity
|
||||||
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
||||||
: this.getPeriodCount(entity, thisPeriod!);
|
: this.getPeriodCount(entity, thisPeriod!);
|
||||||
const lastPeriodCount = this.loadingTimed
|
const lastPeriodCount = loadingSelectedEntity
|
||||||
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
||||||
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
|
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
|
||||||
const periodChange =
|
const periodChange =
|
||||||
this.loadingTimed || lastPeriodCount === 0
|
loadingSelectedEntity || lastPeriodCount === 0
|
||||||
? 0
|
? 0
|
||||||
: (((thisPeriodCount as number) - (lastPeriodCount as number)) / (lastPeriodCount as number)) * 100;
|
: (((thisPeriodCount as number) - (lastPeriodCount as number)) / (lastPeriodCount as number)) * 100;
|
||||||
|
|
||||||
@@ -154,7 +166,7 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
{this.loadingLifetime ? <LoadingIndicator display="inline" /> : abbreviateNumber(totalCount as number)}
|
{this.loadingLifetime ? <LoadingIndicator display="inline" /> : abbreviateNumber(totalCount as number)}
|
||||||
</div>
|
</div>
|
||||||
<div className="StatisticsWidget-period" title={thisPeriodCount}>
|
<div className="StatisticsWidget-period" title={thisPeriodCount}>
|
||||||
{this.loadingTimed ? <LoadingIndicator display="inline" /> : abbreviateNumber(thisPeriodCount as number)}
|
{loadingSelectedEntity ? <LoadingIndicator display="inline" /> : abbreviateNumber(thisPeriodCount as number)}
|
||||||
{periodChange !== 0 && (
|
{periodChange !== 0 && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
@@ -170,13 +182,21 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{this.loadingTimed ? (
|
<>
|
||||||
<div className="StatisticsWidget-chart">
|
{loadingSelectedEntity ? (
|
||||||
|
<div key="loading" className="StatisticsWidget-chart" data-loading="true">
|
||||||
<LoadingIndicator size="large" />
|
<LoadingIndicator size="large" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="StatisticsWidget-chart" oncreate={this.drawChart.bind(this)} onupdate={this.drawChart.bind(this)} />
|
<div
|
||||||
|
key="loaded"
|
||||||
|
className="StatisticsWidget-chart"
|
||||||
|
data-loading="false"
|
||||||
|
oncreate={this.drawChart.bind(this)}
|
||||||
|
onupdate={this.drawChart.bind(this)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -186,7 +206,6 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const offset = this.timedData.timezoneOffset;
|
|
||||||
const period = this.periods![this.selectedPeriod!];
|
const period = this.periods![this.selectedPeriod!];
|
||||||
const periodLength = period.end - period.start;
|
const periodLength = period.end - period.start;
|
||||||
const labels = [];
|
const labels = [];
|
||||||
@@ -197,19 +216,18 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
let label;
|
let label;
|
||||||
|
|
||||||
if (period.step < 86400) {
|
if (period.step < 86400) {
|
||||||
label = dayjs.unix(i + offset).format('h A');
|
label = dayjs.unix(i).format('h A');
|
||||||
} else {
|
} else {
|
||||||
label = dayjs.unix(i + offset).format('D MMM');
|
label = dayjs.unix(i).format('D MMM');
|
||||||
|
|
||||||
if (period.step > 86400) {
|
if (period.step > 86400) {
|
||||||
label += ' - ' + dayjs.unix(i + offset + period.step - 1).format('D MMM');
|
label += ' - ' + dayjs.unix(i + period.step - 1).format('D MMM');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
labels.push(label);
|
labels.push(label);
|
||||||
|
|
||||||
thisPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i, end: i + period.step }));
|
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 }));
|
lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength + period.step }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +237,9 @@ export default class StatisticsWidget extends DashboardWidget {
|
|||||||
datasets,
|
datasets,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!this.chart) {
|
// If the dom element no longer exists, recreate the chart
|
||||||
|
// https://stackoverflow.com/a/2620373/11091039
|
||||||
|
if (!this.chart || !(document.compareDocumentPosition(this.chart.parent) & 16)) {
|
||||||
this.chart = new Chart(vnode.dom, {
|
this.chart = new Chart(vnode.dom, {
|
||||||
data,
|
data,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
|
@@ -10,7 +10,6 @@
|
|||||||
namespace Flarum\Statistics\Api\Controller;
|
namespace Flarum\Statistics\Api\Controller;
|
||||||
|
|
||||||
use DateTime;
|
use DateTime;
|
||||||
use DateTimeZone;
|
|
||||||
use Flarum\Discussion\Discussion;
|
use Flarum\Discussion\Discussion;
|
||||||
use Flarum\Http\RequestUtil;
|
use Flarum\Http\RequestUtil;
|
||||||
use Flarum\Post\Post;
|
use Flarum\Post\Post;
|
||||||
@@ -24,6 +23,7 @@ use Laminas\Diactoros\Response\JsonResponse;
|
|||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
use Tobscure\JsonApi\Exception\InvalidParameterException;
|
||||||
|
|
||||||
class ShowStatisticsData implements RequestHandlerInterface
|
class ShowStatisticsData implements RequestHandlerInterface
|
||||||
{
|
{
|
||||||
@@ -70,20 +70,22 @@ class ShowStatisticsData implements RequestHandlerInterface
|
|||||||
$actor->assertAdmin();
|
$actor->assertAdmin();
|
||||||
|
|
||||||
$reportingPeriod = Arr::get($request->getQueryParams(), 'period');
|
$reportingPeriod = Arr::get($request->getQueryParams(), 'period');
|
||||||
|
$model = Arr::get($request->getQueryParams(), 'model');
|
||||||
|
|
||||||
return new JsonResponse($this->getResponse($reportingPeriod));
|
return new JsonResponse($this->getResponse($model, $reportingPeriod));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getResponse(?string $period): array
|
private function getResponse(?string $model, ?string $period): array
|
||||||
{
|
{
|
||||||
if ($period === 'lifetime') {
|
if ($period === 'lifetime') {
|
||||||
return $this->getLifetimeStatistics();
|
return $this->getLifetimeStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_merge(
|
if (! Arr::exists($this->entities, $model)) {
|
||||||
$this->getTimedStatistics(),
|
throw new InvalidParameterException();
|
||||||
['timezoneOffset' => $this->getUserTimezone()->getOffset(new DateTime)]
|
}
|
||||||
);
|
|
||||||
|
return $this->getTimedStatistics($model);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getLifetimeStatistics()
|
private function getLifetimeStatistics()
|
||||||
@@ -95,61 +97,35 @@ class ShowStatisticsData implements RequestHandlerInterface
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getTimedStatistics()
|
private function getTimedStatistics(string $model)
|
||||||
{
|
{
|
||||||
return $this->cache->remember('flarum-subscriptions.timed_stats', self::$lifetimeStatsCacheTtl, function () {
|
return $this->cache->remember("flarum-subscriptions.timed_stats.$model", self::$lifetimeStatsCacheTtl, function () use ($model) {
|
||||||
return array_map(function ($entity) {
|
return $this->getTimedCounts($this->entities[$model][0], $this->entities[$model][1]);
|
||||||
return $this->getTimedCounts($entity[0], $entity[1]);
|
|
||||||
}, $this->entities);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getTimedCounts(Builder $query, $column)
|
private function getTimedCounts(Builder $query, $column)
|
||||||
{
|
{
|
||||||
// Calculate the offset between the server timezone (which is used for
|
|
||||||
// dates stored in the database) and the user's timezone (set via the
|
|
||||||
// settings table). We will use this to make sure we aggregate the
|
|
||||||
// daily/hourly statistics according to the user's timezone.
|
|
||||||
$offset = $this->getTimezoneOffset();
|
|
||||||
|
|
||||||
$results = $query
|
$results = $query
|
||||||
->selectRaw(
|
->selectRaw(
|
||||||
'DATE_FORMAT(
|
'DATE_FORMAT(
|
||||||
@date := DATE_ADD('.$column.', INTERVAL ? SECOND), -- convert to user timezone
|
@date := '.$column.',
|
||||||
IF(@date > ?, \'%Y-%m-%d %H:00:00\', \'%Y-%m-%d\') -- if within the last 24 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',
|
) as time_group',
|
||||||
[$offset, new DateTime('-25 hours')]
|
[new DateTime('-25 hours')]
|
||||||
)
|
)
|
||||||
->selectRaw('COUNT(id) as count')
|
->selectRaw('COUNT(id) as count')
|
||||||
->where($column, '>', new DateTime('-365 days'))
|
->where($column, '>', new DateTime('-365 days'))
|
||||||
->groupBy('time_group')
|
->groupBy('time_group')
|
||||||
->pluck('count', 'time_group');
|
->pluck('count', 'time_group');
|
||||||
|
|
||||||
// Now that we have the aggregated statistics, convert each time group
|
|
||||||
// into a UNIX timestamp.
|
|
||||||
$userTimezone = $this->getUserTimezone();
|
|
||||||
|
|
||||||
$timed = [];
|
$timed = [];
|
||||||
|
|
||||||
$results->each(function ($count, $time) use (&$timed, $userTimezone) {
|
$results->each(function ($count, $time) use (&$timed) {
|
||||||
$time = new DateTime($time, $userTimezone);
|
$time = new DateTime($time);
|
||||||
$timed[$time->getTimestamp()] = (int) $count;
|
$timed[$time->getTimestamp()] = (int) $count;
|
||||||
});
|
});
|
||||||
|
|
||||||
return $timed;
|
return $timed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getTimezoneOffset()
|
|
||||||
{
|
|
||||||
$now = new DateTime;
|
|
||||||
|
|
||||||
$dataTimezone = new DateTimeZone(date_default_timezone_get());
|
|
||||||
|
|
||||||
return $this->getUserTimezone()->getOffset($now) - $dataTimezone->getOffset($now);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getUserTimezone()
|
|
||||||
{
|
|
||||||
return new DateTimeZone($this->settings->get('flarum-statistics.timezone', date_default_timezone_get()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user