Add graphs and retention metrics to admin dashboard (#16829)
This commit is contained in:
		| @@ -1,49 +1,17 @@ | ||||
| # frozen_string_literal: true | ||||
| require 'sidekiq/api' | ||||
|  | ||||
| module Admin | ||||
|   class DashboardController < BaseController | ||||
|     def index | ||||
|       @system_checks         = Admin::SystemCheck.perform | ||||
|       @users_count           = User.count | ||||
|       @time_period           = (1.month.ago.to_date...Time.now.utc.to_date) | ||||
|       @pending_users_count   = User.pending.count | ||||
|       @registrations_week    = Redis.current.get("activity:accounts:local:#{current_week}") || 0 | ||||
|       @logins_week           = Redis.current.pfcount("activity:logins:#{current_week}") | ||||
|       @interactions_week     = Redis.current.get("activity:interactions:#{current_week}") || 0 | ||||
|       @relay_enabled         = Relay.enabled.exists? | ||||
|       @single_user_mode      = Rails.configuration.x.single_user_mode | ||||
|       @registrations_enabled = Setting.registrations_mode != 'none' | ||||
|       @deletions_enabled     = Setting.open_deletion | ||||
|       @invites_enabled       = Setting.min_invite_role == 'user' | ||||
|       @search_enabled        = Chewy.enabled? | ||||
|       @version               = Mastodon::Version.to_s | ||||
|       @database_version      = ActiveRecord::Base.connection.execute('SELECT VERSION()').first['version'].match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1] | ||||
|       @redis_version         = redis_info['redis_version'] | ||||
|       @reports_count         = Report.unresolved.count | ||||
|       @queue_backlog         = Sidekiq::Stats.new.enqueued | ||||
|       @recent_users          = User.confirmed.recent.includes(:account).limit(8) | ||||
|       @database_size         = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size'] | ||||
|       @redis_size            = redis_info['used_memory'] | ||||
|       @ldap_enabled          = ENV['LDAP_ENABLED'] == 'true' | ||||
|       @cas_enabled           = ENV['CAS_ENABLED'] == 'true' | ||||
|       @saml_enabled          = ENV['SAML_ENABLED'] == 'true' | ||||
|       @pam_enabled           = ENV['PAM_ENABLED'] == 'true' | ||||
|       @hidden_service        = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true' | ||||
|       @trending_hashtags     = TrendingTags.get(10, filtered: false) | ||||
|       @pending_reports_count = Report.unresolved.count | ||||
|       @pending_tags_count    = Tag.pending_review.count | ||||
|       @authorized_fetch      = authorized_fetch_mode? | ||||
|       @whitelist_enabled     = whitelist_mode? | ||||
|       @profile_directory     = Setting.profile_directory | ||||
|       @timeline_preview      = Setting.timeline_preview | ||||
|       @trends_enabled        = Setting.trends | ||||
|     end | ||||
|  | ||||
|     private | ||||
|  | ||||
|     def current_week | ||||
|       @current_week ||= Time.now.utc.to_date.cweek | ||||
|     end | ||||
|  | ||||
|     def redis_info | ||||
|       @redis_info ||= begin | ||||
|         if Redis.current.is_a?(Redis::Namespace) | ||||
|   | ||||
							
								
								
									
										23
									
								
								app/controllers/api/v1/admin/dimensions_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/controllers/api/v1/admin/dimensions_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Api::V1::Admin::DimensionsController < Api::BaseController | ||||
|   protect_from_forgery with: :exception | ||||
|  | ||||
|   before_action :require_staff! | ||||
|   before_action :set_dimensions | ||||
|  | ||||
|   def create | ||||
|     render json: @dimensions, each_serializer: REST::Admin::DimensionSerializer | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def set_dimensions | ||||
|     @dimensions = Admin::Metrics::Dimension.retrieve( | ||||
|       params[:keys], | ||||
|       params[:start_at], | ||||
|       params[:end_at], | ||||
|       params[:limit] | ||||
|     ) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										22
									
								
								app/controllers/api/v1/admin/measures_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/controllers/api/v1/admin/measures_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Api::V1::Admin::MeasuresController < Api::BaseController | ||||
|   protect_from_forgery with: :exception | ||||
|  | ||||
|   before_action :require_staff! | ||||
|   before_action :set_measures | ||||
|  | ||||
|   def create | ||||
|     render json: @measures, each_serializer: REST::Admin::MeasureSerializer | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def set_measures | ||||
|     @measures = Admin::Metrics::Measure.retrieve( | ||||
|       params[:keys], | ||||
|       params[:start_at], | ||||
|       params[:end_at] | ||||
|     ) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										22
									
								
								app/controllers/api/v1/admin/retention_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/controllers/api/v1/admin/retention_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Api::V1::Admin::RetentionController < Api::BaseController | ||||
|   protect_from_forgery with: :exception | ||||
|  | ||||
|   before_action :require_staff! | ||||
|   before_action :set_cohorts | ||||
|  | ||||
|   def create | ||||
|     render json: @cohorts, each_serializer: REST::Admin::CohortSerializer | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def set_cohorts | ||||
|     @cohorts = Admin::Metrics::Retention.new( | ||||
|       params[:start_at], | ||||
|       params[:end_at], | ||||
|       params[:frequency] | ||||
|     ).cohorts | ||||
|   end | ||||
| end | ||||
							
								
								
									
										16
									
								
								app/controllers/api/v1/admin/trends_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								app/controllers/api/v1/admin/trends_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Api::V1::Admin::TrendsController < Api::BaseController | ||||
