mirror of
https://github.com/flarum/framework.git
synced 2025-03-03 14:54:10 +08:00
perf(statistics): rewrite for performance on very large communities (#3531)
Co-authored-by: Sami Mazouz <ilyasmazouz@gmail.com>
This commit is contained in:
parent
6dde236d77
commit
af3116bce9
@ -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),
|
||||
];
|
||||
|
@ -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",
|
||||
|
@ -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];
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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');
|
||||
|
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
|
||||
return [
|
||||
'up' => function (Builder $schema) {
|
||||
$schema->table('posts', function (Blueprint $table) {
|
||||
$table->index('type');
|
||||
});
|
||||
},
|
||||
|
||||
'down' => function (Builder $schema) {
|
||||
$schema->table('posts', function (Blueprint $table) {
|
||||
$table->dropIndex(['type']);
|
||||
});
|
||||
}
|
||||
];
|
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
|
||||
return [
|
||||
'up' => function (Builder $schema) {
|
||||
$schema->table('posts', function (Blueprint $table) {
|
||||
$table->index(['type', 'created_at']);
|
||||
});
|
||||
},
|
||||
|
||||
'down' => function (Builder $schema) {
|
||||
$schema->table('posts', function (Blueprint $table) {
|
||||
$table->index(['type', 'created_at']);
|
||||
});
|
||||
}
|
||||
];
|
87
yarn.lock
87
yarn.lock
@ -1115,6 +1115,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
|
||||
integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==
|
||||
|
||||
"@types/mithril@^2.0.11":
|
||||
version "2.0.11"
|
||||
resolved "https://registry.yarnpkg.com/@types/mithril/-/mithril-2.0.11.tgz#090fb16e5ffcc616f7f409a2b3d50a90427f8b97"
|
||||
integrity sha512-2tYTImXc7RzWkPpgcbnSKpV46DQI4Bm8CfgmkrIbst8MJlX6d8hdgy2yQCEf5NZYLGNyK4xbzb4rr8VPmk0iXQ==
|
||||
|
||||
"@types/mithril@^2.0.7", "@types/mithril@^2.0.8":
|
||||
version "2.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/mithril/-/mithril-2.0.9.tgz#5efb9145f44b3c8693745da3ff48aba4a323b500"
|
||||
@ -1307,6 +1312,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-1.1.1.tgz#9f53b1b7946a6efc2a749095a4f450e2932e8356"
|
||||
integrity sha512-1FBc1f9G4P/AxMqIgfZgeOTuRnwZMten8E7zap5zgpPInnCrP8D4Q81+4CWIch8i/Nf7nXjP0v6CjjbHOrXhKg==
|
||||
|
||||
"@webpack-cli/configtest@^1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-1.2.0.tgz#7b20ce1c12533912c3b217ea68262365fa29a6f5"
|
||||
integrity sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==
|
||||
|
||||
"@webpack-cli/info@^1.4.1":
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-1.4.1.tgz#2360ea1710cbbb97ff156a3f0f24556e0fc1ebea"
|
||||
@ -1314,11 +1324,23 @@
|
||||
dependencies:
|
||||
envinfo "^7.7.3"
|
||||
|
||||
"@webpack-cli/info@^1.5.0":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-1.5.0.tgz#6c78c13c5874852d6e2dd17f08a41f3fe4c261b1"
|
||||
integrity sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==
|
||||
dependencies:
|
||||
envinfo "^7.7.3"
|
||||
|
||||
"@webpack-cli/serve@^1.6.1":
|
||||
version "1.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.6.1.tgz#0de2875ac31b46b6c5bb1ae0a7d7f0ba5678dffe"
|
||||
integrity sha512-gNGTiTrjEVQ0OcVnzsRSqTxaBSr+dmTfm+qJsCDluky8uhdLWep7Gcr62QsAKHTMxjCS/8nEITsmFAhfIx+QSw==
|
||||
|
||||
"@webpack-cli/serve@^1.7.0":
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.7.0.tgz#e1993689ac42d2b16e9194376cfb6753f6254db1"
|
||||
integrity sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==
|
||||
|
||||
"@xtuc/ieee754@^1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
|
||||
@ -1736,6 +1758,14 @@ enhanced-resolve@^5.9.2:
|
||||
graceful-fs "^4.2.4"
|
||||
tapable "^2.2.0"
|
||||
|
||||
enhanced-resolve@^5.9.3:
|
||||
version "5.10.0"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz#0dc579c3bb2a1032e357ac45b8f3a6f3ad4fb1e6"
|
||||
integrity sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.4"
|
||||
tapable "^2.2.0"
|
||||
|
||||
envinfo@^7.7.3:
|
||||
version "7.8.1"
|
||||
resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.8.1.tgz#06377e3e5f4d379fea7ac592d5ad8927e0c4d475"
|
||||
@ -2177,7 +2207,7 @@ json-parse-better-errors@^1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
|
||||
integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==
|
||||
|
||||
json-parse-even-better-errors@^2.3.0:
|
||||
json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
|
||||
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
|
||||
@ -3073,7 +3103,7 @@ typed-styles@^0.0.7:
|
||||
resolved "https://registry.yarnpkg.com/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9"
|
||||
integrity sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q==
|
||||
|
||||
typescript-coverage-report@^0.6.1:
|
||||
typescript-coverage-report@^0.6.1, typescript-coverage-report@^0.6.4:
|
||||
version "0.6.4"
|
||||
resolved "https://registry.yarnpkg.com/typescript-coverage-report/-/typescript-coverage-report-0.6.4.tgz#3a7a7724c0f27de50d2a0708c7b7b7088bed2055"
|
||||
integrity sha512-G+0OFYxwN5oRbORlU1nKYtO00G567lcl4+nbg3MU3Y9ayFnh677dMHmAL4JGP/4Cb1IBN5h/DUQDr/z9X+9lag==
|
||||
@ -3093,6 +3123,11 @@ typescript@^4.4.4, typescript@^4.5.4:
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4"
|
||||
integrity sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==
|
||||
|
||||
typescript@^4.7.4:
|
||||
version "4.7.4"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
|
||||
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
|
||||
|
||||
unicode-canonical-property-names-ecmascript@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc"
|
||||
@ -3166,6 +3201,24 @@ webpack-bundle-analyzer@^4.5.0:
|
||||
sirv "^1.0.7"
|
||||
ws "^7.3.1"
|
||||
|
||||
webpack-cli@^4.10.0:
|
||||
version "4.10.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-4.10.0.tgz#37c1d69c8d85214c5a65e589378f53aec64dab31"
|
||||
integrity sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==
|
||||
dependencies:
|
||||
"@discoveryjs/json-ext" "^0.5.0"
|
||||
"@webpack-cli/configtest" "^1.2.0"
|
||||
"@webpack-cli/info" "^1.5.0"
|
||||
"@webpack-cli/serve" "^1.7.0"
|
||||
colorette "^2.0.14"
|
||||
commander "^7.0.0"
|
||||
cross-spawn "^7.0.3"
|
||||
fastest-levenshtein "^1.0.12"
|
||||
import-local "^3.0.2"
|
||||
interpret "^2.2.0"
|
||||
rechoir "^0.7.0"
|
||||
webpack-merge "^5.7.3"
|
||||
|
||||
webpack-cli@^4.9.1:
|
||||
version "4.9.2"
|
||||
resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-4.9.2.tgz#77c1adaea020c3f9e2db8aad8ea78d235c83659d"
|
||||
@ -3227,6 +3280,36 @@ webpack@^5.0.0, webpack@^5.65.0:
|
||||
watchpack "^2.3.1"
|
||||
webpack-sources "^3.2.3"
|
||||
|
||||
webpack@^5.73.0:
|
||||
version "5.73.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.73.0.tgz#bbd17738f8a53ee5760ea2f59dce7f3431d35d38"
|
||||
integrity sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA==
|
||||
dependencies:
|
||||
"@types/eslint-scope" "^3.7.3"
|
||||
"@types/estree" "^0.0.51"
|
||||
"@webassemblyjs/ast" "1.11.1"
|
||||
"@webassemblyjs/wasm-edit" "1.11.1"
|
||||
"@webassemblyjs/wasm-parser" "1.11.1"
|
||||
acorn "^8.4.1"
|
||||
acorn-import-assertions "^1.7.6"
|
||||
browserslist "^4.14.5"
|
||||
chrome-trace-event "^1.0.2"
|
||||
enhanced-resolve "^5.9.3"
|
||||
es-module-lexer "^0.9.0"
|
||||
eslint-scope "5.1.1"
|
||||
events "^3.2.0"
|
||||
glob-to-regexp "^0.4.1"
|
||||
graceful-fs "^4.2.9"
|
||||
json-parse-even-better-errors "^2.3.1"
|
||||
loader-runner "^4.2.0"
|
||||
mime-types "^2.1.27"
|
||||
neo-async "^2.6.2"
|
||||
schema-utils "^3.1.0"
|
||||
tapable "^2.1.1"
|
||||
terser-webpack-plugin "^5.1.3"
|
||||
watchpack "^2.3.1"
|
||||
webpack-sources "^3.2.3"
|
||||
|
||||
which@^2.0.1:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
|
||||
|
Loading…
x
Reference in New Issue
Block a user