Add graphs and retention metrics to admin dashboard (#16829)

This commit is contained in:
Eugen Rochko 2021-10-14 20:44:59 +02:00 committed by GitHub
parent 959f7fc580
commit 07341e7aa6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1650 additions and 257 deletions

View File

@ -1,49 +1,17 @@
# frozen_string_literal: true
require 'sidekiq/api'
module Admin
class DashboardController < BaseController
def index
@system_checks = Admin::SystemCheck.perform
@users_count = User.count
@time_period = (1.month.ago.to_date...Time.now.utc.to_date)
@pending_users_count = User.pending.count
@registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0
@logins_week = Redis.current.pfcount("activity:logins:#{current_week}")
@interactions_week = Redis.current.get("activity:interactions:#{current_week}") || 0
@relay_enabled = Relay.enabled.exists?
@single_user_mode = Rails.configuration.x.single_user_mode
@registrations_enabled = Setting.registrations_mode != 'none'
@deletions_enabled = Setting.open_deletion
@invites_enabled = Setting.min_invite_role == 'user'
@search_enabled = Chewy.enabled?
@version = Mastodon::Version.to_s
@database_version = ActiveRecord::Base.connection.execute('SELECT VERSION()').first['version'].match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1]
@redis_version = redis_info['redis_version']
@reports_count = Report.unresolved.count
@queue_backlog = Sidekiq::Stats.new.enqueued
@recent_users = User.confirmed.recent.includes(:account).limit(8)
@database_size = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size']
@redis_size = redis_info['used_memory']
@ldap_enabled = ENV['LDAP_ENABLED'] == 'true'
@cas_enabled = ENV['CAS_ENABLED'] == 'true'
@saml_enabled = ENV['SAML_ENABLED'] == 'true'
@pam_enabled = ENV['PAM_ENABLED'] == 'true'
@hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
@trending_hashtags = TrendingTags.get(10, filtered: false)
@pending_reports_count = Report.unresolved.count
@pending_tags_count = Tag.pending_review.count
@authorized_fetch = authorized_fetch_mode?
@whitelist_enabled = whitelist_mode?
@profile_directory = Setting.profile_directory
@timeline_preview = Setting.timeline_preview
@trends_enabled = Setting.trends
end
private
def current_week
@current_week ||= Time.now.utc.to_date.cweek
end
def redis_info
@redis_info ||= begin
if Redis.current.is_a?(Redis::Namespace)

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Api::V1::Admin::DimensionsController < Api::BaseController
protect_from_forgery with: :exception
before_action :require_staff!
before_action :set_dimensions
def create
render json: @dimensions, each_serializer: REST::Admin::DimensionSerializer
end
private
def set_dimensions
@dimensions = Admin::Metrics::Dimension.retrieve(
params[:keys],
params[:start_at],
params[:end_at],
params[:limit]
)
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class Api::V1::Admin::MeasuresController < Api::BaseController
protect_from_forgery with: :exception
before_action :require_staff!
before_action :set_measures
def create
render json: @measures, each_serializer: REST::Admin::MeasureSerializer
end
private
def set_measures
@measures = Admin::Metrics::Measure.retrieve(
params[:keys],
params[:start_at],
params[:end_at]
)
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class Api::V1::Admin::RetentionController < Api::BaseController
protect_from_forgery with: :exception
before_action :require_staff!
before_action :set_cohorts
def create
render json: @cohorts, each_serializer: REST::Admin::CohortSerializer
end
private
def set_cohorts
@cohorts = Admin::Metrics::Retention.new(
params[:start_at],
params[:end_at],
params[:frequency]
).cohorts
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Api::V1::Admin::TrendsController < Api::BaseController
before_action :require_staff!
before_action :set_trends
def index
render json: @trends, each_serializer: REST::Admin::TagSerializer
end
private
def set_trends
@trends = TrendingTags.get(10, filtered: false)
end
end

View File

@ -14,22 +14,21 @@ class Api::V1::Instances::ActivityController < Api::BaseController
private
def activity
weeks = []
statuses_tracker = ActivityTracker.new('activity:statuses:local', :basic)
logins_tracker = ActivityTracker.new('activity:logins', :unique)
registrations_tracker = ActivityTracker.new('activity:accounts:local', :basic)
12.times do |i|
day = i.weeks.ago.to_date
week_id = day.cweek
week = Date.commercial(day.cwyear, week_id)
(0...12).map do |i|
start_of_week = i.weeks.ago
end_of_week = start_of_week + 6.days
weeks << {
week: week.to_time.to_i.to_s,
statuses: Redis.current.get("activity:statuses:local:#{week_id}") || '0',
logins: Redis.current.pfcount("activity:logins:#{week_id}").to_s,
registrations: Redis.current.get("activity:accounts:local:#{week_id}") || '0',
{
week: start_of_week.to_i.to_s,
statuses: statuses_tracker.sum(start_of_week, end_of_week).to_s,
logins: logins_tracker.sum(start_of_week, end_of_week).to_s,
registrations: registrations_tracker.sum(start_of_week, end_of_week).to_s,
}
end
weeks
end
def require_enabled_api!

View File

@ -137,6 +137,10 @@ module ApplicationHelper
end
end
def react_admin_component(name, props = {})
content_tag(:div, nil, data: { 'admin-component': name.to_s.camelcase, props: Oj.dump({ locale: I18n.locale }.merge(props)) })
end
def body_classes
output = (@body_classes || '').split(' ')
output << "theme-#{current_theme.parameterize}"

View File

@ -0,0 +1,115 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { FormattedNumber } from 'react-intl';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import classNames from 'classnames';
import Skeleton from 'mastodon/components/skeleton';
const percIncrease = (a, b) => {
let percent;
if (b !== 0) {
if (a !== 0) {
percent = (b - a) / a;
} else {
percent = 1;
}
} else if (b === 0 && a === 0) {
percent = 0;
} else {
percent = - 1;
}
return percent;
};
export default class Counter extends React.PureComponent {
static propTypes = {
measure: PropTypes.string.isRequired,
start_at: PropTypes.string.isRequired,
end_at: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
href: PropTypes.string,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { measure, start_at, end_at } = this.props;
api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at }).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { label, href } = this.props;
const { loading, data } = this.state;
let content;
if (loading) {
content = (
<React.Fragment>
<span className='sparkline__value__total'><Skeleton width={43} /></span>
<span className='sparkline__value__change'><Skeleton width={43} /></span>
</React.Fragment>
);
} else {
const measure = data[0];
const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1);
content = (
<React.Fragment>
<span className='sparkline__value__total'><FormattedNumber value={measure.total} /></span>
<span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>
</React.Fragment>
);
}
const inner = (
<React.Fragment>
<div className='sparkline__value'>
{content}
</div>
<div className='sparkline__label'>
{label}
</div>
<div className='sparkline__graph'>
{!loading && (
<Sparklines width={259} height={55} data={data[0].data.map(x => x.value * 1)}>
<SparklinesCurve />
</Sparklines>
)}
</div>
</React.Fragment>
);
if (href) {
return (
<a href={href} className='sparkline'>
{inner}
</a>
);
} else {
return (
<div className='sparkline'>
{inner}
</div>
);
}
}
}

