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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 85 additions and 89 deletions

View File

@ -25,11 +25,14 @@ export default class StatisticsWidget extends DashboardWidget {
chart: any;
timedData: any;
timedData: Record<string, undefined | any> = {};
lifetimeData: any;
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';
selectedPeriod: undefined | string;
@ -41,7 +44,6 @@ export default class StatisticsWidget extends DashboardWidget {
super.oncreate(vnode);
this.loadLifetimeData();
this.loadTimedData();
}
async loadLifetimeData() {
@ -62,39 +64,43 @@ export default class StatisticsWidget extends DashboardWidget {
m.redraw();
}
async loadTimedData() {
this.loadingTimed = true;
async loadTimedData(model: string) {
this.loadingTimed[model] = 'loading';
m.redraw();
const data = await app.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/statistics',
});
try {
const data = await app.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/statistics',
params: {
period: 'timed',
model,
},
});
this.timedData = data;
this.loadingTimed = false;
this.timedData[model] = data;
this.loadingTimed[model] = 'loaded';
// 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);
// Create a Date object which represents the start of the day.
let todayDate = new Date();
todayDate.setUTCHours(0, 0, 0, 0);
const today = todayDate.getTime() / 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.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';
this.selectedPeriod = 'last_7_days';
} catch (e) {
console.error(e);
this.loadingTimed[model] = 'fail';
}
m.redraw();
}
@ -104,7 +110,13 @@ export default class StatisticsWidget extends DashboardWidget {
}
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 (
<div className="StatisticsWidget-table">
@ -112,10 +124,10 @@ export default class StatisticsWidget extends DashboardWidget {
<div className="StatisticsWidget-labels">
<div className="StatisticsWidget-label">{app.translator.trans('flarum-statistics.admin.statistics.total_label')}</div>
<div className="StatisticsWidget-label">
{this.loadingTimed ? (
{loadingSelectedEntity ? (
<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) => (
<Button
key={period}
@ -133,14 +145,14 @@ export default class StatisticsWidget extends DashboardWidget {
{this.entities.map((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')
: this.getPeriodCount(entity, thisPeriod!);
const lastPeriodCount = this.loadingTimed
const lastPeriodCount = loadingSelectedEntity
? app.translator.trans('flarum-statistics.admin.statistics.loading')
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
const periodChange =
this.loadingTimed || lastPeriodCount === 0
loadingSelectedEntity || lastPeriodCount === 0
? 0
: (((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)}
</div>
<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 && (
<>
{' '}
@ -170,13 +182,21 @@ export default class StatisticsWidget extends DashboardWidget {
})}
</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)} />
)}
<>
{loadingSelectedEntity ? (
<div key="loading" className="StatisticsWidget-chart" data-loading="true">
<LoadingIndicator size="large" />
</div>
) : (
<div
key="loaded"
className="StatisticsWidget-chart"
data-loading="false"
oncreate={this.drawChart.bind(this)}
onupdate={this.drawChart.bind(this)}
/>
)}
</>
</div>
);
}
@ -186,7 +206,6 @@ export default class StatisticsWidget extends DashboardWidget {
return;
}
const offset = this.timedData.timezoneOffset;
const period = this.periods![this.selectedPeriod!];
const periodLength = period.end - period.start;
const labels = [];
@ -197,19 +216,18 @@ export default class StatisticsWidget extends DashboardWidget {
let label;
if (period.step < 86400) {
label = dayjs.unix(i + offset).format('h A');
label = dayjs.unix(i).format('h A');
} else {
label = dayjs.unix(i + offset).format('D MMM');
label = dayjs.unix(i).format('D MMM');
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);
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 }));
}
@ -219,7 +237,9 @@ export default class StatisticsWidget extends DashboardWidget {
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, {
data,
type: 'line',

View File

@ -10,7 +10,6 @@
namespace Flarum\Statistics\Api\Controller;
use DateTime;
use DateTimeZone;
use Flarum\Discussion\Discussion;
use Flarum\Http\RequestUtil;
use Flarum\Post\Post;
@ -24,6 +23,7 @@ use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Tobscure\JsonApi\Exception\InvalidParameterException;
class ShowStatisticsData implements RequestHandlerInterface
{
@ -70,20 +70,22 @@ class ShowStatisticsData implements RequestHandlerInterface
$actor->assertAdmin();
$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') {
return $this->getLifetimeStatistics();
}
return array_merge(
$this->getTimedStatistics(),
['timezoneOffset' => $this->getUserTimezone()->getOffset(new DateTime)]
);
if (! Arr::exists($this->entities, $model)) {
throw new InvalidParameterException();
}
return $this->getTimedStatistics($model);
}
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 array_map(function ($entity) {
return $this->getTimedCounts($entity[0], $entity[1]);
}, $this->entities);
return $this->cache->remember("flarum-subscriptions.timed_stats.$model", self::$lifetimeStatsCacheTtl, function () use ($model) {
return $this->getTimedCounts($this->entities[$model][0], $this->entities[$model][1]);
});
}
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
->selectRaw(
'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
) as time_group',
[$offset, new DateTime('-25 hours')]
[new DateTime('-25 hours')]
)
->selectRaw('COUNT(id) as count')
->where($column, '>', new DateTime('-365 days'))
->groupBy('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 = [];
$results->each(function ($count, $time) use (&$timed, $userTimezone) {
$time = new DateTime($time, $userTimezone);
$results->each(function ($count, $time) use (&$timed) {
$time = new DateTime($time);
$timed[$time->getTimestamp()] = (int) $count;
});
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()));
}
}