Merge branch 'main' into glitch-soc/merge-upstream

Conflicts:
- `app/controllers/admin/dashboard_controller.rb`:
  Upstream completely redesigned the admin dashboard.
  glitch-soc tracked extra features, but that list is
  gone.
  Followed upstram.
- `app/views/admin/dashboard/index.html.haml`
  Upstream completely redesigned the admin dashboard.
  glitch-soc tracked extra features, but that list is
  gone.
  Followed upstram.
pull/1622/head
Claire 2021-10-14 20:55:16 +02:00
commit 694c073d1f
52 changed files with 1690 additions and 290 deletions

View File

@ -424,7 +424,7 @@ GEM
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.6) public_suffix (4.0.6)
puma (5.5.0) puma (5.5.1)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.1.1) pundit (2.1.1)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)

View File

@ -1,50 +1,17 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'sidekiq/api'
module Admin module Admin
class DashboardController < BaseController class DashboardController < BaseController
def index def index
@system_checks = Admin::SystemCheck.perform @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 @pending_users_count = User.pending.count
@registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0 @pending_reports_count = Report.unresolved.count
@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_tags_count = Tag.pending_review.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
@keybase_integration = Setting.enable_keybase
@trends_enabled = Setting.trends
end end
private private
def current_week
@current_week ||= Time.now.utc.to_date.cweek
end
def redis_info def redis_info
@redis_info ||= begin @redis_info ||= begin
if Redis.current.is_a?(Redis::Namespace) 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 private
def activity 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| (0...12).map do |i|
day = i.weeks.ago.to_date start_of_week = i.weeks.ago
week_id = day.cweek end_of_week = start_of_week + 6.days
week = Date.commercial(day.cwyear, week_id)
weeks << { {
week: week.to_time.to_i.to_s, week: start_of_week.to_i.to_s,
statuses: Redis.current.get("activity:statuses:local:#{week_id}") || '0', statuses: statuses_tracker.sum(start_of_week, end_of_week).to_s,
logins: Redis.current.pfcount("activity:logins:#{week_id}").to_s, logins: logins_tracker.sum(start_of_week, end_of_week).to_s,
registrations: Redis.current.get("activity:accounts:local:#{week_id}") || '0', registrations: registrations_tracker.sum(start_of_week, end_of_week).to_s,
} }
end end
weeks
end end
def require_enabled_api! def require_enabled_api!

View File

@ -137,6 +137,10 @@ module ApplicationHelper
end end
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 def body_classes
output = (@body_classes || '').split(' ') output = (@body_classes || '').split(' ')
output << "flavour-#{current_flavour.parameterize}" output << "flavour-#{current_flavour.parameterize}"

View File

@ -101,4 +101,24 @@ ready(() => {
const registrationMode = document.getElementById('form_admin_settings_registrations_mode'); const registrationMode = document.getElementById('form_admin_settings_registrations_mode');
if (registrationMode) onChangeRegistrationMode(registrationMode); 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

@ -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 ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from './permalink'; import Permalink from './permalink';
import ShortNumber from 'mastodon/components/short_number'; import ShortNumber from 'mastodon/components/short_number';
import Skeleton from 'mastodon/components/skeleton';
import classNames from 'classnames';
class SilentErrorBoundary extends React.Component { class SilentErrorBoundary extends React.Component {
@ -47,45 +49,38 @@ const accountsCountRenderer = (displayNumber, pluralReady) => (
/> />
); );
const Hashtag = ({ hashtag }) => ( export const ImmutableHashtag = ({ hashtag }) => (
<div className='trends__item'> <Hashtag
<div className='trends__item__name'> name={hashtag.get('name')}
<Permalink
href={hashtag.get('url')} href={hashtag.get('url')}
to={`/tags/${hashtag.get('name')}`} to={`/tags/${hashtag.get('name')}`}
> people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
#<span>{hashtag.get('name')}</span> 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={href} to={to}>
{name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
</Permalink> </Permalink>
<ShortNumber {typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
value={
hashtag.getIn(['history', 0, 'accounts']) * 1 +
hashtag.getIn(['history', 1, 'accounts']) * 1
}
renderer={accountsCountRenderer}
/>
</div> </div>
<div className='trends__item__current'> <div className='trends__item__current'>
<ShortNumber {typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
value={
hashtag.getIn(['history', 0, 'uses']) * 1 +
hashtag.getIn(['history', 1, 'uses']) * 1
}
/>
</div> </div>
<div className='trends__item__sparkline'> <div className='trends__item__sparkline'>
<SilentErrorBoundary> <SilentErrorBoundary>
<Sparklines <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
width={50}
height={28}
data={hashtag
.get('history')
.reverse()
.map((day) => day.get('uses'))
.toArray()}
>
<SparklinesCurve style={{ fill: 'none' }} /> <SparklinesCurve style={{ fill: 'none' }} />
</Sparklines> </Sparklines>
</SilentErrorBoundary> </SilentErrorBoundary>
@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => (
); );
Hashtag.propTypes = { 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; 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 { getScrollbarWidth } from 'mastodon/utils/scrollbar';
import MediaGallery from 'mastodon/components/media_gallery'; import MediaGallery from 'mastodon/components/media_gallery';
import Poll from 'mastodon/components/poll'; 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 ModalRoot from 'mastodon/components/modal_root';
import MediaModal from 'mastodon/features/ui/components/media_modal'; import MediaModal from 'mastodon/features/ui/components/media_modal';
import Video from 'mastodon/features/video'; 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 AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container'; import StatusContainer from '../../../containers/status_container';
import ImmutablePureComponent from 'react-immutable-pure-component'; 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 Icon from 'mastodon/components/icon';
import { searchEnabled } from '../../../initial_state'; import { searchEnabled } from '../../../initial_state';
import LoadMore from 'mastodon/components/load_more'; 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 ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; 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'; import { FormattedMessage } from 'react-intl';
export default class Trends extends ImmutablePureComponent { export default class Trends extends ImmutablePureComponent {

View File

@ -69,3 +69,11 @@ export function pluralReady(sourceNumber, division) {
return Math.trunc(sourceNumber / closestScale) * closestScale; 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

@ -1,3 +1,5 @@
@use "sass:math";
$no-columns-breakpoint: 600px; $no-columns-breakpoint: 600px;
$sidebar-width: 240px; $sidebar-width: 240px;
$content-width: 840px; $content-width: 840px;
@ -925,10 +927,197 @@ a.name-tag,
} }
} }
.dashboard__counters.admin-account-counters {
margin-top: 10px;
}
.account-badges { .account-badges {
margin: -2px 0; margin: -2px 0;
} }
.dashboard__counters.admin-account-counters { .retention {
margin-top: 10px; &__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 { &__current {
flex: 0 0 auto; flex: 0 0 auto;
font-size: 24px; font-size: 24px;
line-height: 36px;
font-weight: 500; font-weight: 500;
text-align: right; text-align: right;
padding-right: 15px; padding-right: 15px;
@ -6977,6 +6976,58 @@ noscript {
fill: none !important; 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 { .dashboard {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
grid-gap: 10px;
&__item {
&--span-double-column {
grid-column: span 2;
}
&--span-double-row {
grid-row: span 2;
}
h4 {
padding-top: 20px;
}
}
&__quick-access {
display: flex; display: flex;
flex-wrap: wrap; align-items: baseline;
margin: 0 -5px; border-radius: 4px;
background: $ui-highlight-color;
& > div { color: $primary-text-color;
flex: 0 0 33.333%; transition: all 100ms ease-in;
margin-bottom: 20px; font-size: 14px;
padding: 0 16px;
& > div { line-height: 36px;
padding: 0 5px; height: 36px;
}
}
a:not(.name-tag) {
color: $ui-secondary-color;
font-weight: 500;
text-decoration: none; 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 # frozen_string_literal: true
class ActivityTracker class ActivityTracker
EXPIRE_AFTER = 6.months.seconds
class << self
include Redisable include Redisable
def increment(prefix) EXPIRE_AFTER = 6.months.seconds
key = [prefix, current_week].join(':')
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.incrby(key, 1)
redis.expire(key, EXPIRE_AFTER) redis.expire(key, EXPIRE_AFTER)
end end
def record(prefix, value) def get(start_at, end_at = Time.now.utc)
key = [prefix, current_week].join(':') (start_at.to_date...end_at.to_date).map do |date|
key = key_at(date.to_time(:utc))
redis.pfadd(key, value) value = begin
redis.expire(key, EXPIRE_AFTER) 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
def increment(prefix)
new(prefix, :basic).add
end
def record(prefix, value)
new(prefix, :unique).add(value)
end
end end
private private
def current_week def key_at(at_time)
Time.zone.today.cweek "#{@prefix}:#{at_time.beginning_of_day.to_i}"
end end
def legacy_key_at(at_time)
"#{@prefix}:#{at_time.to_date.cweek}"
end end
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

@ -76,7 +76,7 @@ class Admin::ActionLogFilter
when 'account_id' when 'account_id'
Admin::ActionLog.where(account_id: value) Admin::ActionLog.where(account_id: value)
when 'target_account_id' when 'target_account_id'
account = Account.find(value) account = Account.find_or_initialize_by(id: value)
Admin::ActionLog.where(target: [account, account.user].compact) Admin::ActionLog.where(target: [account, account.user].compact)
else else
raise "Unknown filter: #{key}" raise "Unknown filter: #{key}"

View File

@ -494,7 +494,7 @@ class Status < ApplicationRecord
end end
def decrement_counter_caches def decrement_counter_caches
return if direct_visibility? return if direct_visibility? || new_record?
account&.decrement_count!(:statuses_count) account&.decrement_count!(:statuses_count)
reblog&.decrement_count!(:reblogs_count) if reblog? reblog&.decrement_count!(:reblogs_count) if reblog?

View File

@ -24,8 +24,8 @@ class InstancePresenter
Rails.cache.fetch('user_count') { User.confirmed.joins(:account).merge(Account.without_suspended).count } Rails.cache.fetch('user_count') { User.confirmed.joins(:account).merge(Account.without_suspended).count }
end end
def active_user_count(weeks = 4) def active_user_count(num_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}" }) } Rails.cache.fetch("active_user_count/#{num_weeks}") { ActivityTracker.new('activity:logins', :unique).sum(num_weeks.weeks.ago) }
end end
def status_count 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

@ -83,6 +83,9 @@ class PostStatusService < BaseService
status_for_validation = @account.statuses.build(status_attributes) status_for_validation = @account.statuses.build(status_attributes)
if status_for_validation.valid? if status_for_validation.valid?
# Marking the status as destroyed is necessary to prevent the status from being
# persisted when the associated media attachments get updated when creating the
# scheduled status.
status_for_validation.destroy status_for_validation.destroy
# The following transaction block is needed to wrap the UPDATEs to # The following transaction block is needed to wrap the UPDATEs to

View File

@ -1,6 +1,11 @@
- content_for :page_title do - content_for :page_title do
= t('admin.dashboard.title') = t('admin.dashboard.title')
- content_for :heading_actions do
= l(@time_period.first)
= ' - '
= l(@time_period.last)
- unless @system_checks.empty? - unless @system_checks.empty?
.flash-message-stack .flash-message-stack
- @system_checks.each do |message| - @system_checks.each do |message|
@ -9,133 +14,52 @@
- if message.action - if message.action
= link_to t("admin.system_checks.#{message.key}.action"), message.action = link_to t("admin.system_checks.#{message.key}.action"), message.action
.dashboard__counters .dashboard
%div .dashboard__item
= link_to admin_accounts_url(local: 1, recent: 1) do = 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__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__widgets .dashboard__item
.dashboard__widgets__users = 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
%div
%h4= t 'admin.dashboard.recent_users'
%ul
- @recent_users.each do |user|
%li= admin_account_link_to(user.account)
.dashboard__widgets__features .dashboard__item
%div = react_admin_component :counter, measure: 'interactions', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.interactions')
%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.keybase'), edit_admin_settings_path), @keybase_integration)
%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__widgets__versions .dashboard__item
%div = 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
%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__widgets__space .dashboard__item
%div = 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')
%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__widgets__config .dashboard__item
%div = link_to admin_reports_path, class: 'dashboard__quick-access' do
%h4= t 'admin.dashboard.config' %span= t('admin.dashboard.pending_reports_html', count: @pending_reports_count)
%ul = fa_icon 'chevron-right fw'
%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__widgets__trends = link_to admin_pending_accounts_path, class: 'dashboard__quick-access' do
%div %span= t('admin.dashboard.pending_users_html', count: @pending_users_count)
%h4= t 'admin.dashboard.trends' = fa_icon 'chevron-right fw'
%ul
- @trending_hashtags.each do |tag| = link_to admin_tags_path(pending_review: '1'), class: 'dashboard__quick-access' do
%li %span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count)
= link_to content_tag(:span, "##{tag.name}", class: !tag.trendable? && !tag.reviewed? ? 'warning-hint' : (!tag.trendable? ? 'negative-hint' : nil)), admin_tag_path(tag.id) = fa_icon 'chevron-right fw'
%span.pull-right= number_with_delimiter(tag.history[0][:accounts].to_i)
.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! updated_msg: Emoji successfully updated!
upload: Upload upload: Upload
dashboard: dashboard:
authorized_fetch_mode: Secure mode active_users: active users
backlog: backlogged jobs interactions: interactions
config: Configuration media_storage: Media storage
feature_deletions: Account deletions new_users: new users
feature_invites: Invite links opened_reports: reports opened
feature_profile_directory: Profile directory pending_reports_html:
feature_registrations: Registrations one: "<strong>1</strong> pending reports"
feature_relay: Federation relay other: "<strong>%{count}</strong> pending reports"
feature_timeline_preview: Timeline preview pending_tags_html:
features: Features one: "<strong>1</strong> pending hashtags"
hidden_service: Federation with hidden services other: "<strong>%{count}</strong> pending hashtags"
open_reports: open reports pending_users_html:
pending_tags: hashtags waiting for review one: "<strong>1</strong> pending users"
pending_users: users waiting for review other: "<strong>%{count}</strong> pending users"
recent_users: Recent users resolved_reports: reports resolved
search: Full-text search
single_user_mode: Single user mode
software: Software software: Software
sources: Sign-up sources
space: Space usage space: Space usage
title: Dashboard title: Dashboard
total_users: users in total top_languages: Top active languages
trends: Trends top_servers: Top active servers
week_interactions: interactions this week website: Website
week_users_active: active this week
week_users_new: users this week
whitelist_mode: Limited federation mode
domain_allows: domain_allows:
add_new: Allow federation with domain add_new: Allow federation with domain
created_msg: Domain has been successfully allowed for federation created_msg: Domain has been successfully allowed for federation

View File

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

View File

@ -94,17 +94,22 @@ module Mastodon
exit(1) unless prompt.ask('Type in the domain of the server to confirm:', required: true) == Rails.configuration.x.local_domain exit(1) unless prompt.ask('Type in the domain of the server to confirm:', required: true) == Rails.configuration.x.local_domain
unless options[:dry_run]
prompt.warn('This operation WILL NOT be reversible. It can also take a long time.') prompt.warn('This operation WILL NOT be reversible. It can also take a long time.')
prompt.warn('While the data won\'t be erased locally, the server will be in a BROKEN STATE afterwards.') prompt.warn('While the data won\'t be erased locally, the server will be in a BROKEN STATE afterwards.')
prompt.warn('A running Sidekiq process is required. Do not shut it down until queues clear.') prompt.warn('A running Sidekiq process is required. Do not shut it down until queues clear.')
exit(1) if prompt.no?('Are you sure you want to proceed?') exit(1) if prompt.no?('Are you sure you want to proceed?')
end
inboxes = Account.inboxes inboxes = Account.inboxes
processed = 0 processed = 0
dry_run = options[:dry_run] ? ' (DRY RUN)' : '' dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
Setting.registrations_mode = 'none' unless options[:dry_run]
if inboxes.empty? if inboxes.empty?
Account.local.without_suspended.in_batches.update_all(suspended_at: Time.now.utc, suspension_origin: :local) unless options[:dry_run]
prompt.ok('It seems like your server has not federated with anything') prompt.ok('It seems like your server has not federated with anything')
prompt.ok('You can shut it down and delete it any time') prompt.ok('You can shut it down and delete it any time')
return return
@ -112,9 +117,7 @@ module Mastodon
prompt.warn('Do NOT interrupt this process...') prompt.warn('Do NOT interrupt this process...')
Setting.registrations_mode = 'none' delete_account = ->(account) do
Account.local.without_suspended.find_each do |account|
payload = ActiveModelSerializers::SerializableResource.new( payload = ActiveModelSerializers::SerializableResource.new(
account, account,
serializer: ActivityPub::DeleteActorSerializer, serializer: ActivityPub::DeleteActorSerializer,
@ -128,12 +131,15 @@ module Mastodon
[json, account.id, inbox_url] [json, account.id, inbox_url]
end end
account.suspend! account.suspend!(block_email: false)
end end
processed += 1 processed += 1
end end
Account.local.without_suspended.find_each { |account| delete_account.call(account) }
Account.local.suspended.joins(:deletion_request).find_each { |account| delete_account.call(account) }
prompt.ok("Queued #{inboxes.size * processed} items into Sidekiq for #{processed} accounts#{dry_run}") prompt.ok("Queued #{inboxes.size * processed} items into Sidekiq for #{processed} accounts#{dry_run}")
prompt.ok('Wait until Sidekiq processes all items, then you can shut everything down and delete the data') prompt.ok('Wait until Sidekiq processes all items, then you can shut everything down and delete the data')
rescue TTY::Reader::InputInterrupt rescue TTY::Reader::InputInterrupt

View File

@ -25,31 +25,35 @@ RSpec.describe PostStatusService, type: :service do
expect(status.thread).to eq in_reply_to_status expect(status.thread).to eq in_reply_to_status
end end
context 'when scheduling a status' do
let!(:account) { Fabricate(:account) }
let!(:future) { Time.now.utc + 2.hours }
let!(:previous_status) { Fabricate(:status, account: account) }
it 'schedules a status' do it 'schedules a status' do
account = Fabricate(:account)
future = Time.now.utc + 2.hours
status = subject.call(account, text: 'Hi future!', scheduled_at: future) status = subject.call(account, text: 'Hi future!', scheduled_at: future)
expect(status).to be_a ScheduledStatus expect(status).to be_a ScheduledStatus
expect(status.scheduled_at).to eq future expect(status.scheduled_at).to eq future
expect(status.params['text']).to eq 'Hi future!' expect(status.params['text']).to eq 'Hi future!'
end end
it 'does not immediately create a status when scheduling a status' do it 'does not immediately create a status' do
account = Fabricate(:account) media = Fabricate(:media_attachment, account: account)
media = Fabricate(:media_attachment)
future = Time.now.utc + 2.hours
status = subject.call(account, text: 'Hi future!', media_ids: [media.id], scheduled_at: future) status = subject.call(account, text: 'Hi future!', media_ids: [media.id], scheduled_at: future)
expect(status).to be_a ScheduledStatus expect(status).to be_a ScheduledStatus
expect(status.scheduled_at).to eq future expect(status.scheduled_at).to eq future
expect(status.params['text']).to eq 'Hi future!' expect(status.params['text']).to eq 'Hi future!'
expect(status.params['media_ids']).to eq [media.id]
expect(media.reload.status).to be_nil expect(media.reload.status).to be_nil
expect(Status.where(text: 'Hi future!').exists?).to be_falsey expect(Status.where(text: 'Hi future!').exists?).to be_falsey
end end
it 'does not change statuses count' do
expect { subject.call(account, text: 'Hi future!', scheduled_at: future, thread: previous_status) }.not_to change { [account.statuses_count, previous_status.replies_count] }
end
end
it 'creates response to the original status of boost' do it 'creates response to the original status of boost' do
boosted_status = Fabricate(:status) boosted_status = Fabricate(:status)
in_reply_to_status = Fabricate(:status, reblog: boosted_status) in_reply_to_status = Fabricate(:status, reblog: boosted_status)