|   before_action :require_staff! | ||||
|   before_action :set_trends | ||||
|  | ||||
|   def index | ||||
|     render json: @trends, each_serializer: REST::Admin::TagSerializer | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def set_trends | ||||
|     @trends = TrendingTags.get(10, filtered: false) | ||||
|   end | ||||
| end | ||||
| @@ -14,22 +14,21 @@ class Api::V1::Instances::ActivityController < Api::BaseController | ||||
|   private | ||||
|  | ||||
|   def activity | ||||
|     weeks = [] | ||||
|     statuses_tracker      = ActivityTracker.new('activity:statuses:local', :basic) | ||||
|     logins_tracker        = ActivityTracker.new('activity:logins', :unique) | ||||
|     registrations_tracker = ActivityTracker.new('activity:accounts:local', :basic) | ||||
|  | ||||
|     12.times do |i| | ||||
|       day     = i.weeks.ago.to_date | ||||
|       week_id = day.cweek | ||||
|       week    = Date.commercial(day.cwyear, week_id) | ||||
|     (0...12).map do |i| | ||||
|       start_of_week = i.weeks.ago | ||||
|       end_of_week   = start_of_week + 6.days | ||||
|  | ||||
|       weeks << { | ||||
|         week: week.to_time.to_i.to_s, | ||||
|         statuses: Redis.current.get("activity:statuses:local:#{week_id}") || '0', | ||||
|         logins: Redis.current.pfcount("activity:logins:#{week_id}").to_s, | ||||
|         registrations: Redis.current.get("activity:accounts:local:#{week_id}") || '0', | ||||
|       { | ||||
|         week: start_of_week.to_i.to_s, | ||||
|         statuses: statuses_tracker.sum(start_of_week, end_of_week).to_s, | ||||
|         logins: logins_tracker.sum(start_of_week, end_of_week).to_s, | ||||
|         registrations: registrations_tracker.sum(start_of_week, end_of_week).to_s, | ||||
|       } | ||||
|     end | ||||
|  | ||||
|     weeks | ||||
|   end | ||||
|  | ||||
|   def require_enabled_api! | ||||
|   | ||||
| @@ -137,6 +137,10 @@ module ApplicationHelper | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def react_admin_component(name, props = {}) | ||||
|     content_tag(:div, nil, data: { 'admin-component': name.to_s.camelcase, props: Oj.dump({ locale: I18n.locale }.merge(props)) }) | ||||
|   end | ||||
|  | ||||
|   def body_classes | ||||
|     output = (@body_classes || '').split(' ') | ||||
|     output << "theme-#{current_theme.parameterize}" | ||||
|   | ||||
							
								
								
									
										115
									
								
								app/javascript/mastodon/components/admin/Counter.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								app/javascript/mastodon/components/admin/Counter.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,115 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import api from 'mastodon/api'; | ||||
| import { FormattedNumber } from 'react-intl'; | ||||
| import { Sparklines, SparklinesCurve } from 'react-sparklines'; | ||||
| import classNames from 'classnames'; | ||||
| import Skeleton from 'mastodon/components/skeleton'; | ||||
|  | ||||
| const percIncrease = (a, b) => { | ||||
|   let percent; | ||||
|  | ||||
|   if (b !== 0) { | ||||
|     if (a !== 0) { | ||||
|       percent = (b - a) / a; | ||||
|     } else { | ||||
|       percent = 1; | ||||
|     } | ||||
|   } else if (b === 0 && a === 0) { | ||||
|     percent = 0; | ||||
|   } else { | ||||
|     percent = - 1; | ||||
|   } | ||||
|  | ||||
|   return percent; | ||||
| }; | ||||
|  | ||||
| export default class Counter extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     measure: PropTypes.string.isRequired, | ||||
|     start_at: PropTypes.string.isRequired, | ||||
|     end_at: PropTypes.string.isRequired, | ||||
|     label: PropTypes.string.isRequired, | ||||
|     href: PropTypes.string, | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
|     loading: true, | ||||
|     data: null, | ||||
|   }; | ||||
|  | ||||
|   componentDidMount () { | ||||
|     const { measure, start_at, end_at } = this.props; | ||||
|  | ||||
|     api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at }).then(res => { | ||||
|       this.setState({ | ||||
|         loading: false, | ||||
|         data: res.data, | ||||
|       }); | ||||
|     }).catch(err => { | ||||
|       console.error(err); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { label, href } = this.props; | ||||
|     const { loading, data } = this.state; | ||||
|  | ||||
|     let content; | ||||
|  | ||||
|     if (loading) { | ||||
|       content = ( | ||||
|         <React.Fragment> | ||||
|           <span className='sparkline__value__total'><Skeleton width={43} /></span> | ||||
|           <span className='sparkline__value__change'><Skeleton width={43} /></span> | ||||
|         </React.Fragment> | ||||
|       ); | ||||
|     } else { | ||||
|       const measure = data[0]; | ||||
|       const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1); | ||||
|  | ||||
|       content = ( | ||||
|         <React.Fragment> | ||||
|           <span className='sparkline__value__total'><FormattedNumber value={measure.total} /></span> | ||||
|           <span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span> | ||||
|         </React.Fragment> | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     const inner = ( | ||||
|       <React.Fragment> | ||||
|         <div className='sparkline__value'> | ||||
|           {content} | ||||
|         </div> | ||||
|  | ||||
|         <div className='sparkline__label'> | ||||
|           {label} | ||||
|         </div> | ||||
|  | ||||
|         <div className='sparkline__graph'> | ||||
|           {!loading && ( | ||||
|             <Sparklines width={259} height={55} data={data[0].data.map(x => x.value * 1)}> | ||||
|               <SparklinesCurve /> | ||||
|             </Sparklines> | ||||
|           )} | ||||
|         </div> | ||||
|       </React.Fragment> | ||||
|     ); | ||||
|  | ||||
|     if (href) { | ||||
|       return ( | ||||
|         <a href={href} className='sparkline'> | ||||
|           {inner} | ||||
|         </a> | ||||
|       ); | ||||
|     } else { | ||||
|       return ( | ||||
|         <div className='sparkline'> | ||||
|           {inner} | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| } | ||||
							
								
								
									
										92
									
								
								app/javascript/mastodon/components/admin/Dimension.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								app/javascript/mastodon/components/admin/Dimension.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import api from 'mastodon/api'; | ||||
| import { FormattedNumber } from 'react-intl'; | ||||
| import { roundTo10 } from 'mastodon/utils/numbers'; | ||||
| import Skeleton from 'mastodon/components/skeleton'; | ||||
|  | ||||
| export default class Dimension extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     dimension: PropTypes.string.isRequired, | ||||
|     start_at: PropTypes.string.isRequired, | ||||
|     end_at: PropTypes.string.isRequired, | ||||
|     limit: PropTypes.number.isRequired, | ||||
|     label: PropTypes.string.isRequired, | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
|     loading: true, | ||||
|     data: null, | ||||
|   }; | ||||
|  | ||||
|   componentDidMount () { | ||||
|     const { start_at, end_at, dimension, limit } = this.props; | ||||
|  | ||||
|     api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit }).then(res => { | ||||
|       this.setState({ | ||||
|         loading: false, | ||||
|         data: res.data, | ||||
|       }); | ||||
|     }).catch(err => { | ||||
|       console.error(err); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { label, limit } = this.props; | ||||
|     const { loading, data } = this.state; | ||||
|  | ||||
|     let content; | ||||
|  | ||||
|     if (loading) { | ||||
|       content = ( | ||||
|         <table> | ||||
|           <tbody> | ||||
|             {Array.from(Array(limit)).map((_, i) => ( | ||||
|               <tr className='dimension__item' key={i}> | ||||
|                 <td className='dimension__item__key'> | ||||
|                   <Skeleton width={100} /> | ||||
|                 </td> | ||||
|  | ||||
|                 <td className='dimension__item__value'> | ||||
|                   <Skeleton width={60} /> | ||||
|                 </td> | ||||
|               </tr> | ||||
|             ))} | ||||
|           </tbody> | ||||
|         </table> | ||||
|       ); | ||||
|     } else { | ||||
|       const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0); | ||||
|  | ||||
|       content = ( | ||||
|         <table> | ||||
|           <tbody> | ||||
|             {data[0].data.map(item => ( | ||||
|               <tr className='dimension__item' key={item.key}> | ||||
|                 <td className='dimension__item__key'> | ||||
|                   <span className={`dimension__item__indicator dimension__item__indicator--${roundTo10(((item.value * 1) / sum) * 100)}`} /> | ||||
|                   <span title={item.key}>{item.human_key}</span> | ||||
|                 </td> | ||||
|  | ||||
|                 <td className='dimension__item__value'> | ||||
|                   {typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={item.value} />} | ||||
|                 </td> | ||||
|               </tr> | ||||
|             ))} | ||||
|           </tbody> | ||||
|         </table> | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|       <div className='dimension'> | ||||
|         <h4>{label}</h4> | ||||
|  | ||||
|         {content} | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
							
								
								
									
										141
									
								
								app/javascript/mastodon/components/admin/Retention.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								app/javascript/mastodon/components/admin/Retention.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import api from 'mastodon/api'; | ||||
| import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl'; | ||||
| import classNames from 'classnames'; | ||||
| import { roundTo10 } from 'mastodon/utils/numbers'; | ||||
|  | ||||
| const dateForCohort = cohort => { | ||||
|   switch(cohort.frequency) { | ||||
|   case 'day': | ||||
|     return <FormattedDate value={cohort.period} month='long' day='2-digit' />; | ||||
|   default: | ||||
|     return <FormattedDate value={cohort.period} month='long' year='numeric' />; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| export default class Retention extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     start_at: PropTypes.string, | ||||
|     end_at: PropTypes.string, | ||||
|     frequency: PropTypes.string, | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
|     loading: true, | ||||
|     data: null, | ||||
|   }; | ||||
|  | ||||
|   componentDidMount () { | ||||
|     const { start_at, end_at, frequency } = this.props; | ||||
|  | ||||
|     api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => { | ||||
|       this.setState({ | ||||
|         loading: false, | ||||
|         data: res.data, | ||||
|       }); | ||||
|     }).catch(err => { | ||||
|       console.error(err); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { loading, data } = this.state; | ||||
|  | ||||
|     let content; | ||||
|  | ||||
|     if (loading) { | ||||
|       content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />; | ||||
|     } else { | ||||
|       content = ( | ||||
|         <table className='retention__table'> | ||||
|           <thead> | ||||
|             <tr> | ||||
|               <th> | ||||
|                 <div className='retention__table__date retention__table__label'> | ||||
|                   <FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' /> | ||||
|                 </div> | ||||
|               </th> | ||||
|  | ||||
|               <th> | ||||
|                 <div className='retention__table__number retention__table__label'> | ||||
|                   <FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' /> | ||||
|                 </div> | ||||
|               </th> | ||||
|  | ||||
|               {data[0].data.slice(1).map((retention, i) => ( | ||||
|                 <th key={retention.date}> | ||||
|                   <div className='retention__table__number retention__table__label'> | ||||
|                     {i + 1} | ||||
|                   </div> | ||||
|                 </th> | ||||
|               ))} | ||||
|             </tr> | ||||
|  | ||||
|             <tr> | ||||
|               <td> | ||||
|                 <div className='retention__table__date retention__table__average'> | ||||
|                   <FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' /> | ||||
|                 </div> | ||||
|               </td> | ||||
|  | ||||
|               <td> | ||||
|                 <div className='retention__table__size'> | ||||
|                   <FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} /> | ||||
|                 </div> | ||||
|               </td> | ||||
|  | ||||
|               {data[0].data.slice(1).map((retention, i) => { | ||||
|                 const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].percent - sum)/(k + 1) : sum, 0); | ||||
|  | ||||
|                 return ( | ||||
|                   <td key={retention.date}> | ||||
|                     <div className={classNames('retention__table__box', 'retention__table__average', `retention__table__box--${roundTo10(average * 100)}`)}> | ||||
|                       <FormattedNumber value={average} style='percent' /> | ||||
|                     </div> | ||||
|                   </td> | ||||
|                 ); | ||||
|               })} | ||||
|             </tr> | ||||
|           </thead> | ||||
|  | ||||
|           <tbody> | ||||
|             {data.slice(0, -1).map(cohort => ( | ||||
|               <tr key={cohort.period}> | ||||
|                 <td> | ||||
|                   <div className='retention__table__date'> | ||||
|                     {dateForCohort(cohort)} | ||||
|                   </div> | ||||
|                 </td> | ||||
|  | ||||
|                 <td> | ||||
|                   <div className='retention__table__size'> | ||||
|                     <FormattedNumber value={cohort.data[0].value} /> | ||||
|                   </div> | ||||
|                 </td> | ||||
|  | ||||
|                 {cohort.data.slice(1).map(retention => ( | ||||
|                   <td key={retention.date}> | ||||
|                     <div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.percent * 100)}`)}> | ||||
|                       <FormattedNumber value={retention.percent} style='percent' /> | ||||
|                     </div> | ||||
|                   </td> | ||||
|                 ))} | ||||
|               </tr> | ||||
|             ))} | ||||
|           </tbody> | ||||
|         </table> | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|       <div className='retention'> | ||||
|         <h4><FormattedMessage id='admin.dashboard.retention' defaultMessage='Retention' /></h4> | ||||
|  | ||||
|         {content} | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
							
								
								
									
										73
									
								
								app/javascript/mastodon/components/admin/Trends.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								app/javascript/mastodon/components/admin/Trends.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import api from 'mastodon/api'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import classNames from 'classnames'; | ||||
| import Hashtag from 'mastodon/components/hashtag'; | ||||
|  | ||||
| export default class Trends extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     limit: PropTypes.number.isRequired, | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
|     loading: true, | ||||
|     data: null, | ||||
|   }; | ||||
|  | ||||
|   componentDidMount () { | ||||
|     const { limit } = this.props; | ||||
|  | ||||
|     api().get('/api/v1/admin/trends', { params: { limit } }).then(res => { | ||||
|       this.setState({ | ||||
|         loading: false, | ||||
|         data: res.data, | ||||
|       }); | ||||
|     }).catch(err => { | ||||
|       console.error(err); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { limit } = this.props; | ||||
|     const { loading, data } = this.state; | ||||
|  | ||||
|     let content; | ||||
|  | ||||
|     if (loading) { | ||||
|       content = ( | ||||
|         <div> | ||||
|           {Array.from(Array(limit)).map((_, i) => ( | ||||
|             <Hashtag key={i} /> | ||||
|           ))} | ||||
|         </div> | ||||
|       ); | ||||
|     } else { | ||||
|       content = ( | ||||
|         <div> | ||||
|           {data.map(hashtag => ( | ||||
|             <Hashtag | ||||
|               key={hashtag.name} | ||||
|               name={hashtag.name} | ||||
|               href={`/admin/tags/${hashtag.id}`} | ||||
|               people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1} | ||||
|               uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1} | ||||
|               history={hashtag.history.reverse().map(day => day.uses)} | ||||
|               className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')} | ||||
|             /> | ||||
|           ))} | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|       <div className='trends trends--compact'> | ||||
|         <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4> | ||||
|  | ||||
|         {content} | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -6,6 +6,8 @@ import PropTypes from 'prop-types'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import Permalink from './permalink'; | ||||
| import ShortNumber from 'mastodon/components/short_number'; | ||||
| import Skeleton from 'mastodon/components/skeleton'; | ||||
| import classNames from 'classnames'; | ||||
|  | ||||
| class SilentErrorBoundary extends React.Component { | ||||
|  | ||||
| @@ -47,45 +49,38 @@ const accountsCountRenderer = (displayNumber, pluralReady) => ( | ||||
|   /> | ||||
| ); | ||||
|  | ||||
| const Hashtag = ({ hashtag }) => ( | ||||
|   <div className='trends__item'> | ||||
| export const ImmutableHashtag = ({ hashtag }) => ( | ||||
|   <Hashtag | ||||
|     name={hashtag.get('name')} | ||||
|     href={hashtag.get('url')} | ||||
|     to={`/tags/${hashtag.get('name')}`} | ||||
|     people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1} | ||||
|     uses={hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1} | ||||
|     history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()} | ||||
|   /> | ||||
| ); | ||||
|  | ||||
| ImmutableHashtag.propTypes = { | ||||
|   hashtag: ImmutablePropTypes.map.isRequired, | ||||
| }; | ||||
|  | ||||
| const Hashtag = ({ name, href, to, people, uses, history, className }) => ( | ||||
|   <div className={classNames('trends__item', className)}> | ||||
|     <div className='trends__item__name'> | ||||
|       <Permalink | ||||
|         href={hashtag.get('url')} | ||||
|         to={`/tags/${hashtag.get('name')}`} | ||||
|       > | ||||
|         #<span>{hashtag.get('name')}</span> | ||||
|       <Permalink href={href} to={to}> | ||||
|         {name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />} | ||||
|       </Permalink> | ||||
|  | ||||
|       <ShortNumber | ||||
|         value={ | ||||
|           hashtag.getIn(['history', 0, 'accounts']) * 1 + | ||||
|           hashtag.getIn(['history', 1, 'accounts']) * 1 | ||||
|         } | ||||
|         renderer={accountsCountRenderer} | ||||
|       /> | ||||
|       {typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />} | ||||
|     </div> | ||||
|  | ||||
|     <div className='trends__item__current'> | ||||
|       <ShortNumber | ||||
|         value={ | ||||
|           hashtag.getIn(['history', 0, 'uses']) * 1 + | ||||
|           hashtag.getIn(['history', 1, 'uses']) * 1 | ||||
|         } | ||||
|       /> | ||||
|       {typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />} | ||||
|     </div> | ||||
|  | ||||
|     <div className='trends__item__sparkline'> | ||||
|       <SilentErrorBoundary> | ||||
|         <Sparklines | ||||
|           width={50} | ||||
|           height={28} | ||||
|           data={hashtag | ||||
|             .get('history') | ||||
|             .reverse() | ||||
|             .map((day) => day.get('uses')) | ||||
|             .toArray()} | ||||
|         > | ||||
|         <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}> | ||||
|           <SparklinesCurve style={{ fill: 'none' }} /> | ||||
|         </Sparklines> | ||||
|       </SilentErrorBoundary> | ||||
| @@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => ( | ||||
| ); | ||||
|  | ||||
| Hashtag.propTypes = { | ||||
|   hashtag: ImmutablePropTypes.map.isRequired, | ||||
|   name: PropTypes.string, | ||||
|   href: PropTypes.string, | ||||
|   to: PropTypes.string, | ||||
|   people: PropTypes.number, | ||||
|   uses: PropTypes.number, | ||||
|   history: PropTypes.arrayOf(PropTypes.number), | ||||
|   className: PropTypes.string, | ||||
| }; | ||||
|  | ||||
| export default Hashtag; | ||||
|   | ||||
							
								
								
									
										11
									
								
								app/javascript/mastodon/components/skeleton.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/javascript/mastodon/components/skeleton.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
|  | ||||
| const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>‌</span>; | ||||
|  | ||||
| Skeleton.propTypes = { | ||||
|   width: PropTypes.number, | ||||
|   height: PropTypes.number, | ||||
| }; | ||||
|  | ||||
| export default Skeleton; | ||||
							
								
								
									
										26
									
								
								app/javascript/mastodon/containers/admin_component.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								app/javascript/mastodon/containers/admin_component.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { IntlProvider, addLocaleData } from 'react-intl'; | ||||
| import { getLocale } from '../locales'; | ||||
|  | ||||
| const { localeData, messages } = getLocale(); | ||||
| addLocaleData(localeData); | ||||
|  | ||||
| export default class AdminComponent extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     locale: PropTypes.string.isRequired, | ||||
|     children: PropTypes.node.isRequired, | ||||
|   }; | ||||
|  | ||||
|   render () { | ||||
|     const { locale, children } = this.props; | ||||
|  | ||||
|     return ( | ||||
|       <IntlProvider locale={locale} messages={messages}> | ||||
|         {children} | ||||
|       </IntlProvider> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -7,7 +7,7 @@ import { getLocale } from 'mastodon/locales'; | ||||
| import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; | ||||
| import MediaGallery from 'mastodon/components/media_gallery'; | ||||
| import Poll from 'mastodon/components/poll'; | ||||
| import Hashtag from 'mastodon/components/hashtag'; | ||||
| import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; | ||||
| import ModalRoot from 'mastodon/components/modal_root'; | ||||
| import MediaModal from 'mastodon/features/ui/components/media_modal'; | ||||
| import Video from 'mastodon/features/video'; | ||||
|   | ||||
| @@ -5,7 +5,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; | ||||
| import AccountContainer from '../../../containers/account_container'; | ||||
| import StatusContainer from '../../../containers/status_container'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import Hashtag from '../../../components/hashtag'; | ||||
| import { ImmutableHashtag as Hashtag } from '../../../components/hashtag'; | ||||
| import Icon from 'mastodon/components/icon'; | ||||
| import { searchEnabled } from '../../../initial_state'; | ||||
| import LoadMore from 'mastodon/components/load_more'; | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import React from 'react'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import Hashtag from 'mastodon/components/hashtag'; | ||||
| import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
|  | ||||
| export default class Trends extends ImmutablePureComponent { | ||||
|   | ||||
| @@ -69,3 +69,11 @@ export function pluralReady(sourceNumber, division) { | ||||
|  | ||||
|   return Math.trunc(sourceNumber / closestScale) * closestScale; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * @param {number} num | ||||
|  * @returns {number} | ||||
|  */ | ||||
| export function roundTo10(num) { | ||||
|   return Math.round(num * 0.1) / 0.1; | ||||
| } | ||||
|   | ||||
| @@ -99,4 +99,24 @@ ready(() => { | ||||
|  | ||||
|   const registrationMode = document.getElementById('form_admin_settings_registrations_mode'); | ||||
|   if (registrationMode) onChangeRegistrationMode(registrationMode); | ||||
|  | ||||
|   const React    = require('react'); | ||||
|   const ReactDOM = require('react-dom'); | ||||
|  | ||||
|   [].forEach.call(document.querySelectorAll('[data-admin-component]'), element => { | ||||
|     const componentName  = element.getAttribute('data-admin-component'); | ||||
|     const { locale, ...componentProps } = JSON.parse(element.getAttribute('data-props')); | ||||
|  | ||||
|     import('../mastodon/containers/admin_component').then(({ default: AdminComponent }) => { | ||||
|       return import('../mastodon/components/admin/' + componentName).then(({ default: Component }) => { | ||||
|         ReactDOM.render(( | ||||
|           <AdminComponent locale={locale}> | ||||
|             <Component {...componentProps} /> | ||||
|           </AdminComponent> | ||||
|         ), element); | ||||
|       }); | ||||
|     }).catch(error => { | ||||
|       console.error(error); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| @use "sass:math"; | ||||
|  | ||||
| $no-columns-breakpoint: 600px; | ||||
| $sidebar-width: 240px; | ||||
| $content-width: 840px; | ||||
| @@ -909,10 +911,197 @@ a.name-tag, | ||||
|   } | ||||
| } | ||||
|  | ||||
| .dashboard__counters.admin-account-counters { | ||||
|   margin-top: 10px; | ||||
| } | ||||
|  | ||||
| .account-badges { | ||||
|   margin: -2px 0; | ||||
| } | ||||
|  | ||||
| .dashboard__counters.admin-account-counters { | ||||
|   margin-top: 10px; | ||||
| .retention { | ||||
|   &__table { | ||||
|     &__number { | ||||
|       color: $secondary-text-color; | ||||
|       padding: 10px; | ||||
|     } | ||||
|  | ||||
|     &__date { | ||||
|       white-space: nowrap; | ||||
|       padding: 10px 0; | ||||
|       text-align: left; | ||||
|       min-width: 120px; | ||||
|  | ||||
|       &.retention__table__average { | ||||
|         font-weight: 700; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__size { | ||||
|       text-align: center; | ||||
|       padding: 10px; | ||||
|     } | ||||
|  | ||||
|     &__label { | ||||
|       font-weight: 700; | ||||
|       color: $darker-text-color; | ||||
|     } | ||||
|  | ||||
|     &__box { | ||||
|       box-sizing: border-box; | ||||
|       background: $ui-highlight-color; | ||||
|       padding: 10px; | ||||
|       font-weight: 500; | ||||
|       color: $primary-text-color; | ||||
|       width: 52px; | ||||
|       margin: 1px; | ||||
|  | ||||
|       @for $i from 0 through 10 { | ||||
|         &--#{10 * $i} { | ||||
|           background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10))); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .sparkline { | ||||
|   display: block; | ||||
|   text-decoration: none; | ||||
|   background: lighten($ui-base-color, 4%); | ||||
|   border-radius: 4px; | ||||
|   padding: 0; | ||||
|   position: relative; | ||||
|   padding-bottom: 55px + 20px; | ||||
|   overflow: hidden; | ||||
|  | ||||
|   &__value { | ||||
|     display: flex; | ||||
|     line-height: 33px; | ||||
|     align-items: flex-end; | ||||
|     padding: 20px; | ||||
|     padding-bottom: 10px; | ||||
|  | ||||
|     &__total { | ||||
|       display: block; | ||||
|       margin-right: 10px; | ||||
|       font-weight: 500; | ||||
|       font-size: 28px; | ||||
|       color: $primary-text-color; | ||||
|     } | ||||
|  | ||||
|     &__change { | ||||
|       display: block; | ||||
|       font-weight: 500; | ||||
|       font-size: 18px; | ||||
|       color: $darker-text-color; | ||||
|       margin-bottom: -3px; | ||||
|  | ||||
|       &.positive { | ||||
|         color: $valid-value-color; | ||||
|       } | ||||
|  | ||||
|       &.negative { | ||||
|         color: $error-value-color; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &__label { | ||||
|     padding: 0 20px; | ||||
|     padding-bottom: 10px; | ||||
|     text-transform: uppercase; | ||||
|     color: $darker-text-color; | ||||
|     font-weight: 500; | ||||
|   } | ||||
|  | ||||
|   &__graph { | ||||
|     position: absolute; | ||||
|     bottom: 0; | ||||
|  | ||||
|     svg { | ||||
|       display: block; | ||||
|       margin: 0; | ||||
|     } | ||||
|  | ||||
|     path:first-child { | ||||
|       fill: rgba($highlight-text-color, 0.25) !important; | ||||
|       fill-opacity: 1 !important; | ||||
|     } | ||||
|  | ||||
|     path:last-child { | ||||
|       stroke: lighten($highlight-text-color, 6%) !important; | ||||
|       fill: none !important; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| a.sparkline { | ||||
|   &:hover, | ||||
|   &:focus, | ||||
|   &:active { | ||||
|     background: lighten($ui-base-color, 6%); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .skeleton { | ||||
|   background-color: lighten($ui-base-color, 8%); | ||||
|   background-image: linear-gradient(90deg, lighten($ui-base-color, 8%), lighten($ui-base-color, 12%), lighten($ui-base-color, 8%)); | ||||
|   background-size: 200px 100%; | ||||
|   background-repeat: no-repeat; | ||||
|   border-radius: 4px; | ||||
|   display: inline-block; | ||||
|   line-height: 1; | ||||
|   width: 100%; | ||||
|   animation: skeleton 1.2s ease-in-out infinite; | ||||
| } | ||||
|  | ||||
| @keyframes skeleton { | ||||
|   0% { | ||||
|     background-position: -200px 0; | ||||
|   } | ||||
|  | ||||
|   100% { | ||||
|     background-position: calc(200px + 100%) 0; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .dimension { | ||||
|   table { | ||||
|     width: 100%; | ||||
|   } | ||||
|  | ||||
|   &__item { | ||||
|     border-bottom: 1px solid lighten($ui-base-color, 4%); | ||||
|  | ||||
|     &__key { | ||||
|       font-weight: 500; | ||||
|       padding: 11px 10px; | ||||
|     } | ||||
|  | ||||
|     &__value { | ||||
|       text-align: right; | ||||
|       color: $darker-text-color; | ||||
|       padding: 11px 10px; | ||||
|     } | ||||
|  | ||||
|     &__indicator { | ||||
|       display: inline-block; | ||||
|       width: 8px; | ||||
|       height: 8px; | ||||
|       border-radius: 50%; | ||||
|       background: $ui-highlight-color; | ||||
|       margin-right: 10px; | ||||
|  | ||||
|       @for $i from 0 through 10 { | ||||
|         &--#{10 * $i} { | ||||
|           background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10))); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:last-child { | ||||
|       border-bottom: 0; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -6955,7 +6955,6 @@ noscript { | ||||
|     &__current { | ||||
|       flex: 0 0 auto; | ||||
|       font-size: 24px; | ||||
|       line-height: 36px; | ||||
|       font-weight: 500; | ||||
|       text-align: right; | ||||
|       padding-right: 15px; | ||||
| @@ -6977,6 +6976,58 @@ noscript { | ||||
|         fill: none !important; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &--requires-review { | ||||
|       .trends__item__name { | ||||
|         color: $gold-star; | ||||
|  | ||||
|         a { | ||||
|           color: $gold-star; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       .trends__item__current { | ||||
|         color: $gold-star; | ||||
|       } | ||||
|  | ||||
|       .trends__item__sparkline { | ||||
|         path:first-child { | ||||
|           fill: rgba($gold-star, 0.25) !important; | ||||
|         } | ||||
|  | ||||
|         path:last-child { | ||||
|           stroke: lighten($gold-star, 6%) !important; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &--disabled { | ||||
|       .trends__item__name { | ||||
|         color: lighten($ui-base-color, 12%); | ||||
|  | ||||
|         a { | ||||
|           color: lighten($ui-base-color, 12%); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       .trends__item__current { | ||||
|         color: lighten($ui-base-color, 12%); | ||||
|       } | ||||
|  | ||||
|       .trends__item__sparkline { | ||||
|         path:first-child { | ||||
|           fill: rgba(lighten($ui-base-color, 12%), 0.25) !important; | ||||
|         } | ||||
|  | ||||
|         path:last-child { | ||||
|           stroke: lighten(lighten($ui-base-color, 12%), 6%) !important; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &--compact &__item { | ||||
|     padding: 10px; | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -56,23 +56,56 @@ | ||||
|   } | ||||
| } | ||||
|  | ||||
| .dashboard__widgets { | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
|   margin: 0 -5px; | ||||
| .dashboard { | ||||
|   display: grid; | ||||
|   grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); | ||||
|   grid-gap: 10px; | ||||
|  | ||||
|   & > div { | ||||
|     flex: 0 0 33.333%; | ||||
|     margin-bottom: 20px; | ||||
|   &__item { | ||||
|     &--span-double-column { | ||||
|       grid-column: span 2; | ||||
|     } | ||||
|  | ||||
|     & > div { | ||||
|       padding: 0 5px; | ||||
|     &--span-double-row { | ||||
|       grid-row: span 2; | ||||
|     } | ||||
|  | ||||
|     h4 { | ||||
|       padding-top: 20px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   a:not(.name-tag) { | ||||
|     color: $ui-secondary-color; | ||||
|     font-weight: 500; | ||||
|   &__quick-access { | ||||
|     display: flex; | ||||
|     align-items: baseline; | ||||
|     border-radius: 4px; | ||||
|     background: $ui-highlight-color; | ||||
|     color: $primary-text-color; | ||||
|     transition: all 100ms ease-in; | ||||
|     font-size: 14px; | ||||
|     padding: 0 16px; | ||||
|     line-height: 36px; | ||||
|     height: 36px; | ||||
|     text-decoration: none; | ||||
|     margin-bottom: 4px; | ||||
|  | ||||
|     &:active, | ||||
|     &:focus, | ||||
|     &:hover { | ||||
|       background-color: lighten($ui-highlight-color, 10%); | ||||
|       transition: all 200ms ease-out; | ||||
|     } | ||||
|  | ||||
|     span { | ||||
|       flex: 1 1 auto; | ||||
|     } | ||||
|  | ||||
|     .fa { | ||||
|       flex: 0 0 auto; | ||||
|     } | ||||
|  | ||||
|     strong { | ||||
|       font-weight: 700; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,29 +1,73 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class ActivityTracker | ||||
|   include Redisable | ||||
|  | ||||
|   EXPIRE_AFTER = 6.months.seconds | ||||
|  | ||||
|   def initialize(prefix, type) | ||||
|     @prefix = prefix | ||||
|     @type   = type | ||||
|   end | ||||
|  | ||||
|   def add(value = 1, at_time = Time.now.utc) | ||||
|     key = key_at(at_time) | ||||
|  | ||||
|     case @type | ||||
|     when :basic | ||||
|       redis.incrby(key, value) | ||||
|     when :unique | ||||
|       redis.pfadd(key, value) | ||||
|     end | ||||
|  | ||||
|     redis.expire(key, EXPIRE_AFTER) | ||||
|   end | ||||
|  | ||||
|   def get(start_at, end_at = Time.now.utc) | ||||
|     (start_at.to_date...end_at.to_date).map do |date| | ||||
|       key = key_at(date.to_time(:utc)) | ||||
|  | ||||
|       value = begin | ||||
|         case @type | ||||
|         when :basic | ||||
|           redis.get(key).to_i | ||||
|         when :unique | ||||
|           redis.pfcount(key) | ||||
|         end | ||||
|       end | ||||
|  | ||||
|       [date, value] | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def sum(start_at, end_at = Time.now.utc) | ||||
|     keys = (start_at.to_date...end_at.to_date).flat_map { |date| [key_at(date.to_time(:utc)), legacy_key_at(date)] }.uniq | ||||
|  | ||||
|     case @type | ||||
|     when :basic | ||||
|       redis.mget(*keys).map(&:to_i).sum | ||||
|     when :unique | ||||
|       redis.pfcount(*keys) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   class << self | ||||
|     include Redisable | ||||
|  | ||||
|     def increment(prefix) | ||||
|       key = [prefix, current_week].join(':') | ||||
|  | ||||
|       redis.incrby(key, 1) | ||||
|       redis.expire(key, EXPIRE_AFTER) | ||||
|       new(prefix, :basic).add | ||||
|     end | ||||
|  | ||||
|     def record(prefix, value) | ||||
|       key = [prefix, current_week].join(':') | ||||
|  | ||||
|       redis.pfadd(key, value) | ||||
|       redis.expire(key, EXPIRE_AFTER) | ||||
|     end | ||||
|  | ||||
|     private | ||||
|  | ||||
|     def current_week | ||||
|       Time.zone.today.cweek | ||||
|       new(prefix, :unique).add(value) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def key_at(at_time) | ||||
|     "#{@prefix}:#{at_time.beginning_of_day.to_i}" | ||||
|   end | ||||
|  | ||||
|   def legacy_key_at(at_time) | ||||
|     "#{@prefix}:#{at_time.to_date.cweek}" | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										15
									
								
								app/lib/admin/metrics/dimension.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/lib/admin/metrics/dimension.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Dimension | ||||
|   DIMENSIONS = { | ||||
|     languages: Admin::Metrics::Dimension::LanguagesDimension, | ||||
|     sources: Admin::Metrics::Dimension::SourcesDimension, | ||||
|     servers: Admin::Metrics::Dimension::ServersDimension, | ||||
|     space_usage: Admin::Metrics::Dimension::SpaceUsageDimension, | ||||
|     software_versions: Admin::Metrics::Dimension::SoftwareVersionsDimension, | ||||
|   }.freeze | ||||
|  | ||||
|   def self.retrieve(dimension_keys, start_at, end_at, limit) | ||||
|     Array(dimension_keys).map { |key| DIMENSIONS[key.to_sym]&.new(start_at, end_at, limit) }.compact | ||||
|   end | ||||
| end | ||||
							
								
								
									
										31
									
								
								app/lib/admin/metrics/dimension/base_dimension.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								app/lib/admin/metrics/dimension/base_dimension.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Dimension::BaseDimension | ||||
|   def initialize(start_at, end_at, limit) | ||||
|     @start_at = start_at&.to_datetime | ||||
|     @end_at   = end_at&.to_datetime | ||||
|     @limit    = limit&.to_i | ||||
|   end | ||||
|  | ||||
|   def key | ||||
|     raise NotImplementedError | ||||
|   end | ||||
|  | ||||
|   def data | ||||
|     raise NotImplementedError | ||||
|   end | ||||
|  | ||||
|   def self.model_name | ||||
|     self.class.name | ||||
|   end | ||||
|  | ||||
|   def read_attribute_for_serialization(key) | ||||
|     send(key) if respond_to?(key) | ||||
|   end | ||||
|  | ||||
|   protected | ||||
|  | ||||
|   def time_period | ||||
|     (@start_at...@end_at) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										23
									
								
								app/lib/admin/metrics/dimension/languages_dimension.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/lib/admin/metrics/dimension/languages_dimension.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension::BaseDimension | ||||
|   def key | ||||
|     'languages' | ||||
|   end | ||||
|  | ||||
|   def data | ||||
|     sql = <<-SQL.squish | ||||
|       SELECT locale, count(*) AS value | ||||
|       FROM users | ||||
|       WHERE current_sign_in_at BETWEEN $1 AND $2 | ||||
|         AND locale IS NOT NULL | ||||
|       GROUP BY locale | ||||
|       ORDER BY count(*) DESC | ||||
|       LIMIT $3 | ||||
|     SQL | ||||
|  | ||||
|     rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]]) | ||||
|  | ||||
|     rows.map { |row| { key: row['locale'], human_key: SettingsHelper::HUMAN_LOCALES[row['locale'].to_sym], value: row['value'].to_s } } | ||||
|   end | ||||
| end | ||||
							
								
								
									
										23
									
								
								app/lib/admin/metrics/dimension/servers_dimension.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/lib/admin/metrics/dimension/servers_dimension.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Dimension::ServersDimension < Admin::Metrics::Dimension::BaseDimension | ||||
|   def key | ||||
|     'servers' | ||||
|   end | ||||
|  | ||||
|   def data | ||||
|     sql = <<-SQL.squish | ||||
|       SELECT accounts.domain, count(*) AS value | ||||
|       FROM statuses | ||||
|       INNER JOIN accounts ON accounts.id = statuses.account_id | ||||
|       WHERE statuses.id BETWEEN $1 AND $2 | ||||
|       GROUP BY accounts.domain | ||||
|       ORDER BY count(*) DESC | ||||
|       LIMIT $3 | ||||
|     SQL | ||||
|  | ||||
|     rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, Mastodon::Snowflake.id_at(@start_at)], [nil, Mastodon::Snowflake.id_at(@end_at)], [nil, @limit]]) | ||||
|  | ||||
|     rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } } | ||||
|   end | ||||
| end | ||||
| @@ -0,0 +1,69 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Dimension::SoftwareVersionsDimension < Admin::Metrics::Dimension::BaseDimension | ||||
|   include Redisable | ||||
|  | ||||
|   def key | ||||
|     'software_versions' | ||||
|   end | ||||
|  | ||||
|   def data | ||||
|     [mastodon_version, ruby_version, postgresql_version, redis_version] | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def mastodon_version | ||||
|     value = Mastodon::Version.to_s | ||||
|  | ||||
|     { | ||||
|       key: 'mastodon', | ||||
|       human_key: 'Mastodon', | ||||
|       value: value, | ||||
|       human_value: value, | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   def ruby_version | ||||
|     value = "#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}" | ||||
|  | ||||
|     { | ||||
|       key: 'ruby', | ||||
|       human_key: 'Ruby', | ||||
|       value: value, | ||||
|       human_value: value, | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   def postgresql_version | ||||
|     value = ActiveRecord::Base.connection.execute('SELECT VERSION()').first['version'].match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1] | ||||
|  | ||||
|     { | ||||
|       key: 'postgresql', | ||||
|       human_key: 'PostgreSQL', | ||||
|       value: value, | ||||
|       human_value: value, | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   def redis_version | ||||
|     value = redis_info['redis_version'] | ||||
|  | ||||
|     { | ||||
|       key: 'redis', | ||||
|       human_key: 'Redis', | ||||
|       value: value, | ||||
|       human_value: value, | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   def redis_info | ||||
|     @redis_info ||= begin | ||||
|       if redis.is_a?(Redis::Namespace) | ||||
|         redis.redis.info | ||||
|       else | ||||
|         redis.info | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										23
									
								
								app/lib/admin/metrics/dimension/sources_dimension.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/lib/admin/metrics/dimension/sources_dimension.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Dimension::SourcesDimension < Admin::Metrics::Dimension::BaseDimension | ||||
|   def key | ||||
|     'sources' | ||||
|   end | ||||
|  | ||||
|   def data | ||||
|     sql = <<-SQL.squish | ||||
|       SELECT oauth_applications.name, count(*) AS value | ||||
|       FROM users | ||||
|       LEFT JOIN oauth_applications ON oauth_applications.id = users.created_by_application_id | ||||
|       WHERE users.created_at BETWEEN $1 AND $2 | ||||
|       GROUP BY oauth_applications.name | ||||
|       ORDER BY count(*) DESC | ||||
|       LIMIT $3 | ||||
|     SQL | ||||
|  | ||||
|     rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]]) | ||||
|  | ||||
|     rows.map { |row| { key: row['name'] || 'web', human_key: row['name'] || I18n.t('admin.dashboard.website'), value: row['value'].to_s } } | ||||
|   end | ||||
| end | ||||
							
								
								
									
										70
									
								
								app/lib/admin/metrics/dimension/space_usage_dimension.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								app/lib/admin/metrics/dimension/space_usage_dimension.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Dimension::SpaceUsageDimension < Admin::Metrics::Dimension::BaseDimension | ||||
|   include Redisable | ||||
|   include ActionView::Helpers::NumberHelper | ||||
|  | ||||
|   def key | ||||
|     'space_usage' | ||||
|   end | ||||
|  | ||||
|   def data | ||||
|     [postgresql_size, redis_size, media_size] | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def postgresql_size | ||||
|     value = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size'] | ||||
|  | ||||
|     { | ||||
|       key: 'postgresql', | ||||
|       human_key: 'PostgreSQL', | ||||
|       value: value.to_s, | ||||
|       unit: 'bytes', | ||||
|       human_value: number_to_human_size(value), | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   def redis_size | ||||
|     value = redis_info['used_memory'] | ||||
|  | ||||
|     { | ||||
|       key: 'redis', | ||||
|       human_key: 'Redis', | ||||
|       value: value.to_s, | ||||
|       unit: 'bytes', | ||||
|       human_value: number_to_human_size(value), | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   def media_size | ||||
|     value = [ | ||||
|       MediaAttachment.sum(Arel.sql('COALESCE(file_file_size, 0) + COALESCE(thumbnail_file_size, 0)')), | ||||
|       CustomEmoji.sum(:image_file_size), | ||||
|       PreviewCard.sum(:image_file_size), | ||||
|       Account.sum(Arel.sql('COALESCE(avatar_file_size, 0) + COALESCE(header_file_size, 0)')), | ||||
|       Backup.sum(:dump_file_size), | ||||
|       Import.sum(:data_file_size), | ||||
|       SiteUpload.sum(:file_file_size), | ||||
|     ].sum | ||||
|  | ||||
|     { | ||||
|       key: 'media', | ||||
|       human_key: I18n.t('admin.dashboard.media_storage'), | ||||
|       value: value.to_s, | ||||
|       unit: 'bytes', | ||||
|       human_value: number_to_human_size(value), | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   def redis_info | ||||
|     @redis_info ||= begin | ||||
|       if redis.is_a?(Redis::Namespace) | ||||
|         redis.redis.info | ||||
|       else | ||||
|         redis.info | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										15
									
								
								app/lib/admin/metrics/measure.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/lib/admin/metrics/measure.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Measure | ||||
|   MEASURES = { | ||||
|     active_users: Admin::Metrics::Measure::ActiveUsersMeasure, | ||||
|     new_users: Admin::Metrics::Measure::NewUsersMeasure, | ||||
|     interactions: Admin::Metrics::Measure::InteractionsMeasure, | ||||
|     opened_reports: Admin::Metrics::Measure::OpenedReportsMeasure, | ||||
|     resolved_reports: Admin::Metrics::Measure::ResolvedReportsMeasure, | ||||
|   }.freeze | ||||
|  | ||||
|   def self.retrieve(measure_keys, start_at, end_at) | ||||
|     Array(measure_keys).map { |key| MEASURES[key.to_sym]&.new(start_at, end_at) }.compact | ||||
|   end | ||||
| end | ||||
							
								
								
									
										33
									
								
								app/lib/admin/metrics/measure/active_users_measure.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								app/lib/admin/metrics/measure/active_users_measure.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Measure::ActiveUsersMeasure < Admin::Metrics::Measure::BaseMeasure | ||||
|   def key | ||||
|     'active_users' | ||||
|   end | ||||
|  | ||||
|   def total | ||||
|     activity_tracker.sum(time_period.first, time_period.last) | ||||
|   end | ||||
|  | ||||
|   def previous_total | ||||
|     activity_tracker.sum(previous_time_period.first, previous_time_period.last) | ||||
|   end | ||||
|  | ||||
|   def data | ||||
|     activity_tracker.get(time_period.first, time_period.last).map { |date, value| { date: date.to_time(:utc).iso8601, value: value.to_s } } | ||||
|   end | ||||
|  | ||||
|   protected | ||||
|  | ||||
|   def activity_tracker | ||||
|     @activity_tracker ||= ActivityTracker.new('activity:logins', :unique) | ||||
|   end | ||||
|  | ||||
|   def time_period | ||||
|     (@start_at.to_date...@end_at.to_date) | ||||
|   end | ||||
|  | ||||
|   def previous_time_period | ||||
|     ((@start_at.to_date - length_of_period)...(@end_at.to_date - length_of_period)) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										46
									
								
								app/lib/admin/metrics/measure/base_measure.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								app/lib/admin/metrics/measure/base_measure.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Measure::BaseMeasure | ||||
|   def initialize(start_at, end_at) | ||||
|     @start_at = start_at&.to_datetime | ||||
|     @end_at   = end_at&.to_datetime | ||||
|   end | ||||
|  | ||||
|   def key | ||||
|     raise NotImplementedError | ||||
|   end | ||||
|  | ||||
|   def total | ||||
|     raise NotImplementedError | ||||
|   end | ||||
|  | ||||
|   def previous_total | ||||
|     raise NotImplementedError | ||||
|   end | ||||
|  | ||||
|   def data | ||||
|     raise NotImplementedError | ||||
|   end | ||||
|  | ||||
|   def self.model_name | ||||
|     self.class.name | ||||
|   end | ||||
|  | ||||
|   def read_attribute_for_serialization(key) | ||||
|     send(key) if respond_to?(key) | ||||
|   end | ||||
|  | ||||
|   protected | ||||
|  | ||||
|   def time_period | ||||
|     (@start_at...@end_at) | ||||
|   end | ||||
|  | ||||
|   def previous_time_period | ||||
|     ((@start_at - length_of_period)...(@end_at - length_of_period)) | ||||
|   end | ||||
|  | ||||
|   def length_of_period | ||||
|     @length_of_period ||= @end_at - @start_at | ||||
|   end | ||||
| end | ||||
							
								
								
									
										33
									
								
								app/lib/admin/metrics/measure/interactions_measure.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								app/lib/admin/metrics/measure/interactions_measure.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Measure::InteractionsMeasure < Admin::Metrics::Measure::BaseMeasure | ||||
|   def key | ||||
|     'interactions' | ||||
|   end | ||||
|  | ||||
|   def total | ||||
|     activity_tracker.sum(time_period.first, time_period.last) | ||||
|   end | ||||
|  | ||||
|   def previous_total | ||||
|     activity_tracker.sum(previous_time_period.first, previous_time_period.last) | ||||
|   end | ||||
|  | ||||
|   def data | ||||
|     activity_tracker.get(time_period.first, time_period.last).map { |date, value| { date: date.to_time(:utc).iso8601, value: value.to_s } } | ||||
|   end | ||||
|  | ||||
|   protected | ||||
|  | ||||
|   def activity_tracker | ||||
|     @activity_tracker ||= ActivityTracker.new('activity:interactions', :basic) | ||||
|   end | ||||
|  | ||||
|   def time_period | ||||
|     (@start_at.to_date...@end_at.to_date) | ||||
|   end | ||||
|  | ||||
|   def previous_time_period | ||||
|     ((@start_at.to_date - length_of_period)...(@end_at.to_date - length_of_period)) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										35
									
								
								app/lib/admin/metrics/measure/new_users_measure.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								app/lib/admin/metrics/measure/new_users_measure.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Measure::NewUsersMeasure < Admin::Metrics::Measure::BaseMeasure | ||||
|   def key | ||||
|     'new_users' | ||||
|   end | ||||
|  | ||||
|   def total | ||||
|     User.where(created_at: time_period).count | ||||
|   end | ||||
|  | ||||
|   def previous_total | ||||
|     User.where(created_at: previous_time_period).count | ||||
|   end | ||||
|  | ||||
|   def data | ||||
|     sql = <<-SQL.squish | ||||
|       SELECT axis.*, ( | ||||
|         WITH new_users AS ( | ||||
|           SELECT users.id | ||||
|           FROM users | ||||
|           WHERE date_trunc('day', users.created_at)::date = axis.period | ||||
|         ) | ||||
|         SELECT count(*) FROM new_users | ||||
|       ) AS value | ||||
|       FROM ( | ||||
|         SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period | ||||
|       ) AS axis | ||||
|     SQL | ||||
|  | ||||
|     rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]]) | ||||
|  | ||||
|     rows.map { |row| { date: row['period'], value: row['value'].to_s } } | ||||
|   end | ||||
| end | ||||
							
								
								
									
										35
									
								
								app/lib/admin/metrics/measure/opened_reports_measure.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								app/lib/admin/metrics/measure/opened_reports_measure.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Measure::OpenedReportsMeasure < Admin::Metrics::Measure::BaseMeasure | ||||
|   def key | ||||
|     'opened_reports' | ||||
|   end | ||||
|  | ||||
|   def total | ||||
|     Report.where(created_at: time_period).count | ||||
|   end | ||||
|  | ||||
|   def previous_total | ||||
|     Report.where(created_at: previous_time_period).count | ||||
|   end | ||||
|  | ||||
|   def data | ||||
|     sql = <<-SQL.squish | ||||
|       SELECT axis.*, ( | ||||
|         WITH new_reports AS ( | ||||
|           SELECT reports.id | ||||
|           FROM reports | ||||
|           WHERE date_trunc('day', reports.created_at)::date = axis.period | ||||
|         ) | ||||
|         SELECT count(*) FROM new_reports | ||||
|       ) AS value | ||||
|       FROM ( | ||||
|         SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period | ||||
|       ) AS axis | ||||
|     SQL | ||||
|  | ||||
|     rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]]) | ||||
|  | ||||
|     rows.map { |row| { date: row['period'], value: row['value'].to_s } } | ||||
|   end | ||||
| end | ||||
							
								
								
									
										36
									
								
								app/lib/admin/metrics/measure/resolved_reports_measure.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								app/lib/admin/metrics/measure/resolved_reports_measure.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Measure::ResolvedReportsMeasure < Admin::Metrics::Measure::BaseMeasure | ||||
|   def key | ||||
|     'resolved_reports' | ||||
|   end | ||||
|  | ||||
|   def total | ||||
|     Report.resolved.where(updated_at: time_period).count | ||||
|   end | ||||
|  | ||||
|   def previous_total | ||||
|     Report.resolved.where(updated_at: previous_time_period).count | ||||
|   end | ||||
|  | ||||
|   def data | ||||
|     sql = <<-SQL.squish | ||||
|       SELECT axis.*, ( | ||||
|         WITH resolved_reports AS ( | ||||
|           SELECT reports.id | ||||
|           FROM reports | ||||
|           WHERE action_taken | ||||
|             AND date_trunc('day', reports.updated_at)::date = axis.period | ||||
|         ) | ||||
|         SELECT count(*) FROM resolved_reports | ||||
|       ) AS value | ||||
|       FROM ( | ||||
|         SELECT generate_series(date_trunc('day', $1::timestamp)::date, date_trunc('day', $2::timestamp)::date, interval '1 day') AS period | ||||
|       ) AS axis | ||||
|     SQL | ||||
|  | ||||
|     rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at]]) | ||||
|  | ||||
|     rows.map { |row| { date: row['period'], value: row['value'].to_s } } | ||||
|   end | ||||
| end | ||||
							
								
								
									
										67
									
								
								app/lib/admin/metrics/retention.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								app/lib/admin/metrics/retention.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::Metrics::Retention | ||||
|   class Cohort < ActiveModelSerializers::Model | ||||
|     attributes :period, :frequency, :data | ||||
|   end | ||||
|  | ||||
|   class CohortData < ActiveModelSerializers::Model | ||||
|     attributes :date, :percent, :value | ||||
|   end | ||||
|  | ||||
|   def initialize(start_at, end_at, frequency) | ||||
|     @start_at  = start_at&.to_date | ||||
|     @end_at    = end_at&.to_date | ||||
|     @frequency = %w(day month).include?(frequency) ? frequency : 'day' | ||||
|   end | ||||
|  | ||||
|   def cohorts | ||||
|     sql = <<-SQL.squish | ||||
|       SELECT axis.*, ( | ||||
|         WITH new_users AS ( | ||||
|           SELECT users.id | ||||
|           FROM users | ||||
|           WHERE date_trunc($3, users.created_at)::date = axis.cohort_period | ||||
|         ), | ||||
|         retained_users AS ( | ||||
|           SELECT users.id | ||||
|           FROM users | ||||
|           INNER JOIN new_users on new_users.id = users.id | ||||
|           WHERE date_trunc($3, users.current_sign_in_at) >= axis.retention_period | ||||
|         ) | ||||
|         SELECT ARRAY[count(*), (count(*) + 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 | ||||
| @@ -24,8 +24,8 @@ class InstancePresenter | ||||
|     Rails.cache.fetch('user_count') { User.confirmed.joins(:account).merge(Account.without_suspended).count } | ||||
|   end | ||||
|  | ||||
|   def active_user_count(weeks = 4) | ||||
|     Rails.cache.fetch("active_user_count/#{weeks}") { Redis.current.pfcount(*(0...weeks).map { |i| "activity:logins:#{i.weeks.ago.utc.to_date.cweek}" }) } | ||||
|   def active_user_count(num_weeks = 4) | ||||
|     Rails.cache.fetch("active_user_count/#{num_weeks}") { ActivityTracker.new('activity:logins', :unique).sum(num_weeks.weeks.ago) } | ||||
|   end | ||||
|  | ||||
|   def status_count | ||||
|   | ||||
							
								
								
									
										19
									
								
								app/serializers/rest/admin/cohort_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/serializers/rest/admin/cohort_serializer.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class REST::Admin::CohortSerializer < ActiveModel::Serializer | ||||
|   attributes :period, :frequency | ||||
|  | ||||
|   class CohortDataSerializer < ActiveModel::Serializer | ||||
|     attributes :date, :percent, :value | ||||
|  | ||||
|     def date | ||||
|       object.date.iso8601 | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   has_many :data, serializer: CohortDataSerializer | ||||
|  | ||||
|   def period | ||||
|     object.period.iso8601 | ||||
|   end | ||||
| end | ||||
							
								
								
									
										5
									
								
								app/serializers/rest/admin/dimension_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								app/serializers/rest/admin/dimension_serializer.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class REST::Admin::DimensionSerializer < ActiveModel::Serializer | ||||
|   attributes :key, :data | ||||
| end | ||||
							
								
								
									
										13
									
								
								app/serializers/rest/admin/measure_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								app/serializers/rest/admin/measure_serializer.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class REST::Admin::MeasureSerializer < ActiveModel::Serializer | ||||
|   attributes :key, :total, :previous_total, :data | ||||
|  | ||||
|   def total | ||||
|     object.total.to_s | ||||
|   end | ||||
|  | ||||
|   def previous_total | ||||
|     object.previous_total.to_s | ||||
|   end | ||||
| end | ||||
							
								
								
									
										13
									
								
								app/serializers/rest/admin/tag_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								app/serializers/rest/admin/tag_serializer.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class REST::Admin::TagSerializer < REST::TagSerializer | ||||
|   attributes :id, :trendable, :usable, :requires_review | ||||
|  | ||||
|   def id | ||||
|     object.id.to_s | ||||
|   end | ||||
|  | ||||
|   def requires_review | ||||
|     object.requires_review? | ||||
|   end | ||||
| end | ||||
| @@ -1,6 +1,14 @@ | ||||
| - content_for :header_tags do | ||||
|   = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' | ||||
|  | ||||
| - content_for :page_title do | ||||
|   = t('admin.dashboard.title') | ||||
|  | ||||
| - content_for :heading_actions do | ||||
|   = l(@time_period.first) | ||||
|   = ' - ' | ||||
|   = l(@time_period.last) | ||||
|  | ||||
| - unless @system_checks.empty? | ||||
|   .flash-message-stack | ||||
|     - @system_checks.each do |message| | ||||
| @@ -9,131 +17,52 @@ | ||||
|         - if message.action | ||||
|           = link_to t("admin.system_checks.#{message.key}.action"), message.action | ||||
|  | ||||
| .dashboard__counters | ||||
|   %div | ||||
|     = link_to admin_accounts_url(local: 1, recent: 1) do | ||||
|       .dashboard__counters__num{ title: number_with_delimiter(@users_count, strip_insignificant_zeros: true) } | ||||
|         = friendly_number_to_human @users_count | ||||
|       .dashboard__counters__label= t 'admin.dashboard.total_users' | ||||
|   %div | ||||
|     %div | ||||
|       .dashboard__counters__num{ title: number_with_delimiter(@registrations_week, strip_insignificant_zeros: true) } | ||||
|         = friendly_number_to_human @registrations_week | ||||
|       .dashboard__counters__label= t 'admin.dashboard.week_users_new' | ||||
|   %div | ||||
|     %div | ||||
|       .dashboard__counters__num{ title: number_with_delimiter(@logins_week, strip_insignificant_zeros: true) } | ||||
|         = friendly_number_to_human @logins_week | ||||
|       .dashboard__counters__label= t 'admin.dashboard.week_users_active' | ||||
|   %div | ||||
|     = link_to admin_pending_accounts_path do | ||||
|       .dashboard__counters__num{ title: number_with_delimiter(@pending_users_count, strip_insignificant_zeros: true) } | ||||
|         = friendly_number_to_human @pending_users_count | ||||
|       .dashboard__counters__label= t 'admin.dashboard.pending_users' | ||||
|   %div | ||||
|     = link_to admin_reports_url do | ||||
|       .dashboard__counters__num{ title: number_with_delimiter(@reports_count, strip_insignificant_zeros: true) } | ||||
|         = friendly_number_to_human @reports_count | ||||
|       .dashboard__counters__label= t 'admin.dashboard.open_reports' | ||||
|   %div | ||||
|     = link_to admin_tags_path(pending_review: '1') do | ||||
|       .dashboard__counters__num{ title: number_with_delimiter(@pending_tags_count, strip_insignificant_zeros: true) } | ||||
|         = friendly_number_to_human @pending_tags_count | ||||
|       .dashboard__counters__label= t 'admin.dashboard.pending_tags' | ||||
|   %div | ||||
|     %div | ||||
|       .dashboard__counters__num{ title: number_with_delimiter(@interactions_week, strip_insignificant_zeros: true) } | ||||
|         = friendly_number_to_human @interactions_week | ||||
|       .dashboard__counters__label= t 'admin.dashboard.week_interactions' | ||||
|   %div | ||||
|     = link_to sidekiq_url do | ||||
|       .dashboard__counters__num{ title: number_with_delimiter(@queue_backlog, strip_insignificant_zeros: true) } | ||||
|         = friendly_number_to_human @queue_backlog | ||||
|       .dashboard__counters__label= t 'admin.dashboard.backlog' | ||||
| .dashboard | ||||
|   .dashboard__item | ||||
|     = react_admin_component :counter, measure: 'new_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.new_users'), href: admin_accounts_path | ||||
|  | ||||
| .dashboard__widgets | ||||
|   .dashboard__widgets__users | ||||
|     %div | ||||
|       %h4= t 'admin.dashboard.recent_users' | ||||
|       %ul | ||||
|         - @recent_users.each do |user| | ||||
|           %li= admin_account_link_to(user.account) | ||||
|   .dashboard__item | ||||
|     = react_admin_component :counter, measure: 'active_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.active_users'), href: admin_accounts_path | ||||
|  | ||||
|   .dashboard__widgets__features | ||||
|     %div | ||||
|       %h4= t 'admin.dashboard.features' | ||||
|       %ul | ||||
|         %li | ||||
|           = feature_hint(link_to(t('admin.dashboard.feature_registrations'), edit_admin_settings_path), @registrations_enabled) | ||||
|         %li | ||||
|           = feature_hint(link_to(t('admin.dashboard.feature_invites'), edit_admin_settings_path), @invites_enabled) | ||||
|         %li | ||||
|           = feature_hint(link_to(t('admin.dashboard.feature_deletions'), edit_admin_settings_path), @deletions_enabled) | ||||
|         %li | ||||
|           = feature_hint(link_to(t('admin.dashboard.feature_profile_directory'), edit_admin_settings_path), @profile_directory) | ||||
|         %li | ||||
|           = feature_hint(link_to(t('admin.dashboard.feature_timeline_preview'), edit_admin_settings_path), @timeline_preview) | ||||
|         %li | ||||
|           = feature_hint(link_to(t('admin.dashboard.trends'), edit_admin_settings_path), @trends_enabled) | ||||
|         %li | ||||
|           = feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled) | ||||
|   .dashboard__item | ||||
|     = react_admin_component :counter, measure: 'interactions', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.interactions') | ||||
|  | ||||
|   .dashboard__widgets__versions | ||||
|     %div | ||||
|       %h4= t 'admin.dashboard.software' | ||||
|       %ul | ||||
|         %li | ||||
|           Mastodon | ||||
|           %span.pull-right= @version | ||||
|         %li | ||||
|           Ruby | ||||
|           %span.pull-right= "#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}" | ||||
|         %li | ||||
|           PostgreSQL | ||||
|           %span.pull-right= @database_version | ||||
|         %li | ||||
|           Redis | ||||
|           %span.pull-right= @redis_version | ||||
|   .dashboard__item | ||||
|     = react_admin_component :counter, measure: 'opened_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.opened_reports'), href: admin_reports_path | ||||
|  | ||||
|   .dashboard__widgets__space | ||||
|     %div | ||||
|       %h4= t 'admin.dashboard.space' | ||||
|       %ul | ||||
|         %li | ||||
|           PostgreSQL | ||||
|           %span.pull-right= number_to_human_size @database_size | ||||
|         %li | ||||
|           Redis | ||||
|           %span.pull-right= number_to_human_size @redis_size | ||||
|   .dashboard__item | ||||
|     = react_admin_component :counter, measure: 'resolved_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.resolved_reports'), href: admin_reports_path(resolved: '1') | ||||
|  | ||||
|   .dashboard__widgets__config | ||||
|     %div | ||||
|       %h4= t 'admin.dashboard.config' | ||||
|       %ul | ||||
|         %li | ||||
|           = feature_hint(t('admin.dashboard.search'), @search_enabled) | ||||
|         %li | ||||
|           = feature_hint(t('admin.dashboard.single_user_mode'), @single_user_mode) | ||||
|         %li | ||||
|           = feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch) | ||||
|         %li | ||||
|           = feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_enabled) | ||||
|         %li | ||||
|           = feature_hint('LDAP', @ldap_enabled) | ||||
|         %li | ||||
|           = feature_hint('CAS', @cas_enabled) | ||||
|         %li | ||||
|           = feature_hint('SAML', @saml_enabled) | ||||
|         %li | ||||
|           = feature_hint('PAM', @pam_enabled) | ||||
|         %li | ||||
|           = feature_hint(t('admin.dashboard.hidden_service'), @hidden_service) | ||||
|   .dashboard__item | ||||
|     = link_to admin_reports_path, class: 'dashboard__quick-access' do | ||||
|       %span= t('admin.dashboard.pending_reports_html', count: @pending_reports_count) | ||||
|       = fa_icon 'chevron-right fw' | ||||
|  | ||||
|   .dashboard__widgets__trends | ||||
|     %div | ||||
|       %h4= t 'admin.dashboard.trends' | ||||
|       %ul | ||||
|         - @trending_hashtags.each do |tag| | ||||
|           %li | ||||
|             = link_to content_tag(:span, "##{tag.name}", class: !tag.trendable? && !tag.reviewed? ? 'warning-hint' : (!tag.trendable? ? 'negative-hint' : nil)), admin_tag_path(tag.id) | ||||
|             %span.pull-right= number_with_delimiter(tag.history[0][:accounts].to_i) | ||||
|     = link_to admin_pending_accounts_path, class: 'dashboard__quick-access' do | ||||
|       %span= t('admin.dashboard.pending_users_html', count: @pending_users_count) | ||||
|       = fa_icon 'chevron-right fw' | ||||
|  | ||||
|     = link_to admin_tags_path(pending_review: '1'), class: 'dashboard__quick-access' do | ||||
|       %span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count) | ||||
|       = fa_icon 'chevron-right fw' | ||||
|  | ||||
|   .dashboard__item | ||||
|     = react_admin_component :dimension, dimension: 'sources', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.sources') | ||||
|  | ||||
|   .dashboard__item | ||||
|     = react_admin_component :dimension, dimension: 'languages', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_languages') | ||||
|  | ||||
|   .dashboard__item | ||||
|     = react_admin_component :dimension, dimension: 'servers', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_servers') | ||||
|  | ||||
|   .dashboard__item.dashboard__item--span-double-column | ||||
|     = react_admin_component :retention, start_at: @time_period.last - 6.months,   end_at: @time_period.last, frequency: 'month' | ||||
|  | ||||
|   .dashboard__item.dashboard__item--span-double-row | ||||
|     = react_admin_component :trends, limit: 7 | ||||
|  | ||||
|   .dashboard__item | ||||
|     = react_admin_component :dimension, dimension: 'software_versions', start_at: @time_period.first, end_at: @time_period.last, limit: 4, label: t('admin.dashboard.software') | ||||
|  | ||||
|   .dashboard__item | ||||
|     = react_admin_component :dimension, dimension: 'space_usage', start_at: @time_period.first, end_at: @time_period.last, limit: 3, label: t('admin.dashboard.space') | ||||
|   | ||||
| @@ -371,32 +371,28 @@ en: | ||||
|       updated_msg: Emoji successfully updated! | ||||
|       upload: Upload | ||||
|     dashboard: | ||||
|       authorized_fetch_mode: Secure mode | ||||
|       backlog: backlogged jobs | ||||
|       config: Configuration | ||||
|       feature_deletions: Account deletions | ||||
|       feature_invites: Invite links | ||||
|       feature_profile_directory: Profile directory | ||||
|       feature_registrations: Registrations | ||||
|       feature_relay: Federation relay | ||||
|       feature_timeline_preview: Timeline preview | ||||
|       features: Features | ||||
|       hidden_service: Federation with hidden services | ||||
|       open_reports: open reports | ||||
|       pending_tags: hashtags waiting for review | ||||
|       pending_users: users waiting for review | ||||
|       recent_users: Recent users | ||||
|       search: Full-text search | ||||
|       single_user_mode: Single user mode | ||||
|       active_users: active users | ||||
|       interactions: interactions | ||||
|       media_storage: Media storage | ||||
|       new_users: new users | ||||
|       opened_reports: reports opened | ||||
|       pending_reports_html: | ||||
|         one: "<strong>1</strong> pending reports" | ||||
|         other: "<strong>%{count}</strong> pending reports" | ||||
|       pending_tags_html: | ||||
|         one: "<strong>1</strong> pending hashtags" | ||||
|         other: "<strong>%{count}</strong> pending hashtags" | ||||
|       pending_users_html: | ||||
|         one: "<strong>1</strong> pending users" | ||||
|         other: "<strong>%{count}</strong> pending users" | ||||
|       resolved_reports: reports resolved | ||||
|       software: Software | ||||
|       sources: Sign-up sources | ||||
|       space: Space usage | ||||
|       title: Dashboard | ||||
|       total_users: users in total | ||||
|       trends: Trends | ||||
|       week_interactions: interactions this week | ||||
|       week_users_active: active this week | ||||
|       week_users_new: users this week | ||||
|       whitelist_mode: Limited federation mode | ||||
|       top_languages: Top active languages | ||||
|       top_servers: Top active servers | ||||
|       website: Website | ||||
|     domain_allows: | ||||
|       add_new: Allow federation with domain | ||||
|       created_msg: Domain has been successfully allowed for federation | ||||
|   | ||||
| @@ -510,6 +510,12 @@ Rails.application.routes.draw do | ||||
|             post :resolve | ||||
|           end | ||||
|         end | ||||
|  | ||||
|         resources :trends, only: [:index] | ||||
|  | ||||
|         post :measures, to: 'measures#create' | ||||
|         post :dimensions, to: 'dimensions#create' | ||||
|         post :retention, to: 'retention#create' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user