-
- #{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 0000000000..09093e99c7
--- /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 0000000000..816b44bd17
--- /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 52fdc9294b..2f42a084fd 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 958a65286b..9b3d01cfd2 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 3b9a3075fb..71c7c458dc 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 6f2505cae8..6ef563ad8f 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 4801a46440..24618c29f2 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 12bc472f5f..c239d4c781 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 c0944d417d..cad5a105be 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 81303b7158..6d3401b37b 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 0000000000..279539f686
--- /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 0000000000..8ed8d7683a
--- /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 0000000000..2d0ac124e0
--- /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 0000000000..3e80b66250
--- /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 0000000000..34917404d1
--- /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 0000000000..a9f061809c
--- /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 0000000000..aa00a2e18b
--- /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 0000000000..5cebf0331e
--- /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 0000000000..ac022eb9d1
--- /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 0000000000..4c336a69e6
--- /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 0000000000..9a4ef6d637
--- /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 0000000000..b31679ad3a
--- /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 0000000000..9acc2c33db
--- /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 0000000000..0dcecbbad6
--- /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 0000000000..49ab891293
--- /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 a1c156a8bf..6e19dcf708 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 9f673ee53d..7b11709fae 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 345a5e5e9c..a0f1ebd0ab 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 0000000000..56b35c6991
--- /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 0000000000..a00b6ecd7e
--- /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 0000000000..81d655c1a9
--- /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 0000000000..425ba4ba34
--- /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 250d0e8edd..bc32580253 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 bd36580e69..49e7251428 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 486592b29f..5ad5767c9f 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 d4ca4389d1..007fba5f25 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 3f1658566b..8815e137ad 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 147a59fc31..d21270c793 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