Merge branch 'main' into glitch-soc/merge-upstream
Conflicts: - `app/controllers/admin/dashboard_controller.rb`: Upstream completely redesigned the admin dashboard. glitch-soc tracked extra features, but that list is gone. Followed upstram. - `app/views/admin/dashboard/index.html.haml` Upstream completely redesigned the admin dashboard. glitch-soc tracked extra features, but that list is gone. Followed upstram.
This commit is contained in:
		
						commit
						694c073d1f
					
				
					 52 changed files with 1690 additions and 290 deletions
				
			
		| 
						 | 
					@ -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)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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}"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -101,4 +101,24 @@ ready(() => {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const registrationMode = document.getElementById('form_admin_settings_registrations_mode');
 | 
					  const registrationMode = document.getElementById('form_admin_settings_registrations_mode');
 | 
				
			||||||
  if (registrationMode) onChangeRegistrationMode(registrationMode);
 | 
					  if (registrationMode) onChangeRegistrationMode(registrationMode);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const React    = require('react');
 | 
				
			||||||
 | 
					  const ReactDOM = require('react-dom');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [].forEach.call(document.querySelectorAll('[data-admin-component]'), element => {
 | 
				
			||||||
 | 
					    const componentName  = element.getAttribute('data-admin-component');
 | 
				
			||||||
 | 
					    const { locale, ...componentProps } = JSON.parse(element.getAttribute('data-props'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    import('../mastodon/containers/admin_component').then(({ default: AdminComponent }) => {
 | 
				
			||||||
 | 
					      return import('../mastodon/components/admin/' + componentName).then(({ default: Component }) => {
 | 
				
			||||||
 | 
					        ReactDOM.render((
 | 
				
			||||||
 | 
					          <AdminComponent locale={locale}>
 | 
				
			||||||
 | 
					            <Component {...componentProps} />
 | 
				
			||||||
 | 
					          </AdminComponent>
 | 
				
			||||||
 | 
					        ), element);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }).catch(error => {
 | 
				
			||||||
 | 
					      console.error(error);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										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
 | 
				
			||||||
    <div className='trends__item__name'>
 | 
					    name={hashtag.get('name')}
 | 
				
			||||||
      <Permalink
 | 
					 | 
				
			||||||
    href={hashtag.get('url')}
 | 
					    href={hashtag.get('url')}
 | 
				
			||||||
    to={`/tags/${hashtag.get('name')}`}
 | 
					    to={`/tags/${hashtag.get('name')}`}
 | 
				
			||||||
      >
 | 
					    people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
 | 
				
			||||||
        #<span>{hashtag.get('name')}</span>
 | 
					    uses={hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1}
 | 
				
			||||||
 | 
					    history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
 | 
				
			||||||
 | 
					  />
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ImmutableHashtag.propTypes = {
 | 
				
			||||||
 | 
					  hashtag: ImmutablePropTypes.map.isRequired,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const Hashtag = ({ name, href, to, people, uses, history, className }) => (
 | 
				
			||||||
 | 
					  <div className={classNames('trends__item', className)}>
 | 
				
			||||||
 | 
					    <div className='trends__item__name'>
 | 
				
			||||||
 | 
					      <Permalink href={href} to={to}>
 | 
				
			||||||
 | 
					        {name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
 | 
				
			||||||
      </Permalink>
 | 
					      </Permalink>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <ShortNumber
 | 
					      {typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
 | 
				
			||||||
        value={
 | 
					 | 
				
			||||||
          hashtag.getIn(['history', 0, 'accounts']) * 1 +
 | 
					 | 
				
			||||||
          hashtag.getIn(['history', 1, 'accounts']) * 1
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        renderer={accountsCountRenderer}
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div className='trends__item__current'>
 | 
					    <div className='trends__item__current'>
 | 
				
			||||||
      <ShortNumber
 | 
					      {typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
 | 
				
			||||||
        value={
 | 
					 | 
				
			||||||
          hashtag.getIn(['history', 0, 'uses']) * 1 +
 | 
					 | 
				
			||||||
          hashtag.getIn(['history', 1, 'uses']) * 1
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div className='trends__item__sparkline'>
 | 
					    <div className='trends__item__sparkline'>
 | 
				
			||||||
      <SilentErrorBoundary>
 | 
					      <SilentErrorBoundary>
 | 
				
			||||||
        <Sparklines
 | 
					        <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
 | 
				
			||||||
          width={50}
 | 
					 | 
				
			||||||
          height={28}
 | 
					 | 
				
			||||||
          data={hashtag
 | 
					 | 
				
			||||||
            .get('history')
 | 
					 | 
				
			||||||
            .reverse()
 | 
					 | 
				
			||||||
            .map((day) => day.get('uses'))
 | 
					 | 
				
			||||||
            .toArray()}
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <SparklinesCurve style={{ fill: 'none' }} />
 | 
					          <SparklinesCurve style={{ fill: 'none' }} />
 | 
				
			||||||
        </Sparklines>
 | 
					        </Sparklines>
 | 
				
			||||||
      </SilentErrorBoundary>
 | 
					      </SilentErrorBoundary>
 | 
				
			||||||
| 
						 | 
					@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => (
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Hashtag.propTypes = {
 | 
					Hashtag.propTypes = {
 | 
				
			||||||
  hashtag: ImmutablePropTypes.map.isRequired,
 | 
					  name: PropTypes.string,
 | 
				
			||||||
 | 
					  href: PropTypes.string,
 | 
				
			||||||
 | 
					  to: PropTypes.string,
 | 
				
			||||||
 | 
					  people: PropTypes.number,
 | 
				
			||||||
 | 
					  uses: PropTypes.number,
 | 
				
			||||||
 | 
					  history: PropTypes.arrayOf(PropTypes.number),
 | 
				
			||||||
 | 
					  className: PropTypes.string,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default Hashtag;
 | 
					export default Hashtag;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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: grid;
 | 
				
			||||||
 | 
					  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
 | 
				
			||||||
 | 
					  grid-gap: 10px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__item {
 | 
				
			||||||
 | 
					    &--span-double-column {
 | 
				
			||||||
 | 
					      grid-column: span 2;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &--span-double-row {
 | 
				
			||||||
 | 
					      grid-row: span 2;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    h4 {
 | 
				
			||||||
 | 
					      padding-top: 20px;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__quick-access {
 | 
				
			||||||
    display: flex;
 | 
					    display: flex;
 | 
				
			||||||
  flex-wrap: wrap;
 | 
					    align-items: baseline;
 | 
				
			||||||
  margin: 0 -5px;
 | 
					    border-radius: 4px;
 | 
				
			||||||
 | 
					    background: $ui-highlight-color;
 | 
				
			||||||
  & > div {
 | 
					    color: $primary-text-color;
 | 
				
			||||||
    flex: 0 0 33.333%;
 | 
					    transition: all 100ms ease-in;
 | 
				
			||||||
    margin-bottom: 20px;
 | 
					    font-size: 14px;
 | 
				
			||||||
 | 
					    padding: 0 16px;
 | 
				
			||||||
    & > div {
 | 
					    line-height: 36px;
 | 
				
			||||||
      padding: 0 5px;
 | 
					    height: 36px;
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  a:not(.name-tag) {
 | 
					 | 
				
			||||||
    color: $ui-secondary-color;
 | 
					 | 
				
			||||||
    font-weight: 500;
 | 
					 | 
				
			||||||
    text-decoration: none;
 | 
					    text-decoration: none;
 | 
				
			||||||
 | 
					    margin-bottom: 4px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &:active,
 | 
				
			||||||
 | 
					    &:focus,
 | 
				
			||||||
 | 
					    &:hover {
 | 
				
			||||||
 | 
					      background-color: lighten($ui-highlight-color, 10%);
 | 
				
			||||||
 | 
					      transition: all 200ms ease-out;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    span {
 | 
				
			||||||
 | 
					      flex: 1 1 auto;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .fa {
 | 
				
			||||||
 | 
					      flex: 0 0 auto;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    strong {
 | 
				
			||||||
 | 
					      font-weight: 700;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,29 +1,73 @@
 | 
				
			||||||
# frozen_string_literal: true
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ActivityTracker
 | 
					class ActivityTracker
 | 
				
			||||||
  EXPIRE_AFTER = 6.months.seconds
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  class << self
 | 
					 | 
				
			||||||
  include Redisable
 | 
					  include Redisable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def increment(prefix)
 | 
					  EXPIRE_AFTER = 6.months.seconds
 | 
				
			||||||
      key = [prefix, current_week].join(':')
 | 
					
 | 
				
			||||||
 | 
					  def initialize(prefix, type)
 | 
				
			||||||
 | 
					    @prefix = prefix
 | 
				
			||||||
 | 
					    @type   = type
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def add(value = 1, at_time = Time.now.utc)
 | 
				
			||||||
 | 
					    key = key_at(at_time)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    case @type
 | 
				
			||||||
 | 
					    when :basic
 | 
				
			||||||
 | 
					      redis.incrby(key, value)
 | 
				
			||||||
 | 
					    when :unique
 | 
				
			||||||
 | 
					      redis.pfadd(key, value)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      redis.incrby(key, 1)
 | 
					 | 
				
			||||||
    redis.expire(key, EXPIRE_AFTER)
 | 
					    redis.expire(key, EXPIRE_AFTER)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def record(prefix, value)
 | 
					  def get(start_at, end_at = Time.now.utc)
 | 
				
			||||||
      key = [prefix, current_week].join(':')
 | 
					    (start_at.to_date...end_at.to_date).map do |date|
 | 
				
			||||||
 | 
					      key = key_at(date.to_time(:utc))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      redis.pfadd(key, value)
 | 
					      value = begin
 | 
				
			||||||
      redis.expire(key, EXPIRE_AFTER)
 | 
					        case @type
 | 
				
			||||||
 | 
					        when :basic
 | 
				
			||||||
 | 
					          redis.get(key).to_i
 | 
				
			||||||
 | 
					        when :unique
 | 
				
			||||||
 | 
					          redis.pfcount(key)
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      [date, value]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def sum(start_at, end_at = Time.now.utc)
 | 
				
			||||||
 | 
					    keys = (start_at.to_date...end_at.to_date).flat_map { |date| [key_at(date.to_time(:utc)), legacy_key_at(date)] }.uniq
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    case @type
 | 
				
			||||||
 | 
					    when :basic
 | 
				
			||||||
 | 
					      redis.mget(*keys).map(&:to_i).sum
 | 
				
			||||||
 | 
					    when :unique
 | 
				
			||||||
 | 
					      redis.pfcount(*keys)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  class << self
 | 
				
			||||||
 | 
					    def increment(prefix)
 | 
				
			||||||
 | 
					      new(prefix, :basic).add
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def record(prefix, value)
 | 
				
			||||||
 | 
					      new(prefix, :unique).add(value)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def current_week
 | 
					  def key_at(at_time)
 | 
				
			||||||
      Time.zone.today.cweek
 | 
					    "#{@prefix}:#{at_time.beginning_of_day.to_i}"
 | 
				
			||||||
    end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def legacy_key_at(at_time)
 | 
				
			||||||
 | 
					    "#{@prefix}:#{at_time.to_date.cweek}"
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										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(*) + 1)::float / (SELECT count(*) + 1 FROM new_users)] AS retention_value_and_percent
 | 
				
			||||||
 | 
					        FROM retained_users
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					      FROM (
 | 
				
			||||||
 | 
					        WITH cohort_periods AS (
 | 
				
			||||||
 | 
					          SELECT generate_series(date_trunc($3, $1::timestamp)::date, date_trunc($3, $2::timestamp)::date, ('1 ' || $3)::interval) AS cohort_period
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        retention_periods AS (
 | 
				
			||||||
 | 
					          SELECT cohort_period AS retention_period FROM cohort_periods
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        SELECT *
 | 
				
			||||||
 | 
					        FROM cohort_periods, retention_periods
 | 
				
			||||||
 | 
					        WHERE retention_period >= cohort_period
 | 
				
			||||||
 | 
					      ) as axis
 | 
				
			||||||
 | 
					    SQL
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @frequency]])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    rows.each_with_object([]) do |row, arr|
 | 
				
			||||||
 | 
					      current_cohort = arr.last
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if current_cohort.nil? || current_cohort.period != row['cohort_period']
 | 
				
			||||||
 | 
					        current_cohort = Cohort.new(period: row['cohort_period'], frequency: @frequency, data: [])
 | 
				
			||||||
 | 
					        arr << current_cohort
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      value, percent = row['retention_value_and_percent'].delete('{}').split(',')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      current_cohort.data << CohortData.new(
 | 
				
			||||||
 | 
					        date: row['retention_period'],
 | 
				
			||||||
 | 
					        percent: percent.to_f,
 | 
				
			||||||
 | 
					        value: value.to_s
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
| 
						 | 
					@ -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,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')
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										14
									
								
								lib/cli.rb
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      unless options[:dry_run]
 | 
				
			||||||
        prompt.warn('This operation WILL NOT be reversible. It can also take a long time.')
 | 
					        prompt.warn('This operation WILL NOT be reversible. It can also take a long time.')
 | 
				
			||||||
        prompt.warn('While the data won\'t be erased locally, the server will be in a BROKEN STATE afterwards.')
 | 
					        prompt.warn('While the data won\'t be erased locally, the server will be in a BROKEN STATE afterwards.')
 | 
				
			||||||
        prompt.warn('A running Sidekiq process is required. Do not shut it down until queues clear.')
 | 
					        prompt.warn('A running Sidekiq process is required. Do not shut it down until queues clear.')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        exit(1) if prompt.no?('Are you sure you want to proceed?')
 | 
					        exit(1) if prompt.no?('Are you sure you want to proceed?')
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      inboxes   = Account.inboxes
 | 
					      inboxes   = Account.inboxes
 | 
				
			||||||
      processed = 0
 | 
					      processed = 0
 | 
				
			||||||
      dry_run   = options[:dry_run] ? ' (DRY RUN)' : ''
 | 
					      dry_run   = options[:dry_run] ? ' (DRY RUN)' : ''
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      Setting.registrations_mode = 'none' unless options[:dry_run]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if inboxes.empty?
 | 
					      if inboxes.empty?
 | 
				
			||||||
 | 
					        Account.local.without_suspended.in_batches.update_all(suspended_at: Time.now.utc, suspension_origin: :local) unless options[:dry_run]
 | 
				
			||||||
        prompt.ok('It seems like your server has not federated with anything')
 | 
					        prompt.ok('It seems like your server has not federated with anything')
 | 
				
			||||||
        prompt.ok('You can shut it down and delete it any time')
 | 
					        prompt.ok('You can shut it down and delete it any time')
 | 
				
			||||||
        return
 | 
					        return
 | 
				
			||||||
| 
						 | 
					@ -112,9 +117,7 @@ module Mastodon
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      prompt.warn('Do NOT interrupt this process...')
 | 
					      prompt.warn('Do NOT interrupt this process...')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      Setting.registrations_mode = 'none'
 | 
					      delete_account = ->(account) do
 | 
				
			||||||
 | 
					 | 
				
			||||||
      Account.local.without_suspended.find_each do |account|
 | 
					 | 
				
			||||||
        payload = ActiveModelSerializers::SerializableResource.new(
 | 
					        payload = ActiveModelSerializers::SerializableResource.new(
 | 
				
			||||||
          account,
 | 
					          account,
 | 
				
			||||||
          serializer: ActivityPub::DeleteActorSerializer,
 | 
					          serializer: ActivityPub::DeleteActorSerializer,
 | 
				
			||||||
| 
						 | 
					@ -128,12 +131,15 @@ module Mastodon
 | 
				
			||||||
            [json, account.id, inbox_url]
 | 
					            [json, account.id, inbox_url]
 | 
				
			||||||
          end
 | 
					          end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          account.suspend!
 | 
					          account.suspend!(block_email: false)
 | 
				
			||||||
        end
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        processed += 1
 | 
					        processed += 1
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      Account.local.without_suspended.find_each { |account| delete_account.call(account) }
 | 
				
			||||||
 | 
					      Account.local.suspended.joins(:deletion_request).find_each { |account| delete_account.call(account) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      prompt.ok("Queued #{inboxes.size * processed} items into Sidekiq for #{processed} accounts#{dry_run}")
 | 
					      prompt.ok("Queued #{inboxes.size * processed} items into Sidekiq for #{processed} accounts#{dry_run}")
 | 
				
			||||||
      prompt.ok('Wait until Sidekiq processes all items, then you can shut everything down and delete the data')
 | 
					      prompt.ok('Wait until Sidekiq processes all items, then you can shut everything down and delete the data')
 | 
				
			||||||
    rescue TTY::Reader::InputInterrupt
 | 
					    rescue TTY::Reader::InputInterrupt
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -25,31 +25,35 @@ RSpec.describe PostStatusService, type: :service do
 | 
				
			||||||
    expect(status.thread).to eq in_reply_to_status
 | 
					    expect(status.thread).to eq in_reply_to_status
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when scheduling a status' do
 | 
				
			||||||
 | 
					    let!(:account)         { Fabricate(:account) }
 | 
				
			||||||
 | 
					    let!(:future)          { Time.now.utc + 2.hours }
 | 
				
			||||||
 | 
					    let!(:previous_status) { Fabricate(:status, account: account) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it 'schedules a status' do
 | 
					    it 'schedules a status' do
 | 
				
			||||||
    account = Fabricate(:account)
 | 
					 | 
				
			||||||
    future  = Time.now.utc + 2.hours
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      status = subject.call(account, text: 'Hi future!', scheduled_at: future)
 | 
					      status = subject.call(account, text: 'Hi future!', scheduled_at: future)
 | 
				
			||||||
 | 
					 | 
				
			||||||
      expect(status).to be_a ScheduledStatus
 | 
					      expect(status).to be_a ScheduledStatus
 | 
				
			||||||
      expect(status.scheduled_at).to eq future
 | 
					      expect(status.scheduled_at).to eq future
 | 
				
			||||||
      expect(status.params['text']).to eq 'Hi future!'
 | 
					      expect(status.params['text']).to eq 'Hi future!'
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it 'does not immediately create a status when scheduling a status' do
 | 
					    it 'does not immediately create a status' do
 | 
				
			||||||
    account = Fabricate(:account)
 | 
					      media = Fabricate(:media_attachment, account: account)
 | 
				
			||||||
    media = Fabricate(:media_attachment)
 | 
					 | 
				
			||||||
    future  = Time.now.utc + 2.hours
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      status = subject.call(account, text: 'Hi future!', media_ids: [media.id], scheduled_at: future)
 | 
					      status = subject.call(account, text: 'Hi future!', media_ids: [media.id], scheduled_at: future)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(status).to be_a ScheduledStatus
 | 
					      expect(status).to be_a ScheduledStatus
 | 
				
			||||||
      expect(status.scheduled_at).to eq future
 | 
					      expect(status.scheduled_at).to eq future
 | 
				
			||||||
      expect(status.params['text']).to eq 'Hi future!'
 | 
					      expect(status.params['text']).to eq 'Hi future!'
 | 
				
			||||||
 | 
					      expect(status.params['media_ids']).to eq [media.id]
 | 
				
			||||||
      expect(media.reload.status).to be_nil
 | 
					      expect(media.reload.status).to be_nil
 | 
				
			||||||
      expect(Status.where(text: 'Hi future!').exists?).to be_falsey
 | 
					      expect(Status.where(text: 'Hi future!').exists?).to be_falsey
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'does not change statuses count' do
 | 
				
			||||||
 | 
					      expect { subject.call(account, text: 'Hi future!', scheduled_at: future, thread: previous_status) }.not_to change { [account.statuses_count, previous_status.replies_count] }
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it 'creates response to the original status of boost' do
 | 
					  it 'creates response to the original status of boost' do
 | 
				
			||||||
    boosted_status = Fabricate(:status)
 | 
					    boosted_status = Fabricate(:status)
 | 
				
			||||||
    in_reply_to_status = Fabricate(:status, reblog: boosted_status)
 | 
					    in_reply_to_status = Fabricate(:status, reblog: boosted_status)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in a new issue