diff --git a/Gemfile.lock b/Gemfile.lock index 2693140da32..559d058a3cf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -424,7 +424,7 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (4.0.6) - puma (5.5.0) + puma (5.5.1) nio4r (~> 2.0) pundit (2.1.1) activesupport (>= 3.0.0) diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index a00d7ed96d3..cbfff27075f 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -1,50 +1,17 @@ # frozen_string_literal: true -require 'sidekiq/api' module Admin class DashboardController < BaseController def index @system_checks = Admin::SystemCheck.perform - @users_count = User.count + @time_period = (1.month.ago.to_date...Time.now.utc.to_date) @pending_users_count = User.pending.count - @registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0 - @logins_week = Redis.current.pfcount("activity:logins:#{current_week}") - @interactions_week = Redis.current.get("activity:interactions:#{current_week}") || 0 - @relay_enabled = Relay.enabled.exists? - @single_user_mode = Rails.configuration.x.single_user_mode - @registrations_enabled = Setting.registrations_mode != 'none' - @deletions_enabled = Setting.open_deletion - @invites_enabled = Setting.min_invite_role == 'user' - @search_enabled = Chewy.enabled? - @version = Mastodon::Version.to_s - @database_version = ActiveRecord::Base.connection.execute('SELECT VERSION()').first['version'].match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1] - @redis_version = redis_info['redis_version'] - @reports_count = Report.unresolved.count - @queue_backlog = Sidekiq::Stats.new.enqueued - @recent_users = User.confirmed.recent.includes(:account).limit(8) - @database_size = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size'] - @redis_size = redis_info['used_memory'] - @ldap_enabled = ENV['LDAP_ENABLED'] == 'true' - @cas_enabled = ENV['CAS_ENABLED'] == 'true' - @saml_enabled = ENV['SAML_ENABLED'] == 'true' - @pam_enabled = ENV['PAM_ENABLED'] == 'true' - @hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true' - @trending_hashtags = TrendingTags.get(10, filtered: false) + @pending_reports_count = Report.unresolved.count @pending_tags_count = Tag.pending_review.count - @authorized_fetch = authorized_fetch_mode? - @whitelist_enabled = whitelist_mode? - @profile_directory = Setting.profile_directory - @timeline_preview = Setting.timeline_preview - @keybase_integration = Setting.enable_keybase - @trends_enabled = Setting.trends end private - def current_week - @current_week ||= Time.now.utc.to_date.cweek - end - def redis_info @redis_info ||= begin if Redis.current.is_a?(Redis::Namespace) diff --git a/app/controllers/api/v1/admin/dimensions_controller.rb b/app/controllers/api/v1/admin/dimensions_controller.rb new file mode 100644 index 00000000000..170596d2738 --- /dev/null +++ b/app/controllers/api/v1/admin/dimensions_controller.rb @@ -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 diff --git a/app/controllers/api/v1/admin/measures_controller.rb b/app/controllers/api/v1/admin/measures_controller.rb new file mode 100644 index 00000000000..a3ac6fe85f3 --- /dev/null +++ b/app/controllers/api/v1/admin/measures_controller.rb @@ -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 diff --git a/app/controllers/api/v1/admin/retention_controller.rb b/app/controllers/api/v1/admin/retention_controller.rb new file mode 100644 index 00000000000..a8ff64f21d0 --- /dev/null +++ b/app/controllers/api/v1/admin/retention_controller.rb @@ -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 diff --git a/app/controllers/api/v1/admin/trends_controller.rb b/app/controllers/api/v1/admin/trends_controller.rb new file mode 100644 index 00000000000..e32ab5d2c74 --- /dev/null +++ b/app/controllers/api/v1/admin/trends_controller.rb @@ -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 diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb index 4f6b4bcbfa0..bad61425a5d 100644 --- a/app/controllers/api/v1/instances/activity_controller.rb +++ b/app/controllers/api/v1/instances/activity_controller.rb @@ -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! diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index cc622478d8b..3fbc418cb75 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -137,6 +137,10 @@ module ApplicationHelper end end + def react_admin_component(name, props = {}) + content_tag(:div, nil, data: { 'admin-component': name.to_s.camelcase, props: Oj.dump({ locale: I18n.locale }.merge(props)) }) + end + def body_classes output = (@body_classes || '').split(' ') output << "flavour-#{current_flavour.parameterize}" diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js index d2db89ca77f..8817c09b607 100644 --- a/app/javascript/core/admin.js +++ b/app/javascript/core/admin.js @@ -101,4 +101,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(( + + + + ), element); + }); + }).catch(error => { + console.error(error); + }); + }); }); diff --git a/app/javascript/mastodon/components/admin/Counter.js b/app/javascript/mastodon/components/admin/Counter.js new file mode 100644 index 00000000000..cda572dcede --- /dev/null +++ b/app/javascript/mastodon/components/admin/Counter.js @@ -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 = ( + + + + + ); + } else { + const measure = data[0]; + const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1); + + content = ( + + + 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'} + + ); + } + + const inner = ( + +
+ {content} +
+ +
+ {label} +
+ +
+ {!loading && ( + x.value * 1)}> + + + )} +
+
+ ); + + if (href) { + return ( + + {inner} + + ); + } else { + return ( +
+ {inner} +
+ ); + } + } + +} diff --git a/app/javascript/mastodon/components/admin/Dimension.js b/app/javascript/mastodon/components/admin/Dimension.js new file mode 100644 index 00000000000..ac6dbd1c79d --- /dev/null +++ b/app/javascript/mastodon/components/admin/Dimension.js @@ -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 = ( + + + {Array.from(Array(limit)).map((_, i) => ( + + + + + + ))} + +
+ + + +
+ ); + } else { + const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0); + + content = ( + + + {data[0].data.map(item => ( + + + + + + ))} + +
+ + {item.human_key} + + {typeof item.human_value !== 'undefined' ? item.human_value : } +
+ ); + } + + return ( +
+

{label}

+ + {content} +
+ ); + } + +} diff --git a/app/javascript/mastodon/components/admin/Retention.js b/app/javascript/mastodon/components/admin/Retention.js new file mode 100644 index 00000000000..aa06722f7e8 --- /dev/null +++ b/app/javascript/mastodon/components/admin/Retention.js @@ -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 ; + default: + return ; + } +}; + +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 = ; + } else { + content = ( + + + + + + + + {data[0].data.slice(1).map((retention, i) => ( + + ))} + + + + + + + + {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 ( + + ); + })} + + + + + {data.slice(0, -1).map(cohort => ( + + + + + + {cohort.data.slice(1).map(retention => ( + + ))} + + ))} + +
+
+ +
+
+
+ +
+
+
+ {i + 1} +
+
+
+ +
+
+
+ sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} /> +
+
+
+ +
+
+
+ {dateForCohort(cohort)} +
+
+
+ +
+
+
+ +
+
+ ); + } + + return ( +
+

+ + {content} +
+ ); + } + +} diff --git a/app/javascript/mastodon/components/admin/Trends.js b/app/javascript/mastodon/components/admin/Trends.js new file mode 100644 index 00000000000..46307a28ad3 --- /dev/null +++ b/app/javascript/mastodon/components/admin/Trends.js @@ -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 = ( +
+ {Array.from(Array(limit)).map((_, i) => ( + + ))} +
+ ); + } else { + content = ( +
+ {data.map(hashtag => ( + day.uses)} + className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')} + /> + ))} +
+ ); + } + + return ( +
+

+ + {content} +
+ ); + } + +} diff --git a/app/javascript/mastodon/components/hashtag.js b/app/javascript/mastodon/components/hashtag.js index 1a7edf6e0c2..a793a32f54a 100644 --- a/app/javascript/mastodon/components/hashtag.js +++ b/app/javascript/mastodon/components/hashtag.js @@ -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 }) => ( -
+export const ImmutableHashtag = ({ hashtag }) => ( + day.get('uses')).toArray()} + /> +); + +ImmutableHashtag.propTypes = { + hashtag: ImmutablePropTypes.map.isRequired, +}; + +const Hashtag = ({ name, href, to, people, uses, history, className }) => ( +
- - #{hashtag.get('name')} + + {name ? #{name} : } - + {typeof people !== 'undefined' ? : }
- + {typeof uses !== 'undefined' ? : }
- day.get('uses')) - .toArray()} - > + 0)}> @@ -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; diff --git a/app/javascript/mastodon/components/skeleton.js b/app/javascript/mastodon/components/skeleton.js new file mode 100644 index 00000000000..09093e99c75 --- /dev/null +++ b/app/javascript/mastodon/components/skeleton.js @@ -0,0 +1,11 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const Skeleton = ({ width, height }) => ; + +Skeleton.propTypes = { + width: PropTypes.number, + height: PropTypes.number, +}; + +export default Skeleton; diff --git a/app/javascript/mastodon/containers/admin_component.js b/app/javascript/mastodon/containers/admin_component.js new file mode 100644 index 00000000000..816b44bd179 --- /dev/null +++ b/app/javascript/mastodon/containers/admin_component.js @@ -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 ( + + {children} + + ); + } + +} diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js index 52fdc9294b8..2f42a084fd0 100644 --- a/app/javascript/mastodon/containers/media_container.js +++ b/app/javascript/mastodon/containers/media_container.js @@ -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'; diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js index 958a65286b6..9b3d01cfd28 100644 --- a/app/javascript/mastodon/features/compose/components/search_results.js +++ b/app/javascript/mastodon/features/compose/components/search_results.js @@ -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'; diff --git a/app/javascript/mastodon/features/getting_started/components/trends.js b/app/javascript/mastodon/features/getting_started/components/trends.js index 3b9a3075fbe..71c7c458dca 100644 --- a/app/javascript/mastodon/features/getting_started/components/trends.js +++ b/app/javascript/mastodon/features/getting_started/components/trends.js @@ -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 { diff --git a/app/javascript/mastodon/utils/numbers.js b/app/javascript/mastodon/utils/numbers.js index 6f2505cae87..6ef563ad8f9 100644 --- a/app/javascript/mastodon/utils/numbers.js +++ b/app/javascript/mastodon/utils/numbers.js @@ -69,3 +69,11 @@ export function pluralReady(sourceNumber, division) { return Math.trunc(sourceNumber / closestScale) * closestScale; } + +/** + * @param {number} num + * @returns {number} + */ +export function roundTo10(num) { + return Math.round(num * 0.1) / 0.1; +} diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 4801a464401..24618c29f2a 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -1,3 +1,5 @@ +@use "sass:math"; + $no-columns-breakpoint: 600px; $sidebar-width: 240px; $content-width: 840px; @@ -925,10 +927,197 @@ a.name-tag, } } +.dashboard__counters.admin-account-counters { + margin-top: 10px; +} + .account-badges { margin: -2px 0; } -.dashboard__counters.admin-account-counters { - margin-top: 10px; +.retention { + &__table { + &__number { + color: $secondary-text-color; + padding: 10px; + } + + &__date { + white-space: nowrap; + padding: 10px 0; + text-align: left; + min-width: 120px; + + &.retention__table__average { + font-weight: 700; + } + } + + &__size { + text-align: center; + padding: 10px; + } + + &__label { + font-weight: 700; + color: $darker-text-color; + } + + &__box { + box-sizing: border-box; + background: $ui-highlight-color; + padding: 10px; + font-weight: 500; + color: $primary-text-color; + width: 52px; + margin: 1px; + + @for $i from 0 through 10 { + &--#{10 * $i} { + background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10))); + } + } + } + } +} + +.sparkline { + display: block; + text-decoration: none; + background: lighten($ui-base-color, 4%); + border-radius: 4px; + padding: 0; + position: relative; + padding-bottom: 55px + 20px; + overflow: hidden; + + &__value { + display: flex; + line-height: 33px; + align-items: flex-end; + padding: 20px; + padding-bottom: 10px; + + &__total { + display: block; + margin-right: 10px; + font-weight: 500; + font-size: 28px; + color: $primary-text-color; + } + + &__change { + display: block; + font-weight: 500; + font-size: 18px; + color: $darker-text-color; + margin-bottom: -3px; + + &.positive { + color: $valid-value-color; + } + + &.negative { + color: $error-value-color; + } + } + } + + &__label { + padding: 0 20px; + padding-bottom: 10px; + text-transform: uppercase; + color: $darker-text-color; + font-weight: 500; + } + + &__graph { + position: absolute; + bottom: 0; + + svg { + display: block; + margin: 0; + } + + path:first-child { + fill: rgba($highlight-text-color, 0.25) !important; + fill-opacity: 1 !important; + } + + path:last-child { + stroke: lighten($highlight-text-color, 6%) !important; + fill: none !important; + } + } +} + +a.sparkline { + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 6%); + } +} + +.skeleton { + background-color: lighten($ui-base-color, 8%); + background-image: linear-gradient(90deg, lighten($ui-base-color, 8%), lighten($ui-base-color, 12%), lighten($ui-base-color, 8%)); + background-size: 200px 100%; + background-repeat: no-repeat; + border-radius: 4px; + display: inline-block; + line-height: 1; + width: 100%; + animation: skeleton 1.2s ease-in-out infinite; +} + +@keyframes skeleton { + 0% { + background-position: -200px 0; + } + + 100% { + background-position: calc(200px + 100%) 0; + } +} + +.dimension { + table { + width: 100%; + } + + &__item { + border-bottom: 1px solid lighten($ui-base-color, 4%); + + &__key { + font-weight: 500; + padding: 11px 10px; + } + + &__value { + text-align: right; + color: $darker-text-color; + padding: 11px 10px; + } + + &__indicator { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: $ui-highlight-color; + margin-right: 10px; + + @for $i from 0 through 10 { + &--#{10 * $i} { + background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10))); + } + } + } + + &:last-child { + border-bottom: 0; + } + } } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 12bc472f5fd..c239d4c7813 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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; } } diff --git a/app/javascript/styles/mastodon/dashboard.scss b/app/javascript/styles/mastodon/dashboard.scss index c0944d417dd..cad5a105be7 100644 --- a/app/javascript/styles/mastodon/dashboard.scss +++ b/app/javascript/styles/mastodon/dashboard.scss @@ -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; + } } } diff --git a/app/lib/activity_tracker.rb b/app/lib/activity_tracker.rb index 81303b71584..6d3401b37bb 100644 --- a/app/lib/activity_tracker.rb +++ b/app/lib/activity_tracker.rb @@ -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 diff --git a/app/lib/admin/metrics/dimension.rb b/app/lib/admin/metrics/dimension.rb new file mode 100644 index 00000000000..279539f686c --- /dev/null +++ b/app/lib/admin/metrics/dimension.rb @@ -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 diff --git a/app/lib/admin/metrics/dimension/base_dimension.rb b/app/lib/admin/metrics/dimension/base_dimension.rb new file mode 100644 index 00000000000..8ed8d7683a8 --- /dev/null +++ b/app/lib/admin/metrics/dimension/base_dimension.rb @@ -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 diff --git a/app/lib/admin/metrics/dimension/languages_dimension.rb b/app/lib/admin/metrics/dimension/languages_dimension.rb new file mode 100644 index 00000000000..2d0ac124e0e --- /dev/null +++ b/app/lib/admin/metrics/dimension/languages_dimension.rb @@ -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 diff --git a/app/lib/admin/metrics/dimension/servers_dimension.rb b/app/lib/admin/metrics/dimension/servers_dimension.rb new file mode 100644 index 00000000000..3e80b66250f --- /dev/null +++ b/app/lib/admin/metrics/dimension/servers_dimension.rb @@ -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 diff --git a/app/lib/admin/metrics/dimension/software_versions_dimension.rb b/app/lib/admin/metrics/dimension/software_versions_dimension.rb new file mode 100644 index 00000000000..34917404d14 --- /dev/null +++ b/app/lib/admin/metrics/dimension/software_versions_dimension.rb @@ -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 diff --git a/app/lib/admin/metrics/dimension/sources_dimension.rb b/app/lib/admin/metrics/dimension/sources_dimension.rb new file mode 100644 index 00000000000..a9f061809c8 --- /dev/null +++ b/app/lib/admin/metrics/dimension/sources_dimension.rb @@ -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 diff --git a/app/lib/admin/metrics/dimension/space_usage_dimension.rb b/app/lib/admin/metrics/dimension/space_usage_dimension.rb new file mode 100644 index 00000000000..aa00a2e18b8 --- /dev/null +++ b/app/lib/admin/metrics/dimension/space_usage_dimension.rb @@ -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 diff --git a/app/lib/admin/metrics/measure.rb b/app/lib/admin/metrics/measure.rb new file mode 100644 index 00000000000..5cebf0331e9 --- /dev/null +++ b/app/lib/admin/metrics/measure.rb @@ -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 diff --git a/app/lib/admin/metrics/measure/active_users_measure.rb b/app/lib/admin/metrics/measure/active_users_measure.rb new file mode 100644 index 00000000000..ac022eb9d15 --- /dev/null +++ b/app/lib/admin/metrics/measure/active_users_measure.rb @@ -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 diff --git a/app/lib/admin/metrics/measure/base_measure.rb b/app/lib/admin/metrics/measure/base_measure.rb new file mode 100644 index 00000000000..4c336a69e68 --- /dev/null +++ b/app/lib/admin/metrics/measure/base_measure.rb @@ -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 diff --git a/app/lib/admin/metrics/measure/interactions_measure.rb b/app/lib/admin/metrics/measure/interactions_measure.rb new file mode 100644 index 00000000000..9a4ef6d6375 --- /dev/null +++ b/app/lib/admin/metrics/measure/interactions_measure.rb @@ -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 diff --git a/app/lib/admin/metrics/measure/new_users_measure.rb b/app/lib/admin/metrics/measure/new_users_measure.rb new file mode 100644 index 00000000000..b31679ad3a5 --- /dev/null +++ b/app/lib/admin/metrics/measure/new_users_measure.rb @@ -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 diff --git a/app/lib/admin/metrics/measure/opened_reports_measure.rb b/app/lib/admin/metrics/measure/opened_reports_measure.rb new file mode 100644 index 00000000000..9acc2c33db9 --- /dev/null +++ b/app/lib/admin/metrics/measure/opened_reports_measure.rb @@ -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 diff --git a/app/lib/admin/metrics/measure/resolved_reports_measure.rb b/app/lib/admin/metrics/measure/resolved_reports_measure.rb new file mode 100644 index 00000000000..0dcecbbad68 --- /dev/null +++ b/app/lib/admin/metrics/measure/resolved_reports_measure.rb @@ -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 diff --git a/app/lib/admin/metrics/retention.rb b/app/lib/admin/metrics/retention.rb new file mode 100644 index 00000000000..49ab891293b --- /dev/null +++ b/app/lib/admin/metrics/retention.rb @@ -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 diff --git a/app/models/admin/action_log_filter.rb b/app/models/admin/action_log_filter.rb index a1c156a8bf8..6e19dcf7089 100644 --- a/app/models/admin/action_log_filter.rb +++ b/app/models/admin/action_log_filter.rb @@ -76,7 +76,7 @@ class Admin::ActionLogFilter when 'account_id' Admin::ActionLog.where(account_id: value) when 'target_account_id' - account = Account.find(value) + account = Account.find_or_initialize_by(id: value) Admin::ActionLog.where(target: [account, account.user].compact) else raise "Unknown filter: #{key}" diff --git a/app/models/status.rb b/app/models/status.rb index 9f673ee53d2..7b11709faea 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -494,7 +494,7 @@ class Status < ApplicationRecord end def decrement_counter_caches - return if direct_visibility? + return if direct_visibility? || new_record? account&.decrement_count!(:statuses_count) reblog&.decrement_count!(:reblogs_count) if reblog? diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb index 345a5e5e9cc..a0f1ebd0abe 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -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 diff --git a/app/serializers/rest/admin/cohort_serializer.rb b/app/serializers/rest/admin/cohort_serializer.rb new file mode 100644 index 00000000000..56b35c6991b --- /dev/null +++ b/app/serializers/rest/admin/cohort_serializer.rb @@ -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 diff --git a/app/serializers/rest/admin/dimension_serializer.rb b/app/serializers/rest/admin/dimension_serializer.rb new file mode 100644 index 00000000000..a00b6ecd7e7 --- /dev/null +++ b/app/serializers/rest/admin/dimension_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class REST::Admin::DimensionSerializer < ActiveModel::Serializer + attributes :key, :data +end diff --git a/app/serializers/rest/admin/measure_serializer.rb b/app/serializers/rest/admin/measure_serializer.rb new file mode 100644 index 00000000000..81d655c1a97 --- /dev/null +++ b/app/serializers/rest/admin/measure_serializer.rb @@ -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 diff --git a/app/serializers/rest/admin/tag_serializer.rb b/app/serializers/rest/admin/tag_serializer.rb new file mode 100644 index 00000000000..425ba4ba343 --- /dev/null +++ b/app/serializers/rest/admin/tag_serializer.rb @@ -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 diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 250d0e8eddd..bc32580253c 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -83,6 +83,9 @@ class PostStatusService < BaseService status_for_validation = @account.statuses.build(status_attributes) if status_for_validation.valid? + # Marking the status as destroyed is necessary to prevent the status from being + # persisted when the associated media attachments get updated when creating the + # scheduled status. status_for_validation.destroy # The following transaction block is needed to wrap the UPDATEs to diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index bd36580e69b..49e7251428b 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -1,6 +1,11 @@ - content_for :page_title do = t('admin.dashboard.title') +- content_for :heading_actions do + = l(@time_period.first) + = ' - ' + = l(@time_period.last) + - unless @system_checks.empty? .flash-message-stack - @system_checks.each do |message| @@ -9,133 +14,52 @@ - if message.action = link_to t("admin.system_checks.#{message.key}.action"), message.action -.dashboard__counters - %div - = link_to admin_accounts_url(local: 1, recent: 1) do - .dashboard__counters__num{ title: number_with_delimiter(@users_count, strip_insignificant_zeros: true) } - = friendly_number_to_human @users_count - .dashboard__counters__label= t 'admin.dashboard.total_users' - %div - %div - .dashboard__counters__num{ title: number_with_delimiter(@registrations_week, strip_insignificant_zeros: true) } - = friendly_number_to_human @registrations_week - .dashboard__counters__label= t 'admin.dashboard.week_users_new' - %div - %div - .dashboard__counters__num{ title: number_with_delimiter(@logins_week, strip_insignificant_zeros: true) } - = friendly_number_to_human @logins_week - .dashboard__counters__label= t 'admin.dashboard.week_users_active' - %div - = link_to admin_pending_accounts_path do - .dashboard__counters__num{ title: number_with_delimiter(@pending_users_count, strip_insignificant_zeros: true) } - = friendly_number_to_human @pending_users_count - .dashboard__counters__label= t 'admin.dashboard.pending_users' - %div - = link_to admin_reports_url do - .dashboard__counters__num{ title: number_with_delimiter(@reports_count, strip_insignificant_zeros: true) } - = friendly_number_to_human @reports_count - .dashboard__counters__label= t 'admin.dashboard.open_reports' - %div - = link_to admin_tags_path(pending_review: '1') do - .dashboard__counters__num{ title: number_with_delimiter(@pending_tags_count, strip_insignificant_zeros: true) } - = friendly_number_to_human @pending_tags_count - .dashboard__counters__label= t 'admin.dashboard.pending_tags' - %div - %div - .dashboard__counters__num{ title: number_with_delimiter(@interactions_week, strip_insignificant_zeros: true) } - = friendly_number_to_human @interactions_week - .dashboard__counters__label= t 'admin.dashboard.week_interactions' - %div - = link_to sidekiq_url do - .dashboard__counters__num{ title: number_with_delimiter(@queue_backlog, strip_insignificant_zeros: true) } - = friendly_number_to_human @queue_backlog - .dashboard__counters__label= t 'admin.dashboard.backlog' +.dashboard + .dashboard__item + = react_admin_component :counter, measure: 'new_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.new_users'), href: admin_accounts_path -.dashboard__widgets - .dashboard__widgets__users - %div - %h4= t 'admin.dashboard.recent_users' - %ul - - @recent_users.each do |user| - %li= admin_account_link_to(user.account) + .dashboard__item + = react_admin_component :counter, measure: 'active_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.active_users'), href: admin_accounts_path - .dashboard__widgets__features - %div - %h4= t 'admin.dashboard.features' - %ul - %li - = feature_hint(link_to(t('admin.dashboard.feature_registrations'), edit_admin_settings_path), @registrations_enabled) - %li - = feature_hint(link_to(t('admin.dashboard.feature_invites'), edit_admin_settings_path), @invites_enabled) - %li - = feature_hint(link_to(t('admin.dashboard.feature_deletions'), edit_admin_settings_path), @deletions_enabled) - %li - = feature_hint(link_to(t('admin.dashboard.feature_profile_directory'), edit_admin_settings_path), @profile_directory) - %li - = feature_hint(link_to(t('admin.dashboard.feature_timeline_preview'), edit_admin_settings_path), @timeline_preview) - %li - = feature_hint(link_to(t('admin.dashboard.keybase'), edit_admin_settings_path), @keybase_integration) - %li - = feature_hint(link_to(t('admin.dashboard.trends'), edit_admin_settings_path), @trends_enabled) - %li - = feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled) + .dashboard__item + = react_admin_component :counter, measure: 'interactions', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.interactions') - .dashboard__widgets__versions - %div - %h4= t 'admin.dashboard.software' - %ul - %li - Mastodon - %span.pull-right= @version - %li - Ruby - %span.pull-right= "#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}" - %li - PostgreSQL - %span.pull-right= @database_version - %li - Redis - %span.pull-right= @redis_version + .dashboard__item + = react_admin_component :counter, measure: 'opened_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.opened_reports'), href: admin_reports_path - .dashboard__widgets__space - %div - %h4= t 'admin.dashboard.space' - %ul - %li - PostgreSQL - %span.pull-right= number_to_human_size @database_size - %li - Redis - %span.pull-right= number_to_human_size @redis_size + .dashboard__item + = react_admin_component :counter, measure: 'resolved_reports', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.resolved_reports'), href: admin_reports_path(resolved: '1') - .dashboard__widgets__config - %div - %h4= t 'admin.dashboard.config' - %ul - %li - = feature_hint(t('admin.dashboard.search'), @search_enabled) - %li - = feature_hint(t('admin.dashboard.single_user_mode'), @single_user_mode) - %li - = feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch) - %li - = feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_enabled) - %li - = feature_hint('LDAP', @ldap_enabled) - %li - = feature_hint('CAS', @cas_enabled) - %li - = feature_hint('SAML', @saml_enabled) - %li - = feature_hint('PAM', @pam_enabled) - %li - = feature_hint(t('admin.dashboard.hidden_service'), @hidden_service) + .dashboard__item + = link_to admin_reports_path, class: 'dashboard__quick-access' do + %span= t('admin.dashboard.pending_reports_html', count: @pending_reports_count) + = fa_icon 'chevron-right fw' - .dashboard__widgets__trends - %div - %h4= t 'admin.dashboard.trends' - %ul - - @trending_hashtags.each do |tag| - %li - = link_to content_tag(:span, "##{tag.name}", class: !tag.trendable? && !tag.reviewed? ? 'warning-hint' : (!tag.trendable? ? 'negative-hint' : nil)), admin_tag_path(tag.id) - %span.pull-right= number_with_delimiter(tag.history[0][:accounts].to_i) + = link_to admin_pending_accounts_path, class: 'dashboard__quick-access' do + %span= t('admin.dashboard.pending_users_html', count: @pending_users_count) + = fa_icon 'chevron-right fw' + + = link_to admin_tags_path(pending_review: '1'), class: 'dashboard__quick-access' do + %span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count) + = fa_icon 'chevron-right fw' + + .dashboard__item + = react_admin_component :dimension, dimension: 'sources', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.sources') + + .dashboard__item + = react_admin_component :dimension, dimension: 'languages', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_languages') + + .dashboard__item + = react_admin_component :dimension, dimension: 'servers', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.top_servers') + + .dashboard__item.dashboard__item--span-double-column + = react_admin_component :retention, start_at: @time_period.last - 6.months, end_at: @time_period.last, frequency: 'month' + + .dashboard__item.dashboard__item--span-double-row + = react_admin_component :trends, limit: 7 + + .dashboard__item + = react_admin_component :dimension, dimension: 'software_versions', start_at: @time_period.first, end_at: @time_period.last, limit: 4, label: t('admin.dashboard.software') + + .dashboard__item + = react_admin_component :dimension, dimension: 'space_usage', start_at: @time_period.first, end_at: @time_period.last, limit: 3, label: t('admin.dashboard.space') diff --git a/config/locales/en.yml b/config/locales/en.yml index 486592b29f0..5ad5767c9fa 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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: "1 pending reports" + other: "%{count} pending reports" + pending_tags_html: + one: "1 pending hashtags" + other: "%{count} pending hashtags" + pending_users_html: + one: "1 pending users" + other: "%{count} 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 diff --git a/config/routes.rb b/config/routes.rb index d4ca4389d1d..007fba5f25d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -514,6 +514,12 @@ Rails.application.routes.draw do post :resolve end end + + resources :trends, only: [:index] + + post :measures, to: 'measures#create' + post :dimensions, to: 'dimensions#create' + post :retention, to: 'retention#create' end end diff --git a/lib/cli.rb b/lib/cli.rb index 3f1658566b3..8815e137adb 100644 --- a/lib/cli.rb +++ b/lib/cli.rb @@ -94,17 +94,22 @@ module Mastodon exit(1) unless prompt.ask('Type in the domain of the server to confirm:', required: true) == Rails.configuration.x.local_domain - prompt.warn('This operation WILL NOT be reversible. It can also take a long time.') - prompt.warn('While the data won\'t be erased locally, the server will be in a BROKEN STATE afterwards.') - prompt.warn('A running Sidekiq process is required. Do not shut it down until queues clear.') + unless options[:dry_run] + prompt.warn('This operation WILL NOT be reversible. It can also take a long time.') + prompt.warn('While the data won\'t be erased locally, the server will be in a BROKEN STATE afterwards.') + prompt.warn('A running Sidekiq process is required. Do not shut it down until queues clear.') - exit(1) if prompt.no?('Are you sure you want to proceed?') + exit(1) if prompt.no?('Are you sure you want to proceed?') + end inboxes = Account.inboxes processed = 0 dry_run = options[:dry_run] ? ' (DRY RUN)' : '' + Setting.registrations_mode = 'none' unless options[:dry_run] + if inboxes.empty? + Account.local.without_suspended.in_batches.update_all(suspended_at: Time.now.utc, suspension_origin: :local) unless options[:dry_run] prompt.ok('It seems like your server has not federated with anything') prompt.ok('You can shut it down and delete it any time') return @@ -112,9 +117,7 @@ module Mastodon prompt.warn('Do NOT interrupt this process...') - Setting.registrations_mode = 'none' - - Account.local.without_suspended.find_each do |account| + delete_account = ->(account) do payload = ActiveModelSerializers::SerializableResource.new( account, serializer: ActivityPub::DeleteActorSerializer, @@ -128,12 +131,15 @@ module Mastodon [json, account.id, inbox_url] end - account.suspend! + account.suspend!(block_email: false) end processed += 1 end + Account.local.without_suspended.find_each { |account| delete_account.call(account) } + Account.local.suspended.joins(:deletion_request).find_each { |account| delete_account.call(account) } + prompt.ok("Queued #{inboxes.size * processed} items into Sidekiq for #{processed} accounts#{dry_run}") prompt.ok('Wait until Sidekiq processes all items, then you can shut everything down and delete the data') rescue TTY::Reader::InputInterrupt diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index 147a59fc315..d21270c7938 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -25,29 +25,33 @@ RSpec.describe PostStatusService, type: :service do expect(status.thread).to eq in_reply_to_status end - it 'schedules a status' do - account = Fabricate(:account) - future = Time.now.utc + 2.hours + context 'when scheduling a status' do + let!(:account) { Fabricate(:account) } + let!(:future) { Time.now.utc + 2.hours } + let!(:previous_status) { Fabricate(:status, account: account) } - status = subject.call(account, text: 'Hi future!', scheduled_at: future) + it 'schedules a status' do + status = subject.call(account, text: 'Hi future!', scheduled_at: future) + expect(status).to be_a ScheduledStatus + expect(status.scheduled_at).to eq future + expect(status.params['text']).to eq 'Hi future!' + end - expect(status).to be_a ScheduledStatus - expect(status.scheduled_at).to eq future - expect(status.params['text']).to eq 'Hi future!' - end + it 'does not immediately create a status' do + media = Fabricate(:media_attachment, account: account) + status = subject.call(account, text: 'Hi future!', media_ids: [media.id], scheduled_at: future) - it 'does not immediately create a status when scheduling a status' do - account = Fabricate(:account) - media = Fabricate(:media_attachment) - future = Time.now.utc + 2.hours + expect(status).to be_a ScheduledStatus + expect(status.scheduled_at).to eq future + expect(status.params['text']).to eq 'Hi future!' + expect(status.params['media_ids']).to eq [media.id] + expect(media.reload.status).to be_nil + expect(Status.where(text: 'Hi future!').exists?).to be_falsey + end - status = subject.call(account, text: 'Hi future!', media_ids: [media.id], scheduled_at: future) - - expect(status).to be_a ScheduledStatus - expect(status.scheduled_at).to eq future - expect(status.params['text']).to eq 'Hi future!' - expect(media.reload.status).to be_nil - expect(Status.where(text: 'Hi future!').exists?).to be_falsey + it 'does not change statuses count' do + expect { subject.call(account, text: 'Hi future!', scheduled_at: future, thread: previous_status) }.not_to change { [account.statuses_count, previous_status.replies_count] } + end end it 'creates response to the original status of boost' do