mirror of
https://github.com/glitch-soc/mastodon.git
synced 2024-11-25 01:24:02 -05:00
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 && \
|
RUN cd /opt/mastodon && \
|
||||||
bundle config set deployment 'true' && \
|
bundle config set deployment 'true' && \
|
||||||
bundle config set without 'development test' && \
|
bundle config set without 'development test' && \
|
||||||
|
bundle config set silence_root_warning true && \
|
||||||
bundle install -j"$(nproc)" && \
|
bundle install -j"$(nproc)" && \
|
||||||
yarn install --pure-lockfile
|
yarn install --pure-lockfile
|
||||||
|
|
||||||
|
14
Gemfile.lock
14
Gemfile.lock
@ -188,7 +188,7 @@ GEM
|
|||||||
docile (1.3.4)
|
docile (1.3.4)
|
||||||
domain_name (0.5.20190701)
|
domain_name (0.5.20190701)
|
||||||
unf (>= 0.0.5, < 1.0.0)
|
unf (>= 0.0.5, < 1.0.0)
|
||||||
doorkeeper (5.5.3)
|
doorkeeper (5.5.4)
|
||||||
railties (>= 5)
|
railties (>= 5)
|
||||||
dotenv (2.7.6)
|
dotenv (2.7.6)
|
||||||
dotenv-rails (2.7.6)
|
dotenv-rails (2.7.6)
|
||||||
@ -262,7 +262,7 @@ GEM
|
|||||||
hiredis (0.6.3)
|
hiredis (0.6.3)
|
||||||
hkdf (0.3.0)
|
hkdf (0.3.0)
|
||||||
htmlentities (4.3.4)
|
htmlentities (4.3.4)
|
||||||
http (5.0.2)
|
http (5.0.4)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
http-cookie (~> 1.0)
|
http-cookie (~> 1.0)
|
||||||
http-form_data (~> 2.2)
|
http-form_data (~> 2.2)
|
||||||
@ -326,7 +326,7 @@ GEM
|
|||||||
addressable (~> 2.7)
|
addressable (~> 2.7)
|
||||||
letter_opener (1.7.0)
|
letter_opener (1.7.0)
|
||||||
launchy (~> 2.2)
|
launchy (~> 2.2)
|
||||||
letter_opener_web (1.4.0)
|
letter_opener_web (1.4.1)
|
||||||
actionmailer (>= 3.2)
|
actionmailer (>= 3.2)
|
||||||
letter_opener (~> 1.0)
|
letter_opener (~> 1.0)
|
||||||
railties (>= 3.2)
|
railties (>= 3.2)
|
||||||
@ -357,7 +357,7 @@ GEM
|
|||||||
mime-types (3.3.1)
|
mime-types (3.3.1)
|
||||||
mime-types-data (~> 3.2015)
|
mime-types-data (~> 3.2015)
|
||||||
mime-types-data (3.2020.0512)
|
mime-types-data (3.2020.0512)
|
||||||
mini_mime (1.1.1)
|
mini_mime (1.1.2)
|
||||||
mini_portile2 (2.6.1)
|
mini_portile2 (2.6.1)
|
||||||
minitest (5.14.4)
|
minitest (5.14.4)
|
||||||
msgpack (1.4.2)
|
msgpack (1.4.2)
|
||||||
@ -424,7 +424,7 @@ GEM
|
|||||||
pry-rails (0.3.9)
|
pry-rails (0.3.9)
|
||||||
pry (>= 0.10.4)
|
pry (>= 0.10.4)
|
||||||
public_suffix (4.0.6)
|
public_suffix (4.0.6)
|
||||||
puma (5.5.0)
|
puma (5.5.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.1.1)
|
pundit (2.1.1)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
@ -531,7 +531,7 @@ GEM
|
|||||||
unicode-display_width (>= 1.4.0, < 3.0)
|
unicode-display_width (>= 1.4.0, < 3.0)
|
||||||
rubocop-ast (1.12.0)
|
rubocop-ast (1.12.0)
|
||||||
parser (>= 3.0.1.1)
|
parser (>= 3.0.1.1)
|
||||||
rubocop-rails (2.12.2)
|
rubocop-rails (2.12.3)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.7.0, < 2.0)
|
rubocop (>= 1.7.0, < 2.0)
|
||||||
@ -627,7 +627,7 @@ GEM
|
|||||||
tzinfo (>= 1.0.0)
|
tzinfo (>= 1.0.0)
|
||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
unf_ext
|
unf_ext
|
||||||
unf_ext (0.0.7.7)
|
unf_ext (0.0.8)
|
||||||
unicode-display_width (1.8.0)
|
unicode-display_width (1.8.0)
|
||||||
uniform_notifier (1.14.2)
|
uniform_notifier (1.14.2)
|
||||||
warden (1.2.9)
|
warden (1.2.9)
|
||||||
|
12
Vagrantfile
vendored
12
Vagrantfile
vendored
@ -45,16 +45,8 @@ sudo apt-get install \
|
|||||||
# Install rvm
|
# Install rvm
|
||||||
read RUBY_VERSION < .ruby-version
|
read RUBY_VERSION < .ruby-version
|
||||||
|
|
||||||
gpg_command="gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB"
|
curl -sSL https://rvm.io/mpapis.asc | gpg --import
|
||||||
$($gpg_command)
|
curl -sSL https://rvm.io/pkuczynski.asc | gpg --import
|
||||||
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://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-installer | bash -s stable --ruby=$RUBY_VERSION
|
curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-installer | bash -s stable --ruby=$RUBY_VERSION
|
||||||
source /home/vagrant/.rvm/scripts/rvm
|
source /home/vagrant/.rvm/scripts/rvm
|
||||||
|
@ -1,50 +1,17 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
require 'sidekiq/api'
|
|
||||||
|
|
||||||
module Admin
|
module Admin
|
||||||
class DashboardController < BaseController
|
class DashboardController < BaseController
|
||||||
def index
|
def index
|
||||||
@system_checks = Admin::SystemCheck.perform
|
@system_checks = Admin::SystemCheck.perform
|
||||||
@users_count = User.count
|
@time_period = (1.month.ago.to_date...Time.now.utc.to_date)
|
||||||
@pending_users_count = User.pending.count
|
@pending_users_count = User.pending.count
|
||||||
@registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0
|
@pending_reports_count = Report.unresolved.count
|
||||||
@logins_week = Redis.current.pfcount("activity:logins:#{current_week}")
|
|
||||||
@interactions_week = Redis.current.get("activity:interactions:#{current_week}") || 0
|
|
||||||
@relay_enabled = Relay.enabled.exists?
|
|
||||||
@single_user_mode = Rails.configuration.x.single_user_mode
|
|
||||||
@registrations_enabled = Setting.registrations_mode != 'none'
|
|
||||||
@deletions_enabled = Setting.open_deletion
|
|
||||||
@invites_enabled = Setting.min_invite_role == 'user'
|
|
||||||
@search_enabled = Chewy.enabled?
|
|
||||||
@version = Mastodon::Version.to_s
|
|
||||||
@database_version = ActiveRecord::Base.connection.execute('SELECT VERSION()').first['version'].match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1]
|
|
||||||
@redis_version = redis_info['redis_version']
|
|
||||||
@reports_count = Report.unresolved.count
|
|
||||||
@queue_backlog = Sidekiq::Stats.new.enqueued
|
|
||||||
@recent_users = User.confirmed.recent.includes(:account).limit(8)
|
|
||||||
@database_size = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size']
|
|
||||||
@redis_size = redis_info['used_memory']
|
|
||||||
@ldap_enabled = ENV['LDAP_ENABLED'] == 'true'
|
|
||||||
@cas_enabled = ENV['CAS_ENABLED'] == 'true'
|
|
||||||
@saml_enabled = ENV['SAML_ENABLED'] == 'true'
|
|
||||||
@pam_enabled = ENV['PAM_ENABLED'] == 'true'
|
|
||||||
@hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
|
|
||||||
@trending_hashtags = TrendingTags.get(10, filtered: false)
|
|
||||||
@pending_tags_count = Tag.pending_review.count
|
@pending_tags_count = Tag.pending_review.count
|
||||||
@authorized_fetch = authorized_fetch_mode?
|
|
||||||
@whitelist_enabled = whitelist_mode?
|
|
||||||
@profile_directory = Setting.profile_directory
|
|
||||||
@timeline_preview = Setting.timeline_preview
|
|
||||||
@keybase_integration = Setting.enable_keybase
|
|
||||||
@trends_enabled = Setting.trends
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def current_week
|
|
||||||
@current_week ||= Time.now.utc.to_date.cweek
|
|
||||||
end
|
|
||||||
|
|
||||||
def redis_info
|
def redis_info
|
||||||
@redis_info ||= begin
|
@redis_info ||= begin
|
||||||
if Redis.current.is_a?(Redis::Namespace)
|
if Redis.current.is_a?(Redis::Namespace)
|
||||||
|
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
|
private
|
||||||
|
|
||||||
def activity
|
def activity
|
||||||
weeks = []
|
statuses_tracker = ActivityTracker.new('activity:statuses:local', :basic)
|
||||||
|
logins_tracker = ActivityTracker.new('activity:logins', :unique)
|
||||||
|
registrations_tracker = ActivityTracker.new('activity:accounts:local', :basic)
|
||||||
|
|
||||||
12.times do |i|
|
(0...12).map do |i|
|
||||||
day = i.weeks.ago.to_date
|
start_of_week = i.weeks.ago
|
||||||
week_id = day.cweek
|
end_of_week = start_of_week + 6.days
|
||||||
week = Date.commercial(day.cwyear, week_id)
|
|
||||||
|
|
||||||
weeks << {
|
{
|
||||||
week: week.to_time.to_i.to_s,
|
week: start_of_week.to_i.to_s,
|
||||||
statuses: Redis.current.get("activity:statuses:local:#{week_id}") || '0',
|
statuses: statuses_tracker.sum(start_of_week, end_of_week).to_s,
|
||||||
logins: Redis.current.pfcount("activity:logins:#{week_id}").to_s,
|
logins: logins_tracker.sum(start_of_week, end_of_week).to_s,
|
||||||
registrations: Redis.current.get("activity:accounts:local:#{week_id}") || '0',
|
registrations: registrations_tracker.sum(start_of_week, end_of_week).to_s,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
weeks
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_enabled_api!
|
def require_enabled_api!
|
||||||
|
@ -137,6 +137,10 @@ module ApplicationHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def react_admin_component(name, props = {})
|
||||||
|
content_tag(:div, nil, data: { 'admin-component': name.to_s.camelcase, props: Oj.dump({ locale: I18n.locale }.merge(props)) })
|
||||||
|
end
|
||||||
|
|
||||||
def body_classes
|
def body_classes
|
||||||
output = (@body_classes || '').split(' ')
|
output = (@body_classes || '').split(' ')
|
||||||
output << "flavour-#{current_flavour.parameterize}"
|
output << "flavour-#{current_flavour.parameterize}"
|
||||||
|
@ -41,6 +41,7 @@ module SettingsHelper
|
|||||||
ka: 'ქართული',
|
ka: 'ქართული',
|
||||||
kab: 'Taqbaylit',
|
kab: 'Taqbaylit',
|
||||||
kk: 'Қазақша',
|
kk: 'Қазақша',
|
||||||
|
kmr: 'Kurmancî',
|
||||||
kn: 'ಕನ್ನಡ',
|
kn: 'ಕನ್ನಡ',
|
||||||
ko: '한국어',
|
ko: '한국어',
|
||||||
ku: 'سۆرانی',
|
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 ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Permalink from './permalink';
|
import Permalink from './permalink';
|
||||||
import ShortNumber from 'flavours/glitch/components/short_number';
|
import ShortNumber from 'flavours/glitch/components/short_number';
|
||||||
|
import Skeleton from 'flavours/glitch/components/skeleton';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
class SilentErrorBoundary extends React.Component {
|
class SilentErrorBoundary extends React.Component {
|
||||||
|
|
||||||
@ -47,45 +49,38 @@ const accountsCountRenderer = (displayNumber, pluralReady) => (
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Hashtag = ({ hashtag }) => (
|
export const ImmutableHashtag = ({ hashtag }) => (
|
||||||
<div className='trends__item'>
|
<Hashtag
|
||||||
|
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'>
|
<div className='trends__item__name'>
|
||||||
<Permalink
|
<Permalink href={href} to={to}>
|
||||||
href={hashtag.get('url')}
|
{name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
|
||||||
to={`/tags/${hashtag.get('name')}`}
|
|
||||||
>
|
|
||||||
#<span>{hashtag.get('name')}</span>
|
|
||||||
</Permalink>
|
</Permalink>
|
||||||
|
|
||||||
<ShortNumber
|
{typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
|
||||||
value={
|
|
||||||
hashtag.getIn(['history', 0, 'accounts']) * 1 +
|
|
||||||
hashtag.getIn(['history', 1, 'accounts']) * 1
|
|
||||||
}
|
|
||||||
renderer={accountsCountRenderer}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='trends__item__current'>
|
<div className='trends__item__current'>
|
||||||
<ShortNumber
|
{typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
|
||||||
value={
|
|
||||||
hashtag.getIn(['history', 0, 'uses']) * 1 +
|
|
||||||
hashtag.getIn(['history', 1, 'uses']) * 1
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='trends__item__sparkline'>
|
<div className='trends__item__sparkline'>
|
||||||
<SilentErrorBoundary>
|
<SilentErrorBoundary>
|
||||||
<Sparklines
|
<Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
|
||||||
width={50}
|
|
||||||
height={28}
|
|
||||||
data={hashtag
|
|
||||||
.get('history')
|
|
||||||
.reverse()
|
|
||||||
.map((day) => day.get('uses'))
|
|
||||||
.toArray()}
|
|
||||||
>
|
|
||||||
<SparklinesCurve style={{ fill: 'none' }} />
|
<SparklinesCurve style={{ fill: 'none' }} />
|
||||||
</Sparklines>
|
</Sparklines>
|
||||||
</SilentErrorBoundary>
|
</SilentErrorBoundary>
|
||||||
@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
Hashtag.propTypes = {
|
Hashtag.propTypes = {
|
||||||
hashtag: ImmutablePropTypes.map.isRequired,
|
name: PropTypes.string,
|
||||||
|
href: PropTypes.string,
|
||||||
|
to: PropTypes.string,
|
||||||
|
people: PropTypes.number,
|
||||||
|
uses: PropTypes.number,
|
||||||
|
history: PropTypes.arrayOf(PropTypes.number),
|
||||||
|
className: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Hashtag;
|
export default Hashtag;
|
||||||
|
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 { getScrollbarWidth } from 'flavours/glitch/util/scrollbar';
|
||||||
import MediaGallery from 'flavours/glitch/components/media_gallery';
|
import MediaGallery from 'flavours/glitch/components/media_gallery';
|
||||||
import Poll from 'flavours/glitch/components/poll';
|
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 ModalRoot from 'flavours/glitch/components/modal_root';
|
||||||
import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
|
import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
|
||||||
import Video from 'flavours/glitch/features/video';
|
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 AccountContainer from 'flavours/glitch/containers/account_container';
|
||||||
import StatusContainer from 'flavours/glitch/containers/status_container';
|
import StatusContainer from 'flavours/glitch/containers/status_container';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
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 Icon from 'flavours/glitch/components/icon';
|
||||||
import { searchEnabled } from 'flavours/glitch/util/initial_state';
|
import { searchEnabled } from 'flavours/glitch/util/initial_state';
|
||||||
import LoadMore from 'flavours/glitch/components/load_more';
|
import LoadMore from 'flavours/glitch/components/load_more';
|
||||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Hashtag from 'flavours/glitch/components/hashtag';
|
import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
export default class Trends extends ImmutablePureComponent {
|
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;
|
$no-columns-breakpoint: 600px;
|
||||||
$sidebar-width: 240px;
|
$sidebar-width: 240px;
|
||||||
$content-width: 840px;
|
$content-width: 840px;
|
||||||
@ -925,10 +927,197 @@ a.name-tag,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard__counters.admin-account-counters {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.account-badges {
|
.account-badges {
|
||||||
margin: -2px 0;
|
margin: -2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard__counters.admin-account-counters {
|
.retention {
|
||||||
margin-top: 10px;
|
&__table {
|
||||||
|
&__number {
|
||||||
|
color: $secondary-text-color;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 10px 0;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 120px;
|
||||||
|
|
||||||
|
&.retention__table__average {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__size {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: $darker-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__box {
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: $ui-highlight-color;
|
||||||
|
padding: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $primary-text-color;
|
||||||
|
width: 52px;
|
||||||
|
margin: 1px;
|
||||||
|
|
||||||
|
@for $i from 0 through 10 {
|
||||||
|
&--#{10 * $i} {
|
||||||
|
background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkline {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
background: lighten($ui-base-color, 4%);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 55px + 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
display: flex;
|
||||||
|
line-height: 33px;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
|
||||||
|
&__total {
|
||||||
|
display: block;
|
||||||
|
margin-right: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 28px;
|
||||||
|
color: $primary-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__change {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 18px;
|
||||||
|
color: $darker-text-color;
|
||||||
|
margin-bottom: -3px;
|
||||||
|
|
||||||
|
&.positive {
|
||||||
|
color: $valid-value-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.negative {
|
||||||
|
color: $error-value-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
padding: 0 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $darker-text-color;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__graph {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
path:first-child {
|
||||||
|
fill: rgba($highlight-text-color, 0.25) !important;
|
||||||
|
fill-opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
path:last-child {
|
||||||
|
stroke: lighten($highlight-text-color, 6%) !important;
|
||||||
|
fill: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.sparkline {
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
background: lighten($ui-base-color, 6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background-color: lighten($ui-base-color, 8%);
|
||||||
|
background-image: linear-gradient(90deg, lighten($ui-base-color, 8%), lighten($ui-base-color, 12%), lighten($ui-base-color, 8%));
|
||||||
|
background-size: 200px 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 1;
|
||||||
|
width: 100%;
|
||||||
|
animation: skeleton 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton {
|
||||||
|
0% {
|
||||||
|
background-position: -200px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: calc(200px + 100%) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dimension {
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
border-bottom: 1px solid lighten($ui-base-color, 4%);
|
||||||
|
|
||||||
|
&__key {
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 11px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
text-align: right;
|
||||||
|
color: $darker-text-color;
|
||||||
|
padding: 11px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $ui-highlight-color;
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
|
@for $i from 0 through 10 {
|
||||||
|
&--#{10 * $i} {
|
||||||
|
background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -171,7 +171,6 @@
|
|||||||
&__current {
|
&__current {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
line-height: 36px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding-right: 15px;
|
padding-right: 15px;
|
||||||
@ -193,5 +192,57 @@
|
|||||||
fill: none !important;
|
fill: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--requires-review {
|
||||||
|
.trends__item__name {
|
||||||
|
color: $gold-star;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $gold-star;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends__item__current {
|
||||||
|
color: $gold-star;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends__item__sparkline {
|
||||||
|
path:first-child {
|
||||||
|
fill: rgba($gold-star, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
path:last-child {
|
||||||
|
stroke: lighten($gold-star, 6%) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
.trends__item__name {
|
||||||
|
color: lighten($ui-base-color, 12%);
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: lighten($ui-base-color, 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends__item__current {
|
||||||
|
color: lighten($ui-base-color, 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends__item__sparkline {
|
||||||
|
path:first-child {
|
||||||
|
fill: rgba(lighten($ui-base-color, 12%), 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
path:last-child {
|
||||||
|
stroke: lighten(lighten($ui-base-color, 12%), 6%) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--compact &__item {
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -56,23 +56,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard__widgets {
|
.dashboard {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||||
margin: 0 -5px;
|
grid-gap: 10px;
|
||||||
|
|
||||||
& > div {
|
&__item {
|
||||||
flex: 0 0 33.333%;
|
&--span-double-column {
|
||||||
margin-bottom: 20px;
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
& > div {
|
&--span-double-row {
|
||||||
padding: 0 5px;
|
grid-row: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a:not(.name-tag) {
|
&__quick-access {
|
||||||
color: $ui-secondary-color;
|
display: flex;
|
||||||
font-weight: 500;
|
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;
|
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.
|
# (REQUIRED) The location of the pack files.
|
||||||
pack:
|
pack:
|
||||||
about: packs/about.js
|
about: packs/about.js
|
||||||
admin: packs/public.js
|
admin: packs/admin.js
|
||||||
auth: packs/public.js
|
auth: packs/public.js
|
||||||
common:
|
common:
|
||||||
filename: packs/common.js
|
filename: packs/common.js
|
||||||
|
@ -69,3 +69,11 @@ export function pluralReady(sourceNumber, division) {
|
|||||||
|
|
||||||
return Math.trunc(sourceNumber / closestScale) * closestScale;
|
return Math.trunc(sourceNumber / closestScale) * closestScale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} num
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function roundTo10(num) {
|
||||||
|
return Math.round(num * 0.1) / 0.1;
|
||||||
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# (REQUIRED) The location of the pack files inside `pack_directory`.
|
# (REQUIRED) The location of the pack files inside `pack_directory`.
|
||||||
pack:
|
pack:
|
||||||
about: about.js
|
about: about.js
|
||||||
admin: public.js
|
admin: admin.js
|
||||||
auth: public.js
|
auth: public.js
|
||||||
common:
|
common:
|
||||||
filename: common.js
|
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 ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Permalink from './permalink';
|
import Permalink from './permalink';
|
||||||
import ShortNumber from 'mastodon/components/short_number';
|
import ShortNumber from 'mastodon/components/short_number';
|
||||||
|
import Skeleton from 'mastodon/components/skeleton';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
class SilentErrorBoundary extends React.Component {
|
class SilentErrorBoundary extends React.Component {
|
||||||
|
|
||||||
@ -47,45 +49,38 @@ const accountsCountRenderer = (displayNumber, pluralReady) => (
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Hashtag = ({ hashtag }) => (
|
export const ImmutableHashtag = ({ hashtag }) => (
|
||||||
<div className='trends__item'>
|
<Hashtag
|
||||||
|
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'>
|
<div className='trends__item__name'>
|
||||||
<Permalink
|
<Permalink href={href} to={to}>
|
||||||
href={hashtag.get('url')}
|
{name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
|
||||||
to={`/tags/${hashtag.get('name')}`}
|
|
||||||
>
|
|
||||||
#<span>{hashtag.get('name')}</span>
|
|
||||||
</Permalink>
|
</Permalink>
|
||||||
|
|
||||||
<ShortNumber
|
{typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
|
||||||
value={
|
|
||||||
hashtag.getIn(['history', 0, 'accounts']) * 1 +
|
|
||||||
hashtag.getIn(['history', 1, 'accounts']) * 1
|
|
||||||
}
|
|
||||||
renderer={accountsCountRenderer}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='trends__item__current'>
|
<div className='trends__item__current'>
|
||||||
<ShortNumber
|
{typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
|
||||||
value={
|
|
||||||
hashtag.getIn(['history', 0, 'uses']) * 1 +
|
|
||||||
hashtag.getIn(['history', 1, 'uses']) * 1
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='trends__item__sparkline'>
|
<div className='trends__item__sparkline'>
|
||||||
<SilentErrorBoundary>
|
<SilentErrorBoundary>
|
||||||
<Sparklines
|
<Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
|
||||||
width={50}
|
|
||||||
height={28}
|
|
||||||
data={hashtag
|
|
||||||
.get('history')
|
|
||||||
.reverse()
|
|
||||||
.map((day) => day.get('uses'))
|
|
||||||
.toArray()}
|
|
||||||
>
|
|
||||||
<SparklinesCurve style={{ fill: 'none' }} />
|
<SparklinesCurve style={{ fill: 'none' }} />
|
||||||
</Sparklines>
|
</Sparklines>
|
||||||
</SilentErrorBoundary>
|
</SilentErrorBoundary>
|
||||||
@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
Hashtag.propTypes = {
|
Hashtag.propTypes = {
|
||||||
hashtag: ImmutablePropTypes.map.isRequired,
|
name: PropTypes.string,
|
||||||
|
href: PropTypes.string,
|
||||||
|
to: PropTypes.string,
|
||||||
|
people: PropTypes.number,
|
||||||
|
uses: PropTypes.number,
|
||||||
|
history: PropTypes.arrayOf(PropTypes.number),
|
||||||
|
className: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Hashtag;
|
export default Hashtag;
|
||||||
|
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 { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
||||||
import MediaGallery from 'mastodon/components/media_gallery';
|
import MediaGallery from 'mastodon/components/media_gallery';
|
||||||
import Poll from 'mastodon/components/poll';
|
import Poll from 'mastodon/components/poll';
|
||||||
import Hashtag from 'mastodon/components/hashtag';
|
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||||
import ModalRoot from 'mastodon/components/modal_root';
|
import ModalRoot from 'mastodon/components/modal_root';
|
||||||
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
||||||
import Video from 'mastodon/features/video';
|
import Video from 'mastodon/features/video';
|
||||||
|
@ -5,7 +5,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|||||||
import AccountContainer from '../../../containers/account_container';
|
import AccountContainer from '../../../containers/account_container';
|
||||||
import StatusContainer from '../../../containers/status_container';
|
import StatusContainer from '../../../containers/status_container';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Hashtag from '../../../components/hashtag';
|
import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
import { searchEnabled } from '../../../initial_state';
|
import { searchEnabled } from '../../../initial_state';
|
||||||
import LoadMore from 'mastodon/components/load_more';
|
import LoadMore from 'mastodon/components/load_more';
|
||||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Hashtag from 'mastodon/components/hashtag';
|
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
export default class Trends extends ImmutablePureComponent {
|
export default class Trends extends ImmutablePureComponent {
|
||||||
|
@ -69,3 +69,11 @@ export function pluralReady(sourceNumber, division) {
|
|||||||
|
|
||||||
return Math.trunc(sourceNumber / closestScale) * closestScale;
|
return Math.trunc(sourceNumber / closestScale) * closestScale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} num
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function roundTo10(num) {
|
||||||
|
return Math.round(num * 0.1) / 0.1;
|
||||||
|
}
|
||||||
|
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.woff') format('woff'),
|
||||||
url('~fonts/montserrat/Montserrat-Regular.ttf') format('truetype');
|
url('~fonts/montserrat/Montserrat-Regular.ttf') format('truetype');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -13,5 +14,6 @@
|
|||||||
src: local('Montserrat Medium'),
|
src: local('Montserrat Medium'),
|
||||||
url('~fonts/montserrat/Montserrat-Medium.ttf') format('truetype');
|
url('~fonts/montserrat/Montserrat-Medium.ttf') format('truetype');
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
@ -6,5 +6,6 @@
|
|||||||
url('~fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'),
|
url('~fonts/roboto-mono/robotomono-regular-webfont.ttf') format('truetype'),
|
||||||
url('~fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular') format('svg');
|
url('~fonts/roboto-mono/robotomono-regular-webfont.svg#roboto_monoregular') format('svg');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
url('~fonts/roboto/roboto-italic-webfont.ttf') format('truetype'),
|
url('~fonts/roboto/roboto-italic-webfont.ttf') format('truetype'),
|
||||||
url('~fonts/roboto/roboto-italic-webfont.svg#roboto-italic-webfont') format('svg');
|
url('~fonts/roboto/roboto-italic-webfont.svg#roboto-italic-webfont') format('svg');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
font-display: swap;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,6 +18,7 @@
|
|||||||
url('~fonts/roboto/roboto-bold-webfont.ttf') format('truetype'),
|
url('~fonts/roboto/roboto-bold-webfont.ttf') format('truetype'),
|
||||||
url('~fonts/roboto/roboto-bold-webfont.svg#roboto-bold-webfont') format('svg');
|
url('~fonts/roboto/roboto-bold-webfont.svg#roboto-bold-webfont') format('svg');
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,6 +30,7 @@
|
|||||||
url('~fonts/roboto/roboto-medium-webfont.ttf') format('truetype'),
|
url('~fonts/roboto/roboto-medium-webfont.ttf') format('truetype'),
|
||||||
url('~fonts/roboto/roboto-medium-webfont.svg#roboto-medium-webfont') format('svg');
|
url('~fonts/roboto/roboto-medium-webfont.svg#roboto-medium-webfont') format('svg');
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,5 +42,6 @@
|
|||||||
url('~fonts/roboto/roboto-regular-webfont.ttf') format('truetype'),
|
url('~fonts/roboto/roboto-regular-webfont.ttf') format('truetype'),
|
||||||
url('~fonts/roboto/roboto-regular-webfont.svg#roboto-regular-webfont') format('svg');
|
url('~fonts/roboto/roboto-regular-webfont.svg#roboto-regular-webfont') format('svg');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
font-display: swap;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@use "sass:math";
|
||||||
|
|
||||||
$no-columns-breakpoint: 600px;
|
$no-columns-breakpoint: 600px;
|
||||||
$sidebar-width: 240px;
|
$sidebar-width: 240px;
|
||||||
$content-width: 840px;
|
$content-width: 840px;
|
||||||
@ -925,10 +927,197 @@ a.name-tag,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard__counters.admin-account-counters {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.account-badges {
|
.account-badges {
|
||||||
margin: -2px 0;
|
margin: -2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard__counters.admin-account-counters {
|
.retention {
|
||||||
margin-top: 10px;
|
&__table {
|
||||||
|
&__number {
|
||||||
|
color: $secondary-text-color;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 10px 0;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 120px;
|
||||||
|
|
||||||
|
&.retention__table__average {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__size {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: $darker-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__box {
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: $ui-highlight-color;
|
||||||
|
padding: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $primary-text-color;
|
||||||
|
width: 52px;
|
||||||
|
margin: 1px;
|
||||||
|
|
||||||
|
@for $i from 0 through 10 {
|
||||||
|
&--#{10 * $i} {
|
||||||
|
background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkline {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
background: lighten($ui-base-color, 4%);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 55px + 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
display: flex;
|
||||||
|
line-height: 33px;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
|
||||||
|
&__total {
|
||||||
|
display: block;
|
||||||
|
margin-right: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 28px;
|
||||||
|
color: $primary-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__change {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 18px;
|
||||||
|
color: $darker-text-color;
|
||||||
|
margin-bottom: -3px;
|
||||||
|
|
||||||
|
&.positive {
|
||||||
|
color: $valid-value-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.negative {
|
||||||
|
color: $error-value-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
padding: 0 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $darker-text-color;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__graph {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
path:first-child {
|
||||||
|
fill: rgba($highlight-text-color, 0.25) !important;
|
||||||
|
fill-opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
path:last-child {
|
||||||
|
stroke: lighten($highlight-text-color, 6%) !important;
|
||||||
|
fill: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.sparkline {
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
background: lighten($ui-base-color, 6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background-color: lighten($ui-base-color, 8%);
|
||||||
|
background-image: linear-gradient(90deg, lighten($ui-base-color, 8%), lighten($ui-base-color, 12%), lighten($ui-base-color, 8%));
|
||||||
|
background-size: 200px 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 1;
|
||||||
|
width: 100%;
|
||||||
|
animation: skeleton 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton {
|
||||||
|
0% {
|
||||||
|
background-position: -200px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: calc(200px + 100%) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dimension {
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
border-bottom: 1px solid lighten($ui-base-color, 4%);
|
||||||
|
|
||||||
|
&__key {
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 11px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
text-align: right;
|
||||||
|
color: $darker-text-color;
|
||||||
|
padding: 11px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $ui-highlight-color;
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
|
@for $i from 0 through 10 {
|
||||||
|
&--#{10 * $i} {
|
||||||
|
background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -6955,7 +6955,6 @@ noscript {
|
|||||||
&__current {
|
&__current {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
line-height: 36px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding-right: 15px;
|
padding-right: 15px;
|
||||||
@ -6977,6 +6976,58 @@ noscript {
|
|||||||
fill: none !important;
|
fill: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--requires-review {
|
||||||
|
.trends__item__name {
|
||||||
|
color: $gold-star;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $gold-star;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends__item__current {
|
||||||
|
color: $gold-star;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends__item__sparkline {
|
||||||
|
path:first-child {
|
||||||
|
fill: rgba($gold-star, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
path:last-child {
|
||||||
|
stroke: lighten($gold-star, 6%) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
.trends__item__name {
|
||||||
|
color: lighten($ui-base-color, 12%);
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: lighten($ui-base-color, 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends__item__current {
|
||||||
|
color: lighten($ui-base-color, 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trends__item__sparkline {
|
||||||
|
path:first-child {
|
||||||
|
fill: rgba(lighten($ui-base-color, 12%), 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
path:last-child {
|
||||||
|
stroke: lighten(lighten($ui-base-color, 12%), 6%) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--compact &__item {
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,23 +56,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard__widgets {
|
.dashboard {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||||
margin: 0 -5px;
|
grid-gap: 10px;
|
||||||
|
|
||||||
& > div {
|
&__item {
|
||||||
flex: 0 0 33.333%;
|
&--span-double-column {
|
||||||
margin-bottom: 20px;
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
& > div {
|
&--span-double-row {
|
||||||
padding: 0 5px;
|
grid-row: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a:not(.name-tag) {
|
&__quick-access {
|
||||||
color: $ui-secondary-color;
|
display: flex;
|
||||||
font-weight: 500;
|
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;
|
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
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityTracker
|
class ActivityTracker
|
||||||
|
include Redisable
|
||||||
|
|
||||||
EXPIRE_AFTER = 6.months.seconds
|
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
|
class << self
|
||||||
include Redisable
|
|
||||||
|
|
||||||
def increment(prefix)
|
def increment(prefix)
|
||||||
key = [prefix, current_week].join(':')
|
new(prefix, :basic).add
|
||||||
|
|
||||||
redis.incrby(key, 1)
|
|
||||||
redis.expire(key, EXPIRE_AFTER)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def record(prefix, value)
|
def record(prefix, value)
|
||||||
key = [prefix, current_week].join(':')
|
new(prefix, :unique).add(value)
|
||||||
|
|
||||||
redis.pfadd(key, value)
|
|
||||||
redis.expire(key, EXPIRE_AFTER)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def current_week
|
|
||||||
Time.zone.today.cweek
|
|
||||||
end
|
end
|
||||||
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
|
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
|
def without_popular_scope
|
||||||
scope = Status.left_joins(:status_stat)
|
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.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.favourites_count, 0) < ?', min_favs) unless min_favs.nil?
|
||||||
scope
|
scope
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -76,7 +76,7 @@ class Admin::ActionLogFilter
|
|||||||
when 'account_id'
|
when 'account_id'
|
||||||
Admin::ActionLog.where(account_id: value)
|
Admin::ActionLog.where(account_id: value)
|
||||||
when 'target_account_id'
|
when 'target_account_id'
|
||||||
account = Account.find(value)
|
account = Account.find_or_initialize_by(id: value)
|
||||||
Admin::ActionLog.where(target: [account, account.user].compact)
|
Admin::ActionLog.where(target: [account, account.user].compact)
|
||||||
else
|
else
|
||||||
raise "Unknown filter: #{key}"
|
raise "Unknown filter: #{key}"
|
||||||
|
@ -494,7 +494,7 @@ class Status < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def decrement_counter_caches
|
def decrement_counter_caches
|
||||||
return if direct_visibility?
|
return if direct_visibility? || new_record?
|
||||||
|
|
||||||
account&.decrement_count!(:statuses_count)
|
account&.decrement_count!(:statuses_count)
|
||||||
reblog&.decrement_count!(:reblogs_count) if reblog?
|
reblog&.decrement_count!(:reblogs_count) if reblog?
|
||||||
|
@ -24,8 +24,8 @@ class InstancePresenter
|
|||||||
Rails.cache.fetch('user_count') { User.confirmed.joins(:account).merge(Account.without_suspended).count }
|
Rails.cache.fetch('user_count') { User.confirmed.joins(:account).merge(Account.without_suspended).count }
|
||||||
end
|
end
|
||||||
|
|
||||||
def active_user_count(weeks = 4)
|
def active_user_count(num_weeks = 4)
|
||||||
Rails.cache.fetch("active_user_count/#{weeks}") { Redis.current.pfcount(*(0...weeks).map { |i| "activity:logins:#{i.weeks.ago.utc.to_date.cweek}" }) }
|
Rails.cache.fetch("active_user_count/#{num_weeks}") { ActivityTracker.new('activity:logins', :unique).sum(num_weeks.weeks.ago) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def status_count
|
def status_count
|
||||||
|
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)
|
status_for_validation = @account.statuses.build(status_attributes)
|
||||||
|
|
||||||
if status_for_validation.valid?
|
if status_for_validation.valid?
|
||||||
|
# Marking the status as destroyed is necessary to prevent the status from being
|
||||||
|
# persisted when the associated media attachments get updated when creating the
|
||||||
|
# scheduled status.
|
||||||
status_for_validation.destroy
|
status_for_validation.destroy
|
||||||
|
|
||||||
# The following transaction block is needed to wrap the UPDATEs to
|
# The following transaction block is needed to wrap the UPDATEs to
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ReactionValidator < ActiveModel::Validator
|
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
|
LIMIT = 8
|
||||||
|
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('admin.dashboard.title')
|
= t('admin.dashboard.title')
|
||||||
|
|
||||||
|
- content_for :heading_actions do
|
||||||
|
= l(@time_period.first)
|
||||||
|
= ' - '
|
||||||
|
= l(@time_period.last)
|
||||||
|
|
||||||
- unless @system_checks.empty?
|
- unless @system_checks.empty?
|
||||||
.flash-message-stack
|
.flash-message-stack
|
||||||
- @system_checks.each do |message|
|
- @system_checks.each do |message|
|
||||||
@ -9,133 +14,52 @@
|
|||||||
- if message.action
|
- if message.action
|
||||||
= link_to t("admin.system_checks.#{message.key}.action"), message.action
|
= link_to t("admin.system_checks.#{message.key}.action"), message.action
|
||||||
|
|
||||||
.dashboard__counters
|
.dashboard
|
||||||
%div
|
.dashboard__item
|
||||||
= link_to admin_accounts_url(local: 1, recent: 1) do
|
= react_admin_component :counter, measure: 'new_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.new_users'), href: admin_accounts_path
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@users_count, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @users_count
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.total_users'
|
|
||||||
%div
|
|
||||||
%div
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@registrations_week, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @registrations_week
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.week_users_new'
|
|
||||||
%div
|
|
||||||
%div
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@logins_week, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @logins_week
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.week_users_active'
|
|
||||||
%div
|
|
||||||
= link_to admin_pending_accounts_path do
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@pending_users_count, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @pending_users_count
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.pending_users'
|
|
||||||
%div
|
|
||||||
= link_to admin_reports_url do
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@reports_count, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @reports_count
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.open_reports'
|
|
||||||
%div
|
|
||||||
= link_to admin_tags_path(pending_review: '1') do
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@pending_tags_count, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @pending_tags_count
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.pending_tags'
|
|
||||||
%div
|
|
||||||
%div
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@interactions_week, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @interactions_week
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.week_interactions'
|
|
||||||
%div
|
|
||||||
= link_to sidekiq_url do
|
|
||||||
.dashboard__counters__num{ title: number_with_delimiter(@queue_backlog, strip_insignificant_zeros: true) }
|
|
||||||
= friendly_number_to_human @queue_backlog
|
|
||||||
.dashboard__counters__label= t 'admin.dashboard.backlog'
|
|
||||||
|
|
||||||
.dashboard__widgets
|
.dashboard__item
|
||||||
.dashboard__widgets__users
|
= react_admin_component :counter, measure: 'active_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.active_users'), href: admin_accounts_path
|
||||||
%div
|
|
||||||
%h4= t 'admin.dashboard.recent_users'
|
|
||||||
%ul
|
|
||||||
- @recent_users.each do |user|
|
|
||||||
%li= admin_account_link_to(user.account)
|
|
||||||
|
|
||||||
.dashboard__widgets__features
|
.dashboard__item
|
||||||
%div
|
= react_admin_component :counter, measure: 'interactions', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.interactions')
|
||||||
%h4= t 'admin.dashboard.features'
|
|
||||||
%ul
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.feature_registrations'), edit_admin_settings_path), @registrations_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.feature_invites'), edit_admin_settings_path), @invites_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.feature_deletions'), edit_admin_settings_path), @deletions_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.feature_profile_directory'), edit_admin_settings_path), @profile_directory)
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.feature_timeline_preview'), edit_admin_settings_path), @timeline_preview)
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.keybase'), edit_admin_settings_path), @keybase_integration)
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.trends'), edit_admin_settings_path), @trends_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled)
|
|
||||||
|
|
||||||
.dashboard__widgets__versions
|
.dashboard__item
|
||||||
%div
|
= react_admin_component :counter, measure: 'opened_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.opened_reports'), href: admin_reports_path
|
||||||
%h4= t 'admin.dashboard.software'
|
|
||||||
%ul
|
|
||||||
%li
|
|
||||||
Mastodon
|
|
||||||
%span.pull-right= @version
|
|
||||||
%li
|
|
||||||
Ruby
|
|
||||||
%span.pull-right= "#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}"
|
|
||||||
%li
|
|
||||||
PostgreSQL
|
|
||||||
%span.pull-right= @database_version
|
|
||||||
%li
|
|
||||||
Redis
|
|
||||||
%span.pull-right= @redis_version
|
|
||||||
|
|
||||||
.dashboard__widgets__space
|
.dashboard__item
|
||||||
%div
|
= react_admin_component :counter, measure: 'resolved_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.resolved_reports'), href: admin_reports_path(resolved: '1')
|
||||||
%h4= t 'admin.dashboard.space'
|
|
||||||
%ul
|
|
||||||
%li
|
|
||||||
PostgreSQL
|
|
||||||
%span.pull-right= number_to_human_size @database_size
|
|
||||||
%li
|
|
||||||
Redis
|
|
||||||
%span.pull-right= number_to_human_size @redis_size
|
|
||||||
|
|
||||||
.dashboard__widgets__config
|
.dashboard__item
|
||||||
%div
|
= link_to admin_reports_path, class: 'dashboard__quick-access' do
|
||||||
%h4= t 'admin.dashboard.config'
|
%span= t('admin.dashboard.pending_reports_html', count: @pending_reports_count)
|
||||||
%ul
|
= fa_icon 'chevron-right fw'
|
||||||
%li
|
|
||||||
= feature_hint(t('admin.dashboard.search'), @search_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint(t('admin.dashboard.single_user_mode'), @single_user_mode)
|
|
||||||
%li
|
|
||||||
= feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch)
|
|
||||||
%li
|
|
||||||
= feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint('LDAP', @ldap_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint('CAS', @cas_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint('SAML', @saml_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint('PAM', @pam_enabled)
|
|
||||||
%li
|
|
||||||
= feature_hint(t('admin.dashboard.hidden_service'), @hidden_service)
|
|
||||||
|
|
||||||
.dashboard__widgets__trends
|
= link_to admin_pending_accounts_path, class: 'dashboard__quick-access' do
|
||||||
%div
|
%span= t('admin.dashboard.pending_users_html', count: @pending_users_count)
|
||||||
%h4= t 'admin.dashboard.trends'
|
= fa_icon 'chevron-right fw'
|
||||||
%ul
|
|
||||||
- @trending_hashtags.each do |tag|
|
= link_to admin_tags_path(pending_review: '1'), class: 'dashboard__quick-access' do
|
||||||
%li
|
%span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count)
|
||||||
= link_to content_tag(:span, "##{tag.name}", class: !tag.trendable? && !tag.reviewed? ? 'warning-hint' : (!tag.trendable? ? 'negative-hint' : nil)), admin_tag_path(tag.id)
|
= fa_icon 'chevron-right fw'
|
||||||
%span.pull-right= number_with_delimiter(tag.history[0][:accounts].to_i)
|
|
||||||
|
.dashboard__item
|
||||||
|
= react_admin_component :dimension, dimension: 'sources', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.sources')
|
||||||
|
|
||||||
|
.dashboard__item
|
||||||
|
= react_admin_component :dimension, dimension: 'languages', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_languages')
|
||||||
|
|
||||||
|
.dashboard__item
|
||||||
|
= react_admin_component :dimension, dimension: 'servers', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_servers')
|
||||||
|
|
||||||
|
.dashboard__item.dashboard__item--span-double-column
|
||||||
|
= react_admin_component :retention, start_at: @time_period.last - 6.months, end_at: @time_period.last, frequency: 'month'
|
||||||
|
|
||||||
|
.dashboard__item.dashboard__item--span-double-row
|
||||||
|
= react_admin_component :trends, limit: 7
|
||||||
|
|
||||||
|
.dashboard__item
|
||||||
|
= react_admin_component :dimension, dimension: 'software_versions', start_at: @time_period.first, end_at: @time_period.last, limit: 4, label: t('admin.dashboard.software')
|
||||||
|
|
||||||
|
.dashboard__item
|
||||||
|
= react_admin_component :dimension, dimension: 'space_usage', start_at: @time_period.first, end_at: @time_period.last, limit: 3, label: t('admin.dashboard.space')
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
- name: elasticsearch
|
- name: elasticsearch
|
||||||
repository: https://charts.bitnami.com/bitnami
|
repository: https://charts.bitnami.com/bitnami
|
||||||
version: 14.2.3
|
version: 15.10.3
|
||||||
- name: postgresql
|
- name: postgresql
|
||||||
repository: https://charts.bitnami.com/bitnami
|
repository: https://charts.bitnami.com/bitnami
|
||||||
version: 8.10.14
|
version: 8.10.14
|
||||||
- name: redis
|
- name: redis
|
||||||
repository: https://charts.bitnami.com/bitnami
|
repository: https://charts.bitnami.com/bitnami
|
||||||
version: 10.9.0
|
version: 10.9.0
|
||||||
digest: sha256:9e3e7b987c6ffba9295a30b7fae2613fe680c2b1a1832ff5afb185414ce1898e
|
digest: sha256:f5c57108f7768fd16391c1a050991c7809f84a640cca308d7d24d87379d04000
|
||||||
generated: "2021-02-27T01:01:12.776919968Z"
|
generated: "2021-08-05T08:01:01.457727804Z"
|
||||||
|
@ -24,7 +24,7 @@ appVersion: 3.3.0
|
|||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- name: elasticsearch
|
- name: elasticsearch
|
||||||
version: 14.2.3
|
version: 15.10.3
|
||||||
repository: https://charts.bitnami.com/bitnami
|
repository: https://charts.bitnami.com/bitnami
|
||||||
condition: elasticsearch.enabled
|
condition: elasticsearch.enabled
|
||||||
- name: postgresql
|
- name: postgresql
|
||||||
|
@ -107,6 +107,7 @@ module Mastodon
|
|||||||
:ka,
|
:ka,
|
||||||
:kab,
|
:kab,
|
||||||
:kk,
|
:kk,
|
||||||
|
:kmr,
|
||||||
:kn,
|
:kn,
|
||||||
:ko,
|
:ko,
|
||||||
:ku,
|
:ku,
|
||||||
|
@ -105,7 +105,7 @@ Rails.application.configure do
|
|||||||
:password => ENV['SMTP_PASSWORD'].presence,
|
:password => ENV['SMTP_PASSWORD'].presence,
|
||||||
:domain => ENV['SMTP_DOMAIN'] || ENV['LOCAL_DOMAIN'],
|
:domain => ENV['SMTP_DOMAIN'] || ENV['LOCAL_DOMAIN'],
|
||||||
:authentication => ENV['SMTP_AUTH_METHOD'] == 'none' ? nil : ENV['SMTP_AUTH_METHOD'] || :plain,
|
: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'],
|
:openssl_verify_mode => ENV['SMTP_OPENSSL_VERIFY_MODE'],
|
||||||
:enable_starttls_auto => ENV['SMTP_ENABLE_STARTTLS_AUTO'] || true,
|
:enable_starttls_auto => ENV['SMTP_ENABLE_STARTTLS_AUTO'] || true,
|
||||||
:tls => ENV['SMTP_TLS'].presence,
|
:tls => ENV['SMTP_TLS'].presence,
|
||||||
|
@ -24,10 +24,9 @@ module Twitter::TwitterText
|
|||||||
)
|
)
|
||||||
\)
|
\)
|
||||||
/iox
|
/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
|
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_iri_iprivate] = /[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/iou
|
REGEXEN[:valid_url_query_chars] = /[a-z0-9!?\*'\(\);:&=\+\$\/%#\[\]\-_\.,~|@#{UCHARS}]/iou
|
||||||
REGEXEN[:valid_url_query_chars] = /(?:#{REGEXEN[:valid_iri_ucschar]})|(?:#{REGEXEN[:valid_iri_iprivate]})|[a-z0-9!?\*'\(\);:&=\+\$\/%#\[\]\-_\.,~|@]/iou
|
REGEXEN[:valid_url_query_ending_chars] = /[a-z0-9_&=#\/\-#{UCHARS}]/iou
|
||||||
REGEXEN[:valid_url_query_ending_chars] = /(?:#{REGEXEN[:valid_iri_ucschar]})|(?:#{REGEXEN[:valid_iri_iprivate]})|[a-z0-9_&=#\/\-]/iou
|
|
||||||
REGEXEN[:valid_url_path] = /(?:
|
REGEXEN[:valid_url_path] = /(?:
|
||||||
(?:
|
(?:
|
||||||
#{REGEXEN[:valid_general_url_path_chars]}*
|
#{REGEXEN[:valid_general_url_path_chars]}*
|
||||||
@ -57,23 +56,21 @@ module Twitter::TwitterText
|
|||||||
#{REGEXEN[:validate_url_pct_encoded]}|
|
#{REGEXEN[:validate_url_pct_encoded]}|
|
||||||
#{REGEXEN[:validate_url_sub_delims]}
|
#{REGEXEN[:validate_url_sub_delims]}
|
||||||
)/iox
|
)/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{
|
REGEXEN[:valid_extended_uri] = %r{
|
||||||
( # $1 total match
|
( # $1 total match
|
||||||
(#{REGEXEN[:valid_url_preceding_chars]}) # $2 Preceding character
|
(#{REGEXEN[:valid_url_preceding_chars]}) # $2 Preceding character
|
||||||
( # $3 URL
|
( # $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
|
}iox
|
||||||
|
@ -371,32 +371,28 @@ en:
|
|||||||
updated_msg: Emoji successfully updated!
|
updated_msg: Emoji successfully updated!
|
||||||
upload: Upload
|
upload: Upload
|
||||||
dashboard:
|
dashboard:
|
||||||
authorized_fetch_mode: Secure mode
|
active_users: active users
|
||||||
backlog: backlogged jobs
|
interactions: interactions
|
||||||
config: Configuration
|
media_storage: Media storage
|
||||||
feature_deletions: Account deletions
|
new_users: new users
|
||||||
feature_invites: Invite links
|
opened_reports: reports opened
|
||||||
feature_profile_directory: Profile directory
|
pending_reports_html:
|
||||||
feature_registrations: Registrations
|
one: "<strong>1</strong> pending reports"
|
||||||
feature_relay: Federation relay
|
other: "<strong>%{count}</strong> pending reports"
|
||||||
feature_timeline_preview: Timeline preview
|
pending_tags_html:
|
||||||
features: Features
|
one: "<strong>1</strong> pending hashtags"
|
||||||
hidden_service: Federation with hidden services
|
other: "<strong>%{count}</strong> pending hashtags"
|
||||||
open_reports: open reports
|
pending_users_html:
|
||||||
pending_tags: hashtags waiting for review
|
one: "<strong>1</strong> pending users"
|
||||||
pending_users: users waiting for review
|
other: "<strong>%{count}</strong> pending users"
|
||||||
recent_users: Recent users
|
resolved_reports: reports resolved
|
||||||
search: Full-text search
|
|
||||||
single_user_mode: Single user mode
|
|
||||||
software: Software
|
software: Software
|
||||||
|
sources: Sign-up sources
|
||||||
space: Space usage
|
space: Space usage
|
||||||
title: Dashboard
|
title: Dashboard
|
||||||
total_users: users in total
|
top_languages: Top active languages
|
||||||
trends: Trends
|
top_servers: Top active servers
|
||||||
week_interactions: interactions this week
|
website: Website
|
||||||
week_users_active: active this week
|
|
||||||
week_users_new: users this week
|
|
||||||
whitelist_mode: Limited federation mode
|
|
||||||
domain_allows:
|
domain_allows:
|
||||||
add_new: Allow federation with domain
|
add_new: Allow federation with domain
|
||||||
created_msg: Domain has been successfully allowed for federation
|
created_msg: Domain has been successfully allowed for federation
|
||||||
@ -1336,10 +1332,10 @@ en:
|
|||||||
'63113904': 2 years
|
'63113904': 2 years
|
||||||
'7889238': 3 months
|
'7889238': 3 months
|
||||||
min_age_label: Age threshold
|
min_age_label: Age threshold
|
||||||
min_favs: Keep posts favourited more than
|
min_favs: Keep posts favourited at least
|
||||||
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_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 more than
|
min_reblogs: Keep posts boosted at least
|
||||||
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_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:
|
stream_entries:
|
||||||
pinned: Pinned post
|
pinned: Pinned post
|
||||||
reblogged: boosted
|
reblogged: boosted
|
||||||
|
@ -514,6 +514,12 @@ Rails.application.routes.draw do
|
|||||||
post :resolve
|
post :resolve
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :trends, only: [:index]
|
||||||
|
|
||||||
|
post :measures, to: 'measures#create'
|
||||||
|
post :dimensions, to: 'dimensions#create'
|
||||||
|
post :retention, to: 'retention#create'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
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
|
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.')
|
unless options[:dry_run]
|
||||||
prompt.warn('While the data won\'t be erased locally, the server will be in a BROKEN STATE afterwards.')
|
prompt.warn('This operation WILL NOT be reversible. It can also take a long time.')
|
||||||
prompt.warn('A running Sidekiq process is required. Do not shut it down until queues clear.')
|
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
|
inboxes = Account.inboxes
|
||||||
processed = 0
|
processed = 0
|
||||||
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
|
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
|
||||||
|
|
||||||
|
Setting.registrations_mode = 'none' unless options[:dry_run]
|
||||||
|
|
||||||
if inboxes.empty?
|
if inboxes.empty?
|
||||||
|
Account.local.without_suspended.in_batches.update_all(suspended_at: Time.now.utc, suspension_origin: :local) unless options[:dry_run]
|
||||||
prompt.ok('It seems like your server has not federated with anything')
|
prompt.ok('It seems like your server has not federated with anything')
|
||||||
prompt.ok('You can shut it down and delete it any time')
|
prompt.ok('You can shut it down and delete it any time')
|
||||||
return
|
return
|
||||||
@ -112,9 +117,7 @@ module Mastodon
|
|||||||
|
|
||||||
prompt.warn('Do NOT interrupt this process...')
|
prompt.warn('Do NOT interrupt this process...')
|
||||||
|
|
||||||
Setting.registrations_mode = 'none'
|
delete_account = ->(account) do
|
||||||
|
|
||||||
Account.local.without_suspended.find_each do |account|
|
|
||||||
payload = ActiveModelSerializers::SerializableResource.new(
|
payload = ActiveModelSerializers::SerializableResource.new(
|
||||||
account,
|
account,
|
||||||
serializer: ActivityPub::DeleteActorSerializer,
|
serializer: ActivityPub::DeleteActorSerializer,
|
||||||
@ -128,12 +131,15 @@ module Mastodon
|
|||||||
[json, account.id, inbox_url]
|
[json, account.id, inbox_url]
|
||||||
end
|
end
|
||||||
|
|
||||||
account.suspend!
|
account.suspend!(block_email: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
processed += 1
|
processed += 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Account.local.without_suspended.find_each { |account| delete_account.call(account) }
|
||||||
|
Account.local.suspended.joins(:deletion_request).find_each { |account| delete_account.call(account) }
|
||||||
|
|
||||||
prompt.ok("Queued #{inboxes.size * processed} items into Sidekiq for #{processed} accounts#{dry_run}")
|
prompt.ok("Queued #{inboxes.size * processed} items into Sidekiq for #{processed} accounts#{dry_run}")
|
||||||
prompt.ok('Wait until Sidekiq processes all items, then you can shut everything down and delete the data')
|
prompt.ok('Wait until Sidekiq processes all items, then you can shut everything down and delete the data')
|
||||||
rescue TTY::Reader::InputInterrupt
|
rescue TTY::Reader::InputInterrupt
|
||||||
|
@ -287,7 +287,7 @@ module Mastodon
|
|||||||
|
|
||||||
option :concurrency, type: :numeric, default: 5, aliases: [:c]
|
option :concurrency, type: :numeric, default: 5, aliases: [:c]
|
||||||
option :dry_run, type: :boolean
|
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
|
long_desc <<-LONG_DESC
|
||||||
Query every single remote account in the database to determine
|
Query every single remote account in the database to determine
|
||||||
if it still exists on the origin server, and if it doesn't,
|
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
|
Accounts that have had confirmed activity within the last week
|
||||||
are excluded from the checks.
|
are excluded from the checks.
|
||||||
LONG_DESC
|
LONG_DESC
|
||||||
def cull
|
def cull(*domains)
|
||||||
skip_threshold = 7.days.ago
|
skip_threshold = 7.days.ago
|
||||||
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
|
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
|
||||||
skip_domains = Concurrent::Set.new
|
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)
|
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
|
code = 0
|
||||||
|
|
||||||
begin
|
begin
|
||||||
code = Request.new(:head, account.uri).perform(&:code)
|
code = Request.new(:head, account.uri).perform(&:code)
|
||||||
rescue HTTP::ConnectionError
|
rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
|
||||||
skip_domains << account.domain
|
skip_domains << account.domain
|
||||||
end
|
end
|
||||||
|
|
||||||
|
10
package.json
10
package.json
@ -61,11 +61,11 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.15.5",
|
"@babel/core": "^7.15.8",
|
||||||
"@babel/plugin-proposal-decorators": "^7.15.8",
|
"@babel/plugin-proposal-decorators": "^7.15.8",
|
||||||
"@babel/plugin-transform-react-inline-elements": "^7.14.5",
|
"@babel/plugin-transform-react-inline-elements": "^7.14.5",
|
||||||
"@babel/plugin-transform-runtime": "^7.15.8",
|
"@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/preset-react": "^7.14.5",
|
||||||
"@babel/runtime": "^7.15.4",
|
"@babel/runtime": "^7.15.4",
|
||||||
"@gamestdio/websocket": "^0.3.2",
|
"@gamestdio/websocket": "^0.3.2",
|
||||||
@ -102,7 +102,7 @@
|
|||||||
"glob": "^7.2.0",
|
"glob": "^7.2.0",
|
||||||
"history": "^4.10.1",
|
"history": "^4.10.1",
|
||||||
"http-link-header": "^1.0.3",
|
"http-link-header": "^1.0.3",
|
||||||
"immutable": "^3.8.2",
|
"immutable": "^4.0.0",
|
||||||
"imports-loader": "^1.2.0",
|
"imports-loader": "^1.2.0",
|
||||||
"intersection-observer": "^0.12.0",
|
"intersection-observer": "^0.12.0",
|
||||||
"intl": "^1.2.5",
|
"intl": "^1.2.5",
|
||||||
@ -141,7 +141,7 @@
|
|||||||
"react-redux-loading-bar": "^4.0.8",
|
"react-redux-loading-bar": "^4.0.8",
|
||||||
"react-router-dom": "^4.1.1",
|
"react-router-dom": "^4.1.1",
|
||||||
"react-router-scroll-4": "^1.0.0-beta.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-sparklines": "^1.7.0",
|
||||||
"react-swipeable-views": "^0.14.0",
|
"react-swipeable-views": "^0.14.0",
|
||||||
"react-textarea-autosize": "^8.3.3",
|
"react-textarea-autosize": "^8.3.3",
|
||||||
@ -184,7 +184,7 @@
|
|||||||
"eslint-plugin-jsx-a11y": "~6.4.1",
|
"eslint-plugin-jsx-a11y": "~6.4.1",
|
||||||
"eslint-plugin-promise": "~5.1.0",
|
"eslint-plugin-promise": "~5.1.0",
|
||||||
"eslint-plugin-react": "~7.26.1",
|
"eslint-plugin-react": "~7.26.1",
|
||||||
"jest": "^27.2.3",
|
"jest": "^27.2.5",
|
||||||
"raf": "^3.4.1",
|
"raf": "^3.4.1",
|
||||||
"react-intl-translations-manager": "^5.0.3",
|
"react-intl-translations-manager": "^5.0.3",
|
||||||
"react-test-renderer": "^16.14.0",
|
"react-test-renderer": "^16.14.0",
|
||||||
|
@ -499,9 +499,9 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do
|
|||||||
end
|
end
|
||||||
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
|
before do
|
||||||
account_statuses_cleanup_policy.min_reblogs = 4
|
account_statuses_cleanup_policy.min_reblogs = 5
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not return the recent toot' do
|
it 'does not return the recent toot' do
|
||||||
@ -521,9 +521,9 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do
|
|||||||
end
|
end
|
||||||
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
|
before do
|
||||||
account_statuses_cleanup_policy.min_favs = 4
|
account_statuses_cleanup_policy.min_favs = 5
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not return the recent toot' do
|
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
|
expect(status.thread).to eq in_reply_to_status
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'schedules a status' do
|
context 'when scheduling a status' do
|
||||||
account = Fabricate(:account)
|
let!(:account) { Fabricate(:account) }
|
||||||
future = Time.now.utc + 2.hours
|
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
|
it 'does not immediately create a status' do
|
||||||
expect(status.scheduled_at).to eq future
|
media = Fabricate(:media_attachment, account: account)
|
||||||
expect(status.params['text']).to eq 'Hi future!'
|
status = subject.call(account, text: 'Hi future!', media_ids: [media.id], scheduled_at: future)
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not immediately create a status when scheduling a status' do
|
expect(status).to be_a ScheduledStatus
|
||||||
account = Fabricate(:account)
|
expect(status.scheduled_at).to eq future
|
||||||
media = Fabricate(:media_attachment)
|
expect(status.params['text']).to eq 'Hi future!'
|
||||||
future = Time.now.utc + 2.hours
|
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)
|
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] }
|
||||||
expect(status).to be_a ScheduledStatus
|
end
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates response to the original status of boost' do
|
it 'creates response to the original status of boost' do
|
||||||
|
Loading…
Reference in New Issue
Block a user