1
0
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:
David Wheatley
2022-08-16 18:30:24 +01:00
committed by GitHub
parent 5637fe8041
commit 352a50e3ad
2 changed files with 85 additions and 89 deletions

View File

@@ -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',

View File

@@ -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()));
}
} }