View File

@ -0,0 +1,92 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { FormattedNumber } from 'react-intl';
import { roundTo10 } from 'mastodon/utils/numbers';
import Skeleton from 'mastodon/components/skeleton';
export default class Dimension extends React.PureComponent {
static propTypes = {
dimension: PropTypes.string.isRequired,
start_at: PropTypes.string.isRequired,
end_at: PropTypes.string.isRequired,
limit: PropTypes.number.isRequired,
label: PropTypes.string.isRequired,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { start_at, end_at, dimension, limit } = this.props;
api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit }).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { label, limit } = this.props;
const { loading, data } = this.state;
let content;
if (loading) {
content = (
<table>
<tbody>
{Array.from(Array(limit)).map((_, i) => (
<tr className='dimension__item' key={i}>
<td className='dimension__item__key'>
<Skeleton width={100} />
</td>
<td className='dimension__item__value'>
<Skeleton width={60} />
</td>
</tr>
))}
</tbody>
</table>
);
} else {
const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0);
content = (
<table>
<tbody>
{data[0].data.map(item => (
<tr className='dimension__item' key={item.key}>
<td className='dimension__item__key'>
<span className={`dimension__item__indicator dimension__item__indicator--${roundTo10(((item.value * 1) / sum) * 100)}`} />
<span title={item.key}>{item.human_key}</span>
</td>
<td className='dimension__item__value'>
{typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={item.value} />}
</td>
</tr>
))}
</tbody>
</table>
);
}
return (
<div className='dimension'>
<h4>{label}</h4>
{content}
</div>
);
}
}

View File

@ -0,0 +1,141 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
import classNames from 'classnames';
import { roundTo10 } from 'mastodon/utils/numbers';
const dateForCohort = cohort => {
switch(cohort.frequency) {
case 'day':
return <FormattedDate value={cohort.period} month='long' day='2-digit' />;
default:
return <FormattedDate value={cohort.period} month='long' year='numeric' />;
}
};
export default class Retention extends React.PureComponent {
static propTypes = {
start_at: PropTypes.string,
end_at: PropTypes.string,
frequency: PropTypes.string,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { start_at, end_at, frequency } = this.props;
api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { loading, data } = this.state;
let content;
if (loading) {
content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />;
} else {
content = (
<table className='retention__table'>
<thead>
<tr>
<th>
<div className='retention__table__date retention__table__label'>
<FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' />
</div>
</th>
<th>
<div className='retention__table__number retention__table__label'>
<FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' />
</div>
</th>
{data[0].data.slice(1).map((retention, i) => (
<th key={retention.date}>
<div className='retention__table__number retention__table__label'>
{i + 1}
</div>
</th>
))}
</tr>
<tr>
<td>
<div className='retention__table__date retention__table__average'>
<FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' />
</div>
</td>
<td>
<div className='retention__table__size'>
<FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} />
</div>
</td>
{data[0].data.slice(1).map((retention, i) => {
const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].percent - sum)/(k + 1) : sum, 0);
return (
<td key={retention.date}>
<div className={classNames('retention__table__box', 'retention__table__average', `retention__table__box--${roundTo10(average * 100)}`)}>
<FormattedNumber value={average} style='percent' />
</div>
</td>
);
})}
</tr>
</thead>
<tbody>
{data.slice(0, -1).map(cohort => (
<tr key={cohort.period}>
<td>
<div className='retention__table__date'>
{dateForCohort(cohort)}
</div>
</td>
<td>
<div className='retention__table__size'>
<FormattedNumber value={cohort.data[0].value} />
</div>
</td>
{cohort.data.slice(1).map(retention => (
<td key={retention.date}>
<div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.percent * 100)}`)}>
<FormattedNumber value={retention.percent} style='percent' />
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
return (
<div className='retention'>
<h4><FormattedMessage id='admin.dashboard.retention' defaultMessage='Retention' /></h4>
{content}
</div>
);
}
}

View File

@ -0,0 +1,73 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Hashtag from 'mastodon/components/hashtag';
export default class Trends extends React.PureComponent {
static propTypes = {
limit: PropTypes.number.isRequired,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { limit } = this.props;
api().get('/api/v1/admin/trends', { params: { limit } }).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { limit } = this.props;
const { loading, data } = this.state;
let content;
if (loading) {
content = (
<div>
{Array.from(Array(limit)).map((_, i) => (
<Hashtag key={i} />
))}
</div>
);
} else {
content = (
<div>
{data.map(hashtag => (
<Hashtag
key={hashtag.name}
name={hashtag.name}
href={`/admin/tags/${hashtag.id}`}
people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
history={hashtag.history.reverse().map(day => day.uses)}
className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')}
/>
))}
</div>
);
}
return (
<div className='trends trends--compact'>
<h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
{content}
</div>
);
}
}

View File

@ -6,6 +6,8 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from './permalink';
import ShortNumber from 'mastodon/components/short_number';
import Skeleton from 'mastodon/components/skeleton';
import classNames from 'classnames';
class SilentErrorBoundary extends React.Component {
@ -47,45 +49,38 @@ const accountsCountRenderer = (displayNumber, pluralReady) => (
/>
);
const Hashtag = ({ hashtag }) => (
<div className='trends__item'>
export const ImmutableHashtag = ({ hashtag }) => (
<Hashtag
name={hashtag.get('name')}
href={hashtag.get('url')}
to={`/tags/${hashtag.get('name')}`}
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
uses={hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1}
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
/>
);
ImmutableHashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired,
};
const Hashtag = ({ name, href, to, people, uses, history, className }) => (
<div className={classNames('trends__item', className)}>
<div className='trends__item__name'>
<Permalink
href={hashtag.get('url')}
to={`/tags/${hashtag.get('name')}`}
>
#<span>{hashtag.get('name')}</span>
<Permalink href={href} to={to}>
{name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
</Permalink>
<ShortNumber
value={
hashtag.getIn(['history', 0, 'accounts']) * 1 +
hashtag.getIn(['history', 1, 'accounts']) * 1
}
renderer={accountsCountRenderer}
/>
{typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
</div>
<div className='trends__item__current'>
<ShortNumber
value={
hashtag.getIn(['history', 0, 'uses']) * 1 +
hashtag.getIn(['history', 1, 'uses']) * 1
}
/>
{typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
</div>
<div className='trends__item__sparkline'>
<SilentErrorBoundary>
<Sparklines
width={50}
height={28}
data={hashtag
.get('history')
.reverse()
.map((day) => day.get('uses'))
.toArray()}
>
<Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
</SilentErrorBoundary>
@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => (
);
Hashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired,
name: PropTypes.string,
href: PropTypes.string,
to: PropTypes.string,
people: PropTypes.number,
uses: PropTypes.number,
history: PropTypes.arrayOf(PropTypes.number),
className: PropTypes.string,
};
export default Hashtag;

View File

@ -0,0 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>&zwnj;</span>;
Skeleton.propTypes = {
width: PropTypes.number,
height: PropTypes.number,
};
export default Skeleton;

View File

@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
export default class AdminComponent extends React.PureComponent {
static propTypes = {
locale: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
};
render () {
const { locale, children } = this.props;
return (
<IntlProvider locale={locale} messages={messages}>
{children}
</IntlProvider>
);
}
}

View File

@ -7,7 +7,7 @@ import { getLocale } from 'mastodon/locales';
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
import MediaGallery from 'mastodon/components/media_gallery';
import Poll from 'mastodon/components/poll';
import Hashtag from 'mastodon/components/hashtag';
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import ModalRoot from 'mastodon/components/modal_root';
import MediaModal from 'mastodon/features/ui/components/media_modal';
import Video from 'mastodon/features/video';

View File

@ -5,7 +5,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Hashtag from '../../../components/hashtag';
import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
import Icon from 'mastodon/components/icon';
import { searchEnabled } from '../../../initial_state';
import LoadMore from 'mastodon/components/load_more';

View File

@ -2,7 +2,7 @@ import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Hashtag from 'mastodon/components/hashtag';
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import { FormattedMessage } from 'react-intl';
export default class Trends extends ImmutablePureComponent {

View File

@ -69,3 +69,11 @@ export function pluralReady(sourceNumber, division) {
return Math.trunc(sourceNumber / closestScale) * closestScale;
}
/**
* @param {number} num
* @returns {number}
*/
export function roundTo10(num) {
return Math.round(num * 0.1) / 0.1;
}

View File

@ -99,4 +99,24 @@ ready(() => {
const registrationMode = document.getElementById('form_admin_settings_registrations_mode');
if (registrationMode) onChangeRegistrationMode(registrationMode);
const React = require('react');
const ReactDOM = require('react-dom');
[].forEach.call(document.querySelectorAll('[data-admin-component]'), element => {
const componentName = element.getAttribute('data-admin-component');
const { locale, ...componentProps } = JSON.parse(element.getAttribute('data-props'));
import('../mastodon/containers/admin_component').then(({ default: AdminComponent }) => {
return import('../mastodon/components/admin/' + componentName).then(({ default: Component }) => {
ReactDOM.render((
<AdminComponent locale={locale}>
<Component {...componentProps} />
</AdminComponent>
), element);
});
}).catch(error => {
console.error(error);
});
});
});

View File

@ -1,3 +1,5 @@
@use "sass:math";
$no-columns-breakpoint: 600px;
$sidebar-width: 240px;
$content-width: 840px;
@ -909,10 +911,197 @@ a.name-tag,
}
}
.dashboard__counters.admin-account-counters {
margin-top: 10px;
}
.account-badges {
margin: -2px 0;
}
.dashboard__counters.admin-account-counters {
margin-top: 10px;
.retention {
&__table {
&__number {
color: $secondary-text-color;
padding: 10px;
}
&__date {
white-space: nowrap;
padding: 10px 0;
text-align: left;
min-width: 120px;
&.retention__table__average {
font-weight: 700;
}
}
&__size {
text-align: center;
padding: 10px;
}
&__label {
font-weight: 700;
color: $darker-text-color;
}
&__box {
box-sizing: border-box;
background: $ui-highlight-color;
padding: 10px;
font-weight: 500;
color: $primary-text-color;
width: 52px;
margin: 1px;
@for $i from 0 through 10 {
&--#{10 * $i} {
background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
}
}
}
}
}
.sparkline {
display: block;
text-decoration: none;
background: lighten($ui-base-color, 4%);
border-radius: 4px;
padding: 0;
position: relative;
padding-bottom: 55px + 20px;
overflow: hidden;
&__value {
display: flex;
line-height: 33px;
align-items: flex-end;
padding: 20px;
padding-bottom: 10px;
&__total {
display: block;
margin-right: 10px;
font-weight: 500;
font-size: 28px;
color: $primary-text-color;
}
&__change {
display: block;
font-weight: 500;
font-size: 18px;
color: $darker-text-color;
margin-bottom: -3px;
&.positive {
color: $valid-value-color;
}
&.negative {
color: $error-value-color;
}
}
}
&__label {
padding: 0 20px;
padding-bottom: 10px;
text-transform: uppercase;
color: $darker-text-color;
font-weight: 500;
}
&__graph {
position: absolute;
bottom: 0;
svg {
display: block;
margin: 0;
}
path:first-child {
fill: rgba($highlight-text-color, 0.25) !important;
fill-opacity: 1 !important;
}
path:last-child {
stroke: lighten($highlight-text-color, 6%) !important;
fill: none !important;
}
}
}
a.sparkline {
&:hover,
&:focus,
&:active {
background: lighten($ui-base-color, 6%);
}
}
.skeleton {
background-color: lighten($ui-base-color, 8%);
background-image: linear-gradient(90deg, lighten($ui-base-color, 8%), lighten($ui-base-color, 12%), lighten($ui-base-color, 8%));
background-size: 200px 100%;
background-repeat: no-repeat;
border-radius: 4px;
display: inline-block;
line-height: 1;
width: 100%;
animation: skeleton 1.2s ease-in-out infinite;
}
@keyframes skeleton {
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
}
.dimension {
table {
width: 100%;
}
&__item {
border-bottom: 1px solid lighten($ui-base-color, 4%);
&__key {
font-weight: 500;
padding: 11px 10px;
}
&__value {
text-align: right;
color: $darker-text-color;
padding: 11px 10px;
}
&__indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: $ui-highlight-color;
margin-right: 10px;
@for $i from 0 through 10 {
&--#{10 * $i} {
background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
}
}
}
&:last-child {
border-bottom: 0;
}
}
}

View File

@ -6955,7 +6955,6 @@ noscript {
&__current {
flex: 0 0 auto;
font-size: 24px;
line-height: 36px;
font-weight: 500;
text-align: right;
padding-right: 15px;
@ -6977,6 +6976,58 @@ noscript {
fill: none !important;
}
}
&--requires-review {
.trends__item__name {
color: $gold-star;
a {
color: $gold-star;
}
}
.trends__item__current {
color: $gold-star;
}
.trends__item__sparkline {
path:first-child {
fill: rgba($gold-star, 0.25) !important;
}
path:last-child {
stroke: lighten($gold-star, 6%) !important;
}
}
}
&--disabled {
.trends__item__name {
color: lighten($ui-base-color, 12%);
a {
color: lighten($ui-base-color, 12%);
}
}
.trends__item__current {
color: lighten($ui-base-color, 12%);
}
.trends__item__sparkline {
path:first-child {
fill: rgba(lighten($ui-base-color, 12%), 0.25) !important;
}
path:last-child {
stroke: lighten(lighten($ui-base-color, 12%), 6%) !important;
}
}
}
}
&--compact &__item {
padding: 10px;
}
}

View File

@ -56,23 +56,56 @@
}
}
.dashboard__widgets {
display: flex;
flex-wrap: wrap;
margin: 0 -5px;
.dashboard {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
grid-gap: 10px;
& > div {
flex: 0 0 33.333%;
margin-bottom: 20px;
&__item {
&--span-double-column {
grid-column: span 2;
}
& > div {
padding: 0 5px;
&--span-double-row {
grid-row: span 2;
}
h4 {
padding-top: 20px;
}
}
a:not(.name-tag) {
color: $ui-secondary-color;
font-weight: 500;
&__quick-access {
display: flex;
align-items: baseline;
border-radius: 4px;
background: $ui-highlight-color;
color: $primary-text-color;
transition: all 100ms ease-in;
font-size: 14px;
padding: 0 16px;
line-height: 36px;
height: 36px;
text-decoration: none;
margin-bottom: 4px;
&:active,
&:focus,
&:hover {
background-color: lighten($ui-highlight-color, 10%);
transition: all 200ms ease-out;
}
span {
flex: 1 1 auto;
}
.fa {
flex: 0 0 auto;
}
strong {
font-weight: 700;
}
}
}

View File

@ -1,29 +1,73 @@
# frozen_string_literal: true
class ActivityTracker
include Redisable
EXPIRE_AFTER = 6.months.seconds
def initialize(prefix, type)
@prefix = prefix
@type = type
end
def add(value = 1, at_time = Time.now.utc)
key = key_at(at_time)
case @type
when :basic
redis.incrby(key, value)
when :unique
redis.pfadd(key, value)
end
redis.expire(key, EXPIRE_AFTER)
end
def get(start_at, end_at = Time.now.utc)
(start_at.to_date...end_at.to_date).map do |date|
key = key_at(date.to_time(:utc))
value = begin
case @type
when :basic
redis.get(key).to_i
when :unique
redis.pfcount(key)
end
end
[date, value]
end
end
def sum(start_at, end_at = Time.now.utc)
keys = (start_at.to_date...end_at.to_date).flat_map { |date| [key_at(date.to_time(:utc)), legacy_key_at(date)] }.uniq
case @type
when :basic
redis.mget(*keys).map(&:to_i).sum
when :unique
redis.pfcount(*keys)
end
end
class << self
include Redisable
def increment(prefix)
key = [prefix, current_week].join(':')
redis.incrby(key, 1)
redis.expire(key, EXPIRE_AFTER)
new(prefix, :basic).add
end
def record(prefix, value)
key = [prefix, current_week].join(':')
redis.pfadd(key, value)
redis.expire(key, EXPIRE_AFTER)
end
private
def current_week
Time.zone.today.cweek
new(prefix, :unique).add(value)
end
end
private
def key_at(at_time)
"#{@prefix}:#{at_time.beginning_of_day.to_i}"
end
def legacy_key_at(at_time)
"#{@prefix}:#{at_time.to_date.cweek}"
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class Admin::Metrics::Dimension
DIMENSIONS = {
languages: Admin::Metrics::Dimension::LanguagesDimension,
sources: Admin::Metrics::Dimension::SourcesDimension,
servers: Admin::Metrics::Dimension::ServersDimension,
space_usage: Admin::Metrics::Dimension::SpaceUsageDimension,
software_versions: Admin::Metrics::Dimension::SoftwareVersionsDimension,
}.freeze
def self.retrieve(dimension_keys, start_at, end_at, limit)
Array(dimension_keys).map { |key| DIMENSIONS[key.to_sym]&.new(start_at, end_at, limit) }.compact
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
class Admin::Metrics::Dimension::BaseDimension
def initialize(start_at, end_at, limit)
@start_at = start_at&.to_datetime
@end_at = end_at&.to_datetime
@limit = limit&.to_i
end
def key
raise NotImplementedError
end
def data
raise NotImplementedError
end
def self.model_name
self.class.name
end
def read_attribute_for_serialization(key)
send(key) if respond_to?(key)
end
protected
def time_period
(@start_at...@end_at)
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension::BaseDimension
def key
'languages'
end
def data
sql = <<-SQL.squish
SELECT locale, count(*) AS value
FROM users
WHERE current_sign_in_at BETWEEN $1 AND $2
AND locale IS NOT NULL
GROUP BY locale
ORDER BY count(*) DESC
LIMIT $3
SQL
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])
rows.map { |row| { key: row['locale'], human_key: SettingsHelper::HUMAN_LOCALES[row['locale'].to_sym], value: row['value'].to_s } }
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Admin::Metrics::Dimension::ServersDimension < Admin::Metrics::Dimension::BaseDimension
def key
'servers'
end
def data
sql = <<-SQL.squish
SELECT accounts.domain, count(*) AS value
FROM statuses
INNER JOIN accounts ON accounts.id = statuses.account_id
WHERE statuses.id BETWEEN $1 AND $2
GROUP BY accounts.domain
ORDER BY count(*) DESC
LIMIT $3
SQL
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, Mastodon::Snowflake.id_at(@start_at)], [nil, Mastodon::Snowflake.id_at(@end_at)], [nil, @limit]])
rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
end
end

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dimension::BaseDimension
include Redisable
def key
'software_versions'
end
def data
[mastodon_version, ruby_version, postgresql_version, redis_version]
end
private
def mastodon_version
value = Mastodon::Version.to_s
{
key: 'mastodon',
human_key: 'Mastodon',
value: value,
human_value: value,
}
end
def ruby_version
value = "#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}"
{
key: 'ruby',
human_key: 'Ruby',
value: value,
human_value: value,
}
end
def postgresql_version
value = ActiveRecord::Base.connection.execute('SELECT VERSION()').first['version'].match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1]
{
key: 'postgresql',
human_key: 'PostgreSQL',
value: value,
human_value: value,
}
end
def redis_version
value = redis_info['redis_version']
{
key: 'redis',
human_key: 'Redis',
value: value,
human_value: value,
}
end
def redis_info
@redis_info ||= begin
if redis.is_a?(Redis::Namespace)
redis.redis.info
else
redis.info
end
end
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Admin::Metrics::Dimension::SourcesDimension < Admin::Metrics::Dimension::BaseDimension
def key
'sources'
end
def data
sql = <<-SQL.squish
SELECT oauth_applications.name, count(*) AS value
FROM users
LEFT JOIN oauth_applications ON oauth_applications.id = users.created_by_application_id
WHERE users.created_at BETWEEN $1 AND $2
GROUP BY oauth_applications.name
ORDER BY count(*) DESC
LIMIT $3
SQL
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])
rows.map { |row| { key: row['name'] || 'web', human_key: row['name'] || I18n.t('admin.dashboard.website'), value: row['value'].to_s } }
end
end

View File

@ -0,0 +1,70 @@
# frozen_string_literal: true
class Admin::Metrics::Dimension::SpaceUsageDimension < Admin::Metrics::Dimension::BaseDimension
include Redisable
include ActionView::Helpers::NumberHelper
def key
'space_usage'
end
def data
[postgresql_size, redis_size, media_size]
end
private
def postgresql_size
value = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size']
{
key: 'postgresql',
human_key: 'PostgreSQL',
value: value.to_s,
unit: 'bytes',
human_value: number_to_human_size(value),
}
end
def redis_size
value = redis_info['used_memory']
{
key: 'redis',
human_key: 'Redis',
value: value.to_s,
unit: 'bytes',
human_value: number_to_human_size(value),
}
end
def media_size
value = [
MediaAttachment.sum(Arel.sql('COALESCE(file_file_size, 0) + COALESCE(thumbnail_file_size, 0)')),
CustomEmoji.sum(:image_file_size),
PreviewCard.sum(:image_file_size),
Account.sum(Arel.sql('COALESCE(avatar_file_size, 0) + COALESCE(header_file_size, 0)')),
Backup.sum(:dump_file_size),
Import.sum(:data_file_size),
SiteUpload.sum(:file_file_size),
].sum
{
key: 'media',
human_key: I18n.t('admin.dashboard.media_storage'),
value: value.to_s,
unit: 'bytes',
human_value: number_to_human_size(value),
}
end
def redis_info
@redis_info ||= begin
if redis.is_a?(Redis::Namespace)
redis.redis.info
else
redis.info
end
end
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class Admin::Metrics::Measure
MEASURES = {
active_users: Admin::Metrics::Measure::ActiveUsersMeasure,
new_users: Admin::Metrics::Measure::NewUsersMeasure,
interactions: Admin::Metrics::Measure::InteractionsMeasure,
opened_reports: Admin::Metrics::Measure::OpenedReportsMeasure,
resolved_reports: Admin::Metrics::Measure::ResolvedReportsMeasure,
}.freeze
def self.retrieve(measure_keys, start_at, end_at)
Array(measure_keys).map { |key| MEASURES[key.to_sym]&.new(start_at, end_at) }.compact
end
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::ActiveUsersMeasure < Admin::Metrics::Measure::BaseMeasure
def key
'active_users'
end
def total
activity_tracker.sum(time_period.first, time_period.last)
end
def previous_total
activity_tracker.sum(previous_time_period.first, previous_time_period.last)
end
def data
activity_tracker.get(time_period.first, time_period.last).map { |date, value| { date: date.to_time(:utc).iso8601, value: value.to_s } }
end
protected
def activity_tracker
@activity_tracker ||= ActivityTracker.new('activity:logins', :unique)
end
def time_period
(@start_at.to_date...@end_at.to_date)
end
def previous_time_period
((@start_at.to_date - length_of_period)...(@end_at.to_date - length_of_period))
end
end

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::BaseMeasure
def initialize(start_at, end_at)
@start_at = start_at&.to_datetime
@end_at = end_at&.to_datetime
end
def key
raise NotImplementedError
end
def total
raise NotImplementedError
end
def previous_total
raise NotImplementedError
end
def data
raise NotImplementedError
end
def self.model_name
self.class.name
end
def read_attribute_for_serialization(key)
send(key) if respond_to?(key)
end
protected
def time_period
(@start_at...@end_at)
end
def previous_time_period
((@start_at - length_of_period)...(@end_at - length_of_period))
end
def length_of_period
@length_of_period ||= @end_at - @start_at
end
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::InteractionsMeasure < Admin::Metrics::Measure::BaseMeasure
def key
'interactions'
end
def total
activity_tracker.sum(time_period.first, time_period.last)
end
def previous_total
activity_tracker.sum(previous_time_period.first, previous_time_period.last)
end
def data
activity_tracker.get(time_period.first, time_period.last).map { |date, value| { date: date.to_time(:utc).iso8601, value: value.to_s } }
end
protected
def activity_tracker
@activity_tracker ||= ActivityTracker.new('activity:interactions', :basic)
end
def time_period
(@start_at.to_date...@end_at.to_date)
end
def previous_time_period
((@start_at.to_date - length_of_period)...(@end_at.to_date - length_of_period))
end
end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::NewUsersMeasure < Admin::Metrics::Measure::BaseMeasure
def key
'new_users'
end
def total
User.where(created_at: time_period).count
end
def previous_total
User.where(created_at: previous_time_period).count
end
def data
sql = <<-SQL.squish
SELECT axis.*, (
WITH new_users AS (
SELECT users.id
FROM users
WHERE date_trunc('day', users.created_at)::date = axis.period
)
SELECT count(*) FROM new_users
) AS value
FROM (
SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
) AS axis
SQL
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]])
rows.map { |row| { date: row['period'], value: row['value'].to_s } }
end
end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::OpenedReportsMeasure < Admin::Metrics::Measure::BaseMeasure
def key
'opened_reports'
end
def total
Report.where(created_at: time_period).count
end
def previous_total
Report.where(created_at: previous_time_period).count
end
def data
sql = <<-SQL.squish
SELECT axis.*, (
WITH new_reports AS (
SELECT reports.id
FROM reports
WHERE date_trunc('day', reports.created_at)::date = axis.period
)
SELECT count(*) FROM new_reports
) AS value
FROM (
SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
) AS axis
SQL
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]])
rows.map { |row| { date: row['period'], value: row['value'].to_s } }
end
end

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
class Admin::Metrics::Measure::ResolvedReportsMeasure < Admin::Metrics::Measure::BaseMeasure
def key
'resolved_reports'
end
def total
Report.resolved.where(updated_at: time_period).count
end
def previous_total
Report.resolved.where(updated_at: previous_time_period).count
end
def data
sql = <<-SQL.squish
SELECT axis.*, (
WITH resolved_reports AS (
SELECT reports.id
FROM reports
WHERE action_taken
AND date_trunc('day', reports.updated_at)::date = axis.period
)
SELECT count(*) FROM resolved_reports
) AS value
FROM (
SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period
) AS axis
SQL
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]])
rows.map { |row| { date: row['period'], value: row['value'].to_s } }
end
end

View File

@ -0,0 +1,67 @@
# frozen_string_literal: true
class Admin::Metrics::Retention
class Cohort < ActiveModelSerializers::Model
attributes :period, :frequency, :data
end
class CohortData < ActiveModelSerializers::Model
attributes :date, :percent, :value
end
def initialize(start_at, end_at, frequency)
@start_at = start_at&.to_date
@end_at = end_at&.to_date
@frequency = %w(day month).include?(frequency) ? frequency : 'day'
end
def cohorts
sql = <<-SQL.squish
SELECT axis.*, (
WITH new_users AS (
SELECT users.id
FROM users
WHERE date_trunc($3, users.created_at)::date = axis.cohort_period
),
retained_users AS (
SELECT users.id
FROM users
INNER JOIN new_users on new_users.id = users.id
WHERE date_trunc($3, users.current_sign_in_at) >= axis.retention_period
)
SELECT ARRAY[count(*), (count(*) + 1)::float / (SELECT count(*) + 1 FROM new_users)] AS retention_value_and_percent
FROM retained_users
)
FROM (
WITH cohort_periods AS (
SELECT generate_series(date_trunc($3, $1::timestamp)::date, date_trunc($3, $2::timestamp)::date, ('1 ' || $3)::interval) AS cohort_period
),
retention_periods AS (
SELECT cohort_period AS retention_period FROM cohort_periods
)
SELECT *
FROM cohort_periods, retention_periods
WHERE retention_period >= cohort_period
) as axis
SQL
rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @frequency]])
rows.each_with_object([]) do |row, arr|
current_cohort = arr.last
if current_cohort.nil? || current_cohort.period != row['cohort_period']
current_cohort = Cohort.new(period: row['cohort_period'], frequency: @frequency, data: [])
arr << current_cohort
end
value, percent = row['retention_value_and_percent'].delete('{}').split(',')
current_cohort.data << CohortData.new(
date: row['retention_period'],
percent: percent.to_f,
value: value.to_s
)
end
end
end

View File

@ -24,8 +24,8 @@ class InstancePresenter
Rails.cache.fetch('user_count') { User.confirmed.joins(:account).merge(Account.without_suspended).count }
end
def active_user_count(weeks = 4)
Rails.cache.fetch("active_user_count/#{weeks}") { Redis.current.pfcount(*(0...weeks).map { |i| "activity:logins:#{i.weeks.ago.utc.to_date.cweek}" }) }
def active_user_count(num_weeks = 4)
Rails.cache.fetch("active_user_count/#{num_weeks}") { ActivityTracker.new('activity:logins', :unique).sum(num_weeks.weeks.ago) }
end
def status_count

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class REST::Admin::CohortSerializer < ActiveModel::Serializer
attributes :period, :frequency
class CohortDataSerializer < ActiveModel::Serializer
attributes :date, :percent, :value
def date
object.date.iso8601
end
end
has_many :data, serializer: CohortDataSerializer
def period
object.period.iso8601
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class REST::Admin::DimensionSerializer < ActiveModel::Serializer
attributes :key, :data
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class REST::Admin::MeasureSerializer < ActiveModel::Serializer
attributes :key, :total, :previous_total, :data
def total
object.total.to_s
end
def previous_total
object.previous_total.to_s
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class REST::Admin::TagSerializer < REST::TagSerializer
attributes :id, :trendable, :usable, :requires_review
def id
object.id.to_s
end
def requires_review
object.requires_review?
end
end

View File

@ -1,6 +1,14 @@
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
- content_for :page_title do
= t('admin.dashboard.title')
- content_for :heading_actions do
= l(@time_period.first)
= ' - '
= l(@time_period.last)
- unless @system_checks.empty?
.flash-message-stack
- @system_checks.each do |message|
@ -9,131 +17,52 @@
- if message.action
= link_to t("admin.system_checks.#{message.key}.action"), message.action
.dashboard__counters
%div
= link_to admin_accounts_url(local: 1, recent: 1) do
.dashboard__counters__num{ title: number_with_delimiter(@users_count, strip_insignificant_zeros: true) }
= friendly_number_to_human @users_count
.dashboard__counters__label= t 'admin.dashboard.total_users'
%div
%div
.dashboard__counters__num{ title: number_with_delimiter(@registrations_week, strip_insignificant_zeros: true) }
= friendly_number_to_human @registrations_week
.dashboard__counters__label= t 'admin.dashboard.week_users_new'
%div
%div
.dashboard__counters__num{ title: number_with_delimiter(@logins_week, strip_insignificant_zeros: true) }
= friendly_number_to_human @logins_week
.dashboard__counters__label= t 'admin.dashboard.week_users_active'
%div
= link_to admin_pending_accounts_path do
.dashboard__counters__num{ title: number_with_delimiter(@pending_users_count, strip_insignificant_zeros: true) }
= friendly_number_to_human @pending_users_count
.dashboard__counters__label= t 'admin.dashboard.pending_users'
%div
= link_to admin_reports_url do
.dashboard__counters__num{ title: number_with_delimiter(@reports_count, strip_insignificant_zeros: true) }
= friendly_number_to_human @reports_count
.dashboard__counters__label= t 'admin.dashboard.open_reports'
%div
= link_to admin_tags_path(pending_review: '1') do
.dashboard__counters__num{ title: number_with_delimiter(@pending_tags_count, strip_insignificant_zeros: true) }
= friendly_number_to_human @pending_tags_count
.dashboard__counters__label= t 'admin.dashboard.pending_tags'
%div
%div
.dashboard__counters__num{ title: number_with_delimiter(@interactions_week, strip_insignificant_zeros: true) }
= friendly_number_to_human @interactions_week
.dashboard__counters__label= t 'admin.dashboard.week_interactions'
%div
= link_to sidekiq_url do
.dashboard__counters__num{ title: number_with_delimiter(@queue_backlog, strip_insignificant_zeros: true) }
= friendly_number_to_human @queue_backlog
.dashboard__counters__label= t 'admin.dashboard.backlog'
.dashboard
.dashboard__item
= react_admin_component :counter, measure: 'new_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.new_users'), href: admin_accounts_path
.dashboard__widgets
.dashboard__widgets__users
%div
%h4= t 'admin.dashboard.recent_users'
%ul
- @recent_users.each do |user|
%li= admin_account_link_to(user.account)
.dashboard__item
= react_admin_component :counter, measure: 'active_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.active_users'), href: admin_accounts_path
.dashboard__widgets__features
%div
%h4= t 'admin.dashboard.features'
%ul
%li
= feature_hint(link_to(t('admin.dashboard.feature_registrations'), edit_admin_settings_path), @registrations_enabled)
%li
= feature_hint(link_to(t('admin.dashboard.feature_invites'), edit_admin_settings_path), @invites_enabled)
%li
= feature_hint(link_to(t('admin.dashboard.feature_deletions'), edit_admin_settings_path), @deletions_enabled)
%li
= feature_hint(link_to(t('admin.dashboard.feature_profile_directory'), edit_admin_settings_path), @profile_directory)
%li
= feature_hint(link_to(t('admin.dashboard.feature_timeline_preview'), edit_admin_settings_path), @timeline_preview)
%li
= feature_hint(link_to(t('admin.dashboard.trends'), edit_admin_settings_path), @trends_enabled)
%li
= feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled)
.dashboard__item
= react_admin_component :counter, measure: 'interactions', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.interactions')
.dashboard__widgets__versions
%div
%h4= t 'admin.dashboard.software'
%ul
%li
Mastodon
%span.pull-right= @version
%li
Ruby
%span.pull-right= "#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}"
%li
PostgreSQL
%span.pull-right= @database_version
%li
Redis
%span.pull-right= @redis_version
.dashboard__item
= react_admin_component :counter, measure: 'opened_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.opened_reports'), href: admin_reports_path
.dashboard__widgets__space
%div
%h4= t 'admin.dashboard.space'
%ul
%li
PostgreSQL
%span.pull-right= number_to_human_size @database_size
%li
Redis
%span.pull-right= number_to_human_size @redis_size
.dashboard__item
= react_admin_component :counter, measure: 'resolved_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.resolved_reports'), href: admin_reports_path(resolved: '1')
.dashboard__widgets__config
%div
%h4= t 'admin.dashboard.config'
%ul
%li
= feature_hint(t('admin.dashboard.search'), @search_enabled)
%li
= feature_hint(t('admin.dashboard.single_user_mode'), @single_user_mode)
%li
= feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch)
%li
= feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_enabled)
%li
= feature_hint('LDAP', @ldap_enabled)
%li
= feature_hint('CAS', @cas_enabled)
%li
= feature_hint('SAML', @saml_enabled)
%li
= feature_hint('PAM', @pam_enabled)
%li
= feature_hint(t('admin.dashboard.hidden_service'), @hidden_service)
.dashboard__item
= link_to admin_reports_path, class: 'dashboard__quick-access' do
%span= t('admin.dashboard.pending_reports_html', count: @pending_reports_count)
= fa_icon 'chevron-right fw'
.dashboard__widgets__trends
%div
%h4= t 'admin.dashboard.trends'
%ul
- @trending_hashtags.each do |tag|
%li
= link_to content_tag(:span, "##{tag.name}", class: !tag.trendable? && !tag.reviewed? ? 'warning-hint' : (!tag.trendable? ? 'negative-hint' : nil)), admin_tag_path(tag.id)
%span.pull-right= number_with_delimiter(tag.history[0][:accounts].to_i)
= link_to admin_pending_accounts_path, class: 'dashboard__quick-access' do
%span= t('admin.dashboard.pending_users_html', count: @pending_users_count)
= fa_icon 'chevron-right fw'
= link_to admin_tags_path(pending_review: '1'), class: 'dashboard__quick-access' do
%span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count)
= fa_icon 'chevron-right fw'
.dashboard__item
= react_admin_component :dimension, dimension: 'sources', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.sources')
.dashboard__item
= react_admin_component :dimension, dimension: 'languages', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_languages')
.dashboard__item
= react_admin_component :dimension, dimension: 'servers', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_servers')
.dashboard__item.dashboard__item--span-double-column
= react_admin_component :retention, start_at: @time_period.last - 6.months, end_at: @time_period.last, frequency: 'month'
.dashboard__item.dashboard__item--span-double-row
= react_admin_component :trends, limit: 7
.dashboard__item
= react_admin_component :dimension, dimension: 'software_versions', start_at: @time_period.first, end_at: @time_period.last, limit: 4, label: t('admin.dashboard.software')
.dashboard__item
= react_admin_component :dimension, dimension: 'space_usage', start_at: @time_period.first, end_at: @time_period.last, limit: 3, label: t('admin.dashboard.space')

View File

@ -371,32 +371,28 @@ en:
updated_msg: Emoji successfully updated!
upload: Upload
dashboard:
authorized_fetch_mode: Secure mode
backlog: backlogged jobs
config: Configuration
feature_deletions: Account deletions
feature_invites: Invite links
feature_profile_directory: Profile directory
feature_registrations: Registrations
feature_relay: Federation relay
feature_timeline_preview: Timeline preview
features: Features
hidden_service: Federation with hidden services
open_reports: open reports
pending_tags: hashtags waiting for review
pending_users: users waiting for review
recent_users: Recent users
search: Full-text search
single_user_mode: Single user mode
active_users: active users
interactions: interactions
media_storage: Media storage
new_users: new users
opened_reports: reports opened
pending_reports_html:
one: "<strong>1</strong> pending reports"
other: "<strong>%{count}</strong> pending reports"
pending_tags_html:
one: "<strong>1</strong> pending hashtags"
other: "<strong>%{count}</strong> pending hashtags"
pending_users_html:
one: "<strong>1</strong> pending users"
other: "<strong>%{count}</strong> pending users"
resolved_reports: reports resolved
software: Software
sources: Sign-up sources
space: Space usage
title: Dashboard
total_users: users in total
trends: Trends
week_interactions: interactions this week
week_users_active: active this week
week_users_new: users this week
whitelist_mode: Limited federation mode
top_languages: Top active languages
top_servers: Top active servers
website: Website
domain_allows:
add_new: Allow federation with domain
created_msg: Domain has been successfully allowed for federation

View File

@ -510,6 +510,12 @@ Rails.application.routes.draw do
post :resolve
end
end
resources :trends, only: [:index]
post :measures, to: 'measures#create'
post :dimensions, to: 'dimensions#create'
post :retention, to: 'retention#create'
end
end