Merge pull request #1622 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes
This commit is contained in:
commit
b6f24ef0fb
@ -56,6 +56,7 @@ COPY Gemfile* package.json yarn.lock /opt/mastodon/
|
||||
RUN cd /opt/mastodon && \
|
||||
bundle config set deployment 'true' && \
|
||||
bundle config set without 'development test' && \
|
||||
bundle config set silence_root_warning true && \
|
||||
bundle install -j"$(nproc)" && \
|
||||
yarn install --pure-lockfile
|
||||
|
||||
|
14
Gemfile.lock
14
Gemfile.lock
@ -188,7 +188,7 @@ GEM
|
||||
docile (1.3.4)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
doorkeeper (5.5.3)
|
||||
doorkeeper (5.5.4)
|
||||
railties (>= 5)
|
||||
dotenv (2.7.6)
|
||||
dotenv-rails (2.7.6)
|
||||
@ -262,7 +262,7 @@ GEM
|
||||
hiredis (0.6.3)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.0.2)
|
||||
http (5.0.4)
|
||||
addressable (~> 2.8)
|
||||
http-cookie (~> 1.0)
|
||||
http-form_data (~> 2.2)
|
||||
@ -326,7 +326,7 @@ GEM
|
||||
addressable (~> 2.7)
|
||||
letter_opener (1.7.0)
|
||||
launchy (~> 2.2)
|
||||
letter_opener_web (1.4.0)
|
||||
letter_opener_web (1.4.1)
|
||||
actionmailer (>= 3.2)
|
||||
letter_opener (~> 1.0)
|
||||
railties (>= 3.2)
|
||||
@ -357,7 +357,7 @@ GEM
|
||||
mime-types (3.3.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2020.0512)
|
||||
mini_mime (1.1.1)
|
||||
mini_mime (1.1.2)
|
||||
mini_portile2 (2.6.1)
|
||||
minitest (5.14.4)
|
||||
msgpack (1.4.2)
|
||||
@ -424,7 +424,7 @@ GEM
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (4.0.6)
|
||||
puma (5.5.0)
|
||||
puma (5.5.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.1.1)
|
||||
activesupport (>= 3.0.0)
|
||||
@ -531,7 +531,7 @@ GEM
|
||||
unicode-display_width (>= 1.4.0, < 3.0)
|
||||
rubocop-ast (1.12.0)
|
||||
parser (>= 3.0.1.1)
|
||||
rubocop-rails (2.12.2)
|
||||
rubocop-rails (2.12.3)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.7.0, < 2.0)
|
||||
@ -627,7 +627,7 @@ GEM
|
||||
tzinfo (>= 1.0.0)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.7.7)
|
||||
unf_ext (0.0.8)
|
||||
unicode-display_width (1.8.0)
|
||||
uniform_notifier (1.14.2)
|
||||
warden (1.2.9)
|
||||
|
12
Vagrantfile
vendored
12
Vagrantfile
vendored
@ -45,16 +45,8 @@ sudo apt-get install \
|
||||
# Install rvm
|
||||
read RUBY_VERSION < .ruby-version
|
||||
|
||||
gpg_command="gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB"
|
||||
$($gpg_command)
|
||||
if [ $? -ne 0 ];then
|
||||
echo "GPG command failed, This prevented RVM from installing."
|
||||
echo "Retrying once..." && $($gpg_command)
|
||||
if [ $? -ne 0 ];then
|
||||
echo "GPG failed for the second time, please ensure network connectivity."
|
||||
echo "Exiting..." && exit 1
|
||||
fi
|
||||
fi
|
||||
curl -sSL https://rvm.io/mpapis.asc | gpg --import
|
||||
curl -sSL https://rvm.io/pkuczynski.asc | gpg --import
|
||||
|
||||
curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-installer | bash -s stable --ruby=$RUBY_VERSION
|
||||
source /home/vagrant/.rvm/scripts/rvm
|
||||
|
@ -1,50 +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
|
||||
@keybase_integration = Setting.enable_keybase
|
||||
@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)
|
||||
|
23
app/controllers/api/v1/admin/dimensions_controller.rb
Normal file
23
app/controllers/api/v1/admin/dimensions_controller.rb
Normal 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
|
22
app/controllers/api/v1/admin/measures_controller.rb
Normal file
22
app/controllers/api/v1/admin/measures_controller.rb
Normal 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
|
22
app/controllers/api/v1/admin/retention_controller.rb
Normal file
22
app/controllers/api/v1/admin/retention_controller.rb
Normal 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
|
16
app/controllers/api/v1/admin/trends_controller.rb
Normal file
16
app/controllers/api/v1/admin/trends_controller.rb
Normal 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
|
@ -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!
|
||||
|
@ -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 << "flavour-#{current_flavour.parameterize}"
|
||||
|
@ -41,6 +41,7 @@ module SettingsHelper
|
||||
ka: 'ქართული',
|
||||
kab: 'Taqbaylit',
|
||||
kk: 'Қазақша',
|
||||
kmr: 'Kurmancî',
|
||||
kn: 'ಕನ್ನಡ',
|
||||
ko: '한국어',
|
||||
ku: 'سۆرانی',
|
||||
|
115
app/javascript/flavours/glitch/components/admin/Counter.js
Normal file
115
app/javascript/flavours/glitch/components/admin/Counter.js
Normal file
@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import api from 'flavours/glitch/util/api';
|
||||
import { FormattedNumber } from 'react-intl';
|
||||
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||
import classNames from 'classnames';
|
||||
import Skeleton from 'flavours/glitch/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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
92
app/javascript/flavours/glitch/components/admin/Dimension.js
Normal file
92
app/javascript/flavours/glitch/components/admin/Dimension.js
Normal file
@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import api from 'flavours/glitch/util/api';
|
||||
import { FormattedNumber } from 'react-intl';
|
||||
import { roundTo10 } from 'flavours/glitch/util/numbers';
|
||||
import Skeleton from 'flavours/glitch/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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
141
app/javascript/flavours/glitch/components/admin/Retention.js
Normal file
141
app/javascript/flavours/glitch/components/admin/Retention.js
Normal file
@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import api from 'flavours/glitch/util/api';
|
||||
import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import { roundTo10 } from 'flavours/glitch/util/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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
73
app/javascript/flavours/glitch/components/admin/Trends.js
Normal file
73
app/javascript/flavours/glitch/components/admin/Trends.js
Normal file
@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import api from 'flavours/glitch/util/api';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import Hashtag from 'flavours/glitch/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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -6,6 +6,8 @@ import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Permalink from './permalink';
|
||||
import ShortNumber from 'flavours/glitch/components/short_number';
|
||||
import Skeleton from 'flavours/glitch/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;
|
||||
|
11
app/javascript/flavours/glitch/components/skeleton.js
Normal file
11
app/javascript/flavours/glitch/components/skeleton.js
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>‌</span>;
|
||||
|
||||
Skeleton.propTypes = {
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
};
|
||||
|
||||
export default Skeleton;
|
26
app/javascript/flavours/glitch/containers/admin_component.js
Normal file
26
app/javascript/flavours/glitch/containers/admin_component.js
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||
import { getLocale } from 'mastodon/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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -7,7 +7,7 @@ import { getLocale } from 'mastodon/locales';
|
||||
import { getScrollbarWidth } from 'flavours/glitch/util/scrollbar';
|
||||
import MediaGallery from 'flavours/glitch/components/media_gallery';
|
||||
import Poll from 'flavours/glitch/components/poll';
|
||||
import Hashtag from 'flavours/glitch/components/hashtag';
|
||||
import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
|
||||
import ModalRoot from 'flavours/glitch/components/modal_root';
|
||||
import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
|
||||
import Video from 'flavours/glitch/features/video';
|
||||
|
@ -5,7 +5,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import AccountContainer from 'flavours/glitch/containers/account_container';
|
||||
import StatusContainer from 'flavours/glitch/containers/status_container';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Hashtag from 'flavours/glitch/components/hashtag';
|
||||
import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
|
||||
import Icon from 'flavours/glitch/components/icon';
|
||||
import { searchEnabled } from 'flavours/glitch/util/initial_state';
|
||||
import LoadMore from 'flavours/glitch/components/load_more';
|
||||
|
@ -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 'flavours/glitch/components/hashtag';
|
||||
import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default class Trends extends ImmutablePureComponent {
|
||||
|
24
app/javascript/flavours/glitch/packs/admin.js
Normal file
24
app/javascript/flavours/glitch/packs/admin.js
Normal file
@ -0,0 +1,24 @@
|
||||
import 'packs/public-path';
|
||||
import ready from 'flavours/glitch/util/ready';
|
||||
|
||||
ready(() => {
|
||||
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('flavours/glitch/containers/admin_component').then(({ default: AdminComponent }) => {
|
||||
return import('flavours/glitch/components/admin/' + componentName).then(({ default: Component }) => {
|
||||
ReactDOM.render((
|
||||
<AdminComponent locale={locale}>
|
||||
<Component {...componentProps} />
|
||||
</AdminComponent>
|
||||
), element);
|
||||
});
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,3 +1,5 @@
|
||||
@use "sass:math";
|
||||
|
||||
$no-columns-breakpoint: 600px;
|
||||
$sidebar-width: 240px;
|
||||
$content-width: 840px;
|
||||
@ -925,10 +927,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -171,7 +171,6 @@
|
||||
&__current {
|
||||
flex: 0 0 auto;
|
||||
font-size: 24px;
|
||||
line-height: 36px;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
padding-right: 15px;
|
||||
@ -193,5 +192,57 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
# (REQUIRED) The location of the pack files.
|
||||
pack:
|
||||
about: packs/about.js
|
||||
admin: packs/public.js
|
||||
admin: packs/admin.js
|
||||
auth: packs/public.js
|
||||
common:
|
||||
filename: packs/common.js
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
# (REQUIRED) The location of the pack files inside `pack_directory`.
|
||||
pack:
|
||||
about: about.js
|
||||
admin: public.js
|
||||
admin: admin.js
|
||||
auth: public.js
|
||||
common:
|
||||
filename: common.js
|
||||
|
115
app/javascript/mastodon/components/admin/Counter.js
Normal file
115
app/javascript/mastodon/components/admin/Counter.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
92
app/javascript/mastodon/components/admin/Dimension.js
Normal file
92
app/javascript/mastodon/components/admin/Dimension.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
141
app/javascript/mastodon/components/admin/Retention.js
Normal file
141
app/javascript/mastodon/components/admin/Retention.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
73
app/javascript/mastodon/components/admin/Trends.js
Normal file
73
app/javascript/mastodon/components/admin/Trends.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
|
11
app/javascript/mastodon/components/skeleton.js
Normal file
11
app/javascript/mastodon/components/skeleton.js
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>‌</span>;
|
||||
|
||||
Skeleton.propTypes = {
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
};
|
||||
|
||||
export default Skeleton;
|
26
app/javascript/mastodon/containers/admin_component.js
Normal file
26
app/javascript/mastodon/containers/admin_component.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
|
24
app/javascript/packs/admin.js
Normal file
24
app/javascript/packs/admin.js
Normal file
@ -0,0 +1,24 @@
|
||||
import './public-path';
|
||||
import ready from '../mastodon/ready';
|
||||
|
||||
ready(() => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
@ -5,6 +5,7 @@
|
||||
url('~fonts/montserrat/Montserrat-Regular.woff') format('woff'),
|
||||
url('~fonts/montserrat/Montserrat-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@ -13,5 +14,6 @@
|
||||
src: local('Montserrat Medium'),
|
||||
url('~fonts/montserrat/Montserrat-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
}
|
||||
|
@ -6,5 +6,6 @@
|
||||
url('~fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'),
|
||||
url('~fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular') format('svg');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
}
|
||||
|
@ -6,6 +6,7 @@
|
||||
url('~fonts/roboto/roboto-italic-webfont.ttf') format('truetype'),
|
||||
url('~fonts/roboto/roboto-italic-webfont.svg#roboto-italic-webfont') format('svg');
|
||||
font-weight: normal;
|
||||
font-display: swap;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@ -17,6 +18,7 @@
|
||||
url('~fonts/roboto/roboto-bold-webfont.ttf') format('truetype'),
|
||||
url('~fonts/roboto/roboto-bold-webfont.svg#roboto-bold-webfont') format('svg');
|
||||
font-weight: bold;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@ -28,6 +30,7 @@
|
||||
url('~fonts/roboto/roboto-medium-webfont.ttf') format('truetype'),
|
||||
url('~fonts/roboto/roboto-medium-webfont.svg#roboto-medium-webfont') format('svg');
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@ -39,5 +42,6 @@
|
||||
url('~fonts/roboto/roboto-regular-webfont.ttf') format('truetype'),
|
||||
url('~fonts/roboto/roboto-regular-webfont.svg#roboto-regular-webfont') format('svg');
|
||||
font-weight: normal;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
@use "sass:math";
|
||||
|
||||
$no-columns-breakpoint: 600px;
|
||||
$sidebar-width: 240px;
|
||||
$content-width: 840px;
|
||||
@ -925,10 +927,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
15
app/lib/admin/metrics/dimension.rb
Normal file
15
app/lib/admin/metrics/dimension.rb
Normal 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
|
31
app/lib/admin/metrics/dimension/base_dimension.rb
Normal file
31
app/lib/admin/metrics/dimension/base_dimension.rb
Normal 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
|
23
app/lib/admin/metrics/dimension/languages_dimension.rb
Normal file
23
app/lib/admin/metrics/dimension/languages_dimension.rb
Normal 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
|
23
app/lib/admin/metrics/dimension/servers_dimension.rb
Normal file
23
app/lib/admin/metrics/dimension/servers_dimension.rb
Normal 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
|
@ -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
|
23
app/lib/admin/metrics/dimension/sources_dimension.rb
Normal file
23
app/lib/admin/metrics/dimension/sources_dimension.rb
Normal 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
|
70
app/lib/admin/metrics/dimension/space_usage_dimension.rb
Normal file
70
app/lib/admin/metrics/dimension/space_usage_dimension.rb
Normal 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
|
15
app/lib/admin/metrics/measure.rb
Normal file
15
app/lib/admin/metrics/measure.rb
Normal 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
|
33
app/lib/admin/metrics/measure/active_users_measure.rb
Normal file
33
app/lib/admin/metrics/measure/active_users_measure.rb
Normal 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
|
46
app/lib/admin/metrics/measure/base_measure.rb
Normal file
46
app/lib/admin/metrics/measure/base_measure.rb
Normal 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
|
33
app/lib/admin/metrics/measure/interactions_measure.rb
Normal file
33
app/lib/admin/metrics/measure/interactions_measure.rb
Normal 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
|
35
app/lib/admin/metrics/measure/new_users_measure.rb
Normal file
35
app/lib/admin/metrics/measure/new_users_measure.rb
Normal 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
|
35
app/lib/admin/metrics/measure/opened_reports_measure.rb
Normal file
35
app/lib/admin/metrics/measure/opened_reports_measure.rb
Normal 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
|
36
app/lib/admin/metrics/measure/resolved_reports_measure.rb
Normal file
36
app/lib/admin/metrics/measure/resolved_reports_measure.rb
Normal 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
|
67
app/lib/admin/metrics/retention.rb
Normal file
67
app/lib/admin/metrics/retention.rb
Normal 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(*))::float / (SELECT GREATEST(count(*), 1) FROM new_users)] AS retention_value_and_rate
|
||||
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, rate = row['retention_value_and_rate'].delete('{}').split(',')
|
||||
|
||||
current_cohort.data << CohortData.new(
|
||||
date: row['retention_period'],
|
||||
percent: rate.to_f,
|
||||
value: value.to_s
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
@ -164,8 +164,8 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
|
||||
|
||||
def without_popular_scope
|
||||
scope = Status.left_joins(:status_stat)
|
||||
scope = scope.where('COALESCE(status_stats.reblogs_count, 0) <= ?', min_reblogs) unless min_reblogs.nil?
|
||||
scope = scope.where('COALESCE(status_stats.favourites_count, 0) <= ?', min_favs) unless min_favs.nil?
|
||||
scope = scope.where('COALESCE(status_stats.reblogs_count, 0) < ?', min_reblogs) unless min_reblogs.nil?
|
||||
scope = scope.where('COALESCE(status_stats.favourites_count, 0) < ?', min_favs) unless min_favs.nil?
|
||||
scope
|
||||
end
|
||||
end
|
||||
|
@ -76,7 +76,7 @@ class Admin::ActionLogFilter
|
||||
when 'account_id'
|
||||
Admin::ActionLog.where(account_id: value)
|
||||
when 'target_account_id'
|
||||
account = Account.find(value)
|
||||
account = Account.find_or_initialize_by(id: value)
|
||||
Admin::ActionLog.where(target: [account, account.user].compact)
|
||||
else
|
||||
raise "Unknown filter: #{key}"
|
||||
|
@ -494,7 +494,7 @@ class Status < ApplicationRecord
|
||||
end
|
||||
|
||||
def decrement_counter_caches
|
||||
return if direct_visibility?
|
||||
return if direct_visibility? || new_record?
|
||||
|
||||
account&.decrement_count!(:statuses_count)
|
||||
reblog&.decrement_count!(:reblogs_count) if reblog?
|
||||
|
@ -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
|
||||
|
19
app/serializers/rest/admin/cohort_serializer.rb
Normal file
19
app/serializers/rest/admin/cohort_serializer.rb
Normal 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
|
5
app/serializers/rest/admin/dimension_serializer.rb
Normal file
5
app/serializers/rest/admin/dimension_serializer.rb
Normal file
@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::Admin::DimensionSerializer < ActiveModel::Serializer
|
||||
attributes :key, :data
|
||||
end
|
13
app/serializers/rest/admin/measure_serializer.rb
Normal file
13
app/serializers/rest/admin/measure_serializer.rb
Normal 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
|
13
app/serializers/rest/admin/tag_serializer.rb
Normal file
13
app/serializers/rest/admin/tag_serializer.rb
Normal 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
|
@ -83,6 +83,9 @@ class PostStatusService < BaseService
|
||||
status_for_validation = @account.statuses.build(status_attributes)
|
||||
|
||||
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
|
||||
|
||||
# The following transaction block is needed to wrap the UPDATEs to
|
||||
|
@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ReactionValidator < ActiveModel::Validator
|
||||
SUPPORTED_EMOJIS = Oj.load(File.read(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json'))).keys.freeze
|
||||
SUPPORTED_EMOJIS = Oj.load_file(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json').to_s).keys.freeze
|
||||
|
||||
LIMIT = 8
|
||||
|
||||
|
@ -1,6 +1,11 @@
|
||||
- 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,133 +14,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.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__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')
|
||||
|
@ -1,12 +1,12 @@
|
||||
dependencies:
|
||||
- name: elasticsearch
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
version: 14.2.3
|
||||
version: 15.10.3
|
||||
- name: postgresql
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
version: 8.10.14
|
||||
- name: redis
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
version: 10.9.0
|
||||
digest: sha256:9e3e7b987c6ffba9295a30b7fae2613fe680c2b1a1832ff5afb185414ce1898e
|
||||
generated: "2021-02-27T01:01:12.776919968Z"
|
||||
digest: sha256:f5c57108f7768fd16391c1a050991c7809f84a640cca308d7d24d87379d04000
|
||||
generated: "2021-08-05T08:01:01.457727804Z"
|
||||
|
@ -24,7 +24,7 @@ appVersion: 3.3.0
|
||||
|
||||
dependencies:
|
||||
- name: elasticsearch
|
||||
version: 14.2.3
|
||||
version: 15.10.3
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
condition: elasticsearch.enabled
|
||||
- name: postgresql
|
||||
|
@ -107,6 +107,7 @@ module Mastodon
|
||||
:ka,
|
||||
:kab,
|
||||
:kk,
|
||||
:kmr,
|
||||
:kn,
|
||||
:ko,
|
||||
:ku,
|
||||
|
@ -105,7 +105,7 @@ Rails.application.configure do
|
||||
:password => ENV['SMTP_PASSWORD'].presence,
|
||||
:domain => ENV['SMTP_DOMAIN'] || ENV['LOCAL_DOMAIN'],
|
||||
:authentication => ENV['SMTP_AUTH_METHOD'] == 'none' ? nil : ENV['SMTP_AUTH_METHOD'] || :plain,
|
||||
:ca_file => ENV['SMTP_CA_FILE'].presence,
|
||||
:ca_file => ENV['SMTP_CA_FILE'].presence || '/etc/ssl/certs/ca-certificates.crt',
|
||||
:openssl_verify_mode => ENV['SMTP_OPENSSL_VERIFY_MODE'],
|
||||
:enable_starttls_auto => ENV['SMTP_ENABLE_STARTTLS_AUTO'] || true,
|
||||
:tls => ENV['SMTP_TLS'].presence,
|
||||
|
@ -24,10 +24,9 @@ module Twitter::TwitterText
|
||||
)
|
||||
\)
|
||||
/iox
|
||||
REGEXEN[:valid_iri_ucschar] = /[\u{A0}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFEF}\u{10000}-\u{1FFFD}\u{20000}-\u{2FFFD}\u{30000}-\u{3FFFD}\u{40000}-\u{4FFFD}\u{50000}-\u{5FFFD}\u{60000}-\u{6FFFD}\u{70000}-\u{7FFFD}\u{80000}-\u{8FFFD}\u{90000}-\u{9FFFD}\u{A0000}-\u{AFFFD}\u{B0000}-\u{BFFFD}\u{C0000}-\u{CFFFD}\u{D0000}-\u{DFFFD}\u{E1000}-\u{EFFFD}]/iou
|
||||
REGEXEN[:valid_iri_iprivate] = /[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/iou
|
||||
REGEXEN[:valid_url_query_chars] = /(?:#{REGEXEN[:valid_iri_ucschar]})|(?:#{REGEXEN[:valid_iri_iprivate]})|[a-z0-9!?\*'\(\);:&=\+\$\/%#\[\]\-_\.,~|@]/iou
|
||||
REGEXEN[:valid_url_query_ending_chars] = /(?:#{REGEXEN[:valid_iri_ucschar]})|(?:#{REGEXEN[:valid_iri_iprivate]})|[a-z0-9_&=#\/\-]/iou
|
||||
UCHARS = '\u{A0}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFEF}\u{10000}-\u{1FFFD}\u{20000}-\u{2FFFD}\u{30000}-\u{3FFFD}\u{40000}-\u{4FFFD}\u{50000}-\u{5FFFD}\u{60000}-\u{6FFFD}\u{70000}-\u{7FFFD}\u{80000}-\u{8FFFD}\u{90000}-\u{9FFFD}\u{A0000}-\u{AFFFD}\u{B0000}-\u{BFFFD}\u{C0000}-\u{CFFFD}\u{D0000}-\u{DFFFD}\u{E1000}-\u{EFFFD}\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}'
|
||||
REGEXEN[:valid_url_query_chars] = /[a-z0-9!?\*'\(\);:&=\+\$\/%#\[\]\-_\.,~|@#{UCHARS}]/iou
|
||||
REGEXEN[:valid_url_query_ending_chars] = /[a-z0-9_&=#\/\-#{UCHARS}]/iou
|
||||
REGEXEN[:valid_url_path] = /(?:
|
||||
(?:
|
||||
#{REGEXEN[:valid_general_url_path_chars]}*
|
||||
@ -57,23 +56,21 @@ module Twitter::TwitterText
|
||||
#{REGEXEN[:validate_url_pct_encoded]}|
|
||||
#{REGEXEN[:validate_url_sub_delims]}
|
||||
)/iox
|
||||
REGEXEN[:xmpp_uri] = %r{
|
||||
(xmpp:) # Protocol
|
||||
(//#{REGEXEN[:validate_nodeid]}+@#{REGEXEN[:valid_domain]}/)? # Authority (optional)
|
||||
(#{REGEXEN[:validate_nodeid]}+@)? # Username in path (optional)
|
||||
(#{REGEXEN[:valid_domain]}) # Domain in path
|
||||
(/#{REGEXEN[:validate_resid]}+)? # Resource in path (optional)
|
||||
(\?#{REGEXEN[:valid_url_query_chars]}*#{REGEXEN[:valid_url_query_ending_chars]})? # Query String
|
||||
}iox
|
||||
REGEXEN[:magnet_uri] = %r{
|
||||
(magnet:) # Protocol
|
||||
(\?#{REGEXEN[:valid_url_query_chars]}*#{REGEXEN[:valid_url_query_ending_chars]}) # Query String
|
||||
}iox
|
||||
REGEXEN[:valid_extended_uri] = %r{
|
||||
( # $1 total match
|
||||
(#{REGEXEN[:valid_url_preceding_chars]}) # $2 Preceding character
|
||||
( # $3 URL
|
||||
(#{REGEXEN[:xmpp_uri]}) | (#{REGEXEN[:magnet_uri]})
|
||||
(
|
||||
(xmpp:) # Protocol
|
||||
(//#{REGEXEN[:validate_nodeid]}+@#{REGEXEN[:valid_domain]}/)? # Authority (optional)
|
||||
(#{REGEXEN[:validate_nodeid]}+@)? # Username in path (optional)
|
||||
(#{REGEXEN[:valid_domain]}) # Domain in path
|
||||
(/#{REGEXEN[:validate_resid]}+)? # Resource in path (optional)
|
||||
(\?#{REGEXEN[:valid_url_query_chars]}*#{REGEXEN[:valid_url_query_ending_chars]})? # Query String
|
||||
) | (
|
||||
(magnet:) # Protocol
|
||||
(\?#{REGEXEN[:valid_url_query_chars]}*#{REGEXEN[:valid_url_query_ending_chars]}) # Query String
|
||||
)
|
||||
)
|
||||
)
|
||||
}iox
|
||||
|
@ -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
|
||||
@ -1336,10 +1332,10 @@ en:
|
||||
'63113904': 2 years
|
||||
'7889238': 3 months
|
||||
min_age_label: Age threshold
|
||||
min_favs: Keep posts favourited more than
|
||||
min_favs_hint: Doesn't delete any of your posts that has received more than this amount of favourites. Leave blank to delete posts regardless of their number of favourites
|
||||
min_reblogs: Keep posts boosted more than
|
||||
min_reblogs_hint: Doesn't delete any of your posts that has been boosted more than this number of times. Leave blank to delete posts regardless of their number of boosts
|
||||
min_favs: Keep posts favourited at least
|
||||
min_favs_hint: Doesn't delete any of your posts that has received at least this amount of favourites. Leave blank to delete posts regardless of their number of favourites
|
||||
min_reblogs: Keep posts boosted at least
|
||||
min_reblogs_hint: Doesn't delete any of your posts that has been boosted at least this number of times. Leave blank to delete posts regardless of their number of boosts
|
||||
stream_entries:
|
||||
pinned: Pinned post
|
||||
reblogged: boosted
|
||||
|
@ -514,6 +514,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
|
||||
|
||||
|
22
lib/cli.rb
22
lib/cli.rb
@ -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
|
||||
|
||||
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('A running Sidekiq process is required. Do not shut it down until queues clear.')
|
||||
unless options[:dry_run]
|
||||
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('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
|
||||
processed = 0
|
||||
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
|
||||
|
||||
Setting.registrations_mode = 'none' unless options[:dry_run]
|
||||
|
||||
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('You can shut it down and delete it any time')
|
||||
return
|
||||
@ -112,9 +117,7 @@ module Mastodon
|
||||
|
||||
prompt.warn('Do NOT interrupt this process...')
|
||||
|
||||
Setting.registrations_mode = 'none'
|
||||
|
||||
Account.local.without_suspended.find_each do |account|
|
||||
delete_account = ->(account) do
|
||||
payload = ActiveModelSerializers::SerializableResource.new(
|
||||
account,
|
||||
serializer: ActivityPub::DeleteActorSerializer,
|
||||
@ -128,12 +131,15 @@ module Mastodon
|
||||
[json, account.id, inbox_url]
|
||||
end
|
||||
|
||||
account.suspend!
|
||||
account.suspend!(block_email: false)
|
||||
end
|
||||
|
||||
processed += 1
|
||||
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('Wait until Sidekiq processes all items, then you can shut everything down and delete the data')
|
||||
rescue TTY::Reader::InputInterrupt
|
||||
|
@ -287,7 +287,7 @@ module Mastodon
|
||||
|
||||
option :concurrency, type: :numeric, default: 5, aliases: [:c]
|
||||
option :dry_run, type: :boolean
|
||||
desc 'cull', 'Remove remote accounts that no longer exist'
|
||||
desc 'cull [DOMAIN...]', 'Remove remote accounts that no longer exist'
|
||||
long_desc <<-LONG_DESC
|
||||
Query every single remote account in the database to determine
|
||||
if it still exists on the origin server, and if it doesn't,
|
||||
@ -296,19 +296,22 @@ module Mastodon
|
||||
Accounts that have had confirmed activity within the last week
|
||||
are excluded from the checks.
|
||||
LONG_DESC
|
||||
def cull
|
||||
def cull(*domains)
|
||||
skip_threshold = 7.days.ago
|
||||
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
|
||||
skip_domains = Concurrent::Set.new
|
||||
|
||||
processed, culled = parallelize_with_progress(Account.remote.where(protocol: :activitypub).partitioned) do |account|
|
||||
query = Account.remote.where(protocol: :activitypub)
|
||||
query = query.where(domain: domains) unless domains.empty?
|
||||
|
||||
processed, culled = parallelize_with_progress(query.partitioned) do |account|
|
||||
next if account.updated_at >= skip_threshold || (account.last_webfingered_at.present? && account.last_webfingered_at >= skip_threshold) || skip_domains.include?(account.domain)
|
||||
|
||||
code = 0
|
||||
|
||||
begin
|
||||
code = Request.new(:head, account.uri).perform(&:code)
|
||||
rescue HTTP::ConnectionError
|
||||
rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
|
||||
skip_domains << account.domain
|
||||
end
|
||||
|
||||
|
10
package.json
10
package.json
@ -61,11 +61,11 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.15.5",
|
||||
"@babel/core": "^7.15.8",
|
||||
"@babel/plugin-proposal-decorators": "^7.15.8",
|
||||
"@babel/plugin-transform-react-inline-elements": "^7.14.5",
|
||||
"@babel/plugin-transform-runtime": "^7.15.8",
|
||||
"@babel/preset-env": "^7.15.6",
|
||||
"@babel/preset-env": "^7.15.8",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@babel/runtime": "^7.15.4",
|
||||
"@gamestdio/websocket": "^0.3.2",
|
||||
@ -102,7 +102,7 @@
|
||||
"glob": "^7.2.0",
|
||||
"history": "^4.10.1",
|
||||
"http-link-header": "^1.0.3",
|
||||
"immutable": "^3.8.2",
|
||||
"immutable": "^4.0.0",
|
||||
"imports-loader": "^1.2.0",
|
||||
"intersection-observer": "^0.12.0",
|
||||
"intl": "^1.2.5",
|
||||
@ -141,7 +141,7 @@
|
||||
"react-redux-loading-bar": "^4.0.8",
|
||||
"react-router-dom": "^4.1.1",
|
||||
"react-router-scroll-4": "^1.0.0-beta.1",
|
||||
"react-select": "^4.3.1",
|
||||
"react-select": "^5.1.0",
|
||||
"react-sparklines": "^1.7.0",
|
||||
"react-swipeable-views": "^0.14.0",
|
||||
"react-textarea-autosize": "^8.3.3",
|
||||
@ -184,7 +184,7 @@
|
||||
"eslint-plugin-jsx-a11y": "~6.4.1",
|
||||
"eslint-plugin-promise": "~5.1.0",
|
||||
"eslint-plugin-react": "~7.26.1",
|
||||
"jest": "^27.2.3",
|
||||
"jest": "^27.2.5",
|
||||
"raf": "^3.4.1",
|
||||
"react-intl-translations-manager": "^5.0.3",
|
||||
"react-test-renderer": "^16.14.0",
|
||||
|
@ -499,9 +499,9 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when policy is to keep statuses with more than 4 boosts' do
|
||||
context 'when policy is to keep statuses with at least 5 boosts' do
|
||||
before do
|
||||
account_statuses_cleanup_policy.min_reblogs = 4
|
||||
account_statuses_cleanup_policy.min_reblogs = 5
|
||||
end
|
||||
|
||||
it 'does not return the recent toot' do
|
||||
@ -521,9 +521,9 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when policy is to keep statuses with more than 4 favs' do
|
||||
context 'when policy is to keep statuses with at least 5 favs' do
|
||||
before do
|
||||
account_statuses_cleanup_policy.min_favs = 4
|
||||
account_statuses_cleanup_policy.min_favs = 5
|
||||
end
|
||||
|
||||
it 'does not return the recent toot' do
|
||||
|
@ -25,29 +25,33 @@ RSpec.describe PostStatusService, type: :service do
|
||||
expect(status.thread).to eq in_reply_to_status
|
||||
end
|
||||
|
||||
it 'schedules a status' do
|
||||
account = Fabricate(:account)
|
||||
future = Time.now.utc + 2.hours
|
||||
context 'when scheduling a status' do
|
||||
let!(:account) { Fabricate(:account) }
|
||||
let!(:future) { Time.now.utc + 2.hours }
|
||||
let!(:previous_status) { Fabricate(:status, account: account) }
|
||||
|
||||
status = subject.call(account, text: 'Hi future!', scheduled_at: future)
|
||||
it 'schedules a status' do
|
||||
status = subject.call(account, text: 'Hi future!', scheduled_at: future)
|
||||
expect(status).to be_a ScheduledStatus
|
||||
expect(status.scheduled_at).to eq future
|
||||
expect(status.params['text']).to eq 'Hi future!'
|
||||
end
|
||||
|
||||
expect(status).to be_a ScheduledStatus
|
||||
expect(status.scheduled_at).to eq future
|
||||
expect(status.params['text']).to eq 'Hi future!'
|
||||
end
|
||||
it 'does not immediately create a status' do
|
||||
media = Fabricate(:media_attachment, account: account)
|
||||
status = subject.call(account, text: 'Hi future!', media_ids: [media.id], scheduled_at: future)
|
||||
|
||||
it 'does not immediately create a status when scheduling a status' do
|
||||
account = Fabricate(:account)
|
||||
media = Fabricate(:media_attachment)
|
||||
future = Time.now.utc + 2.hours
|
||||
expect(status).to be_a ScheduledStatus
|
||||
expect(status.scheduled_at).to eq 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(Status.where(text: 'Hi future!').exists?).to be_falsey
|
||||
end
|
||||
|
||||
status = subject.call(account, text: 'Hi future!', media_ids: [media.id], scheduled_at: future)
|
||||
|
||||
expect(status).to be_a ScheduledStatus
|
||||
expect(status.scheduled_at).to eq future
|
||||
expect(status.params['text']).to eq 'Hi future!'
|
||||
expect(media.reload.status).to be_nil
|
||||
expect(Status.where(text: 'Hi future!').exists?).to be_falsey
|
||||
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
|
||||
|
Loading…
Reference in New Issue
Block a user