diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index b140c454c..16defc1ea 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -56,10 +56,6 @@ module Admin end end - def show - authorize @domain_block, :show? - end - def destroy authorize @domain_block, :destroy? UnblockDomainService.new.call(@domain_block) diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index 306ec1f53..5c82331de 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -4,28 +4,26 @@ module Admin class InstancesController < BaseController before_action :set_instances, only: :index before_action :set_instance, except: :index - before_action :set_exhausted_deliveries_days, only: :show def index authorize :instance, :index? + preload_delivery_failures! end def show authorize :instance, :show? + @time_period = (6.days.ago.to_date...Time.now.utc.to_date) end def destroy authorize :instance, :destroy? - Admin::DomainPurgeWorker.perform_async(@instance.domain) - log_action :destroy, @instance redirect_to admin_instances_path, notice: I18n.t('admin.instances.destroyed_msg', domain: @instance.domain) end def clear_delivery_errors authorize :delivery, :clear_delivery_errors? - @instance.delivery_failure_tracker.clear_failures! redirect_to admin_instance_path(@instance.domain) end @@ -33,11 +31,9 @@ module Admin def restart_delivery authorize :delivery, :restart_delivery? - last_unavailable_domain = unavailable_domain - - if last_unavailable_domain.present? + if @instance.unavailable? @instance.delivery_failure_tracker.track_success! - log_action :destroy, last_unavailable_domain + log_action :destroy, @instance.unavailable_domain end redirect_to admin_instance_path(@instance.domain) @@ -45,8 +41,7 @@ module Admin def stop_delivery authorize :delivery, :stop_delivery? - - UnavailableDomain.create(domain: @instance.domain) + unavailable_domain = UnavailableDomain.create!(domain: @instance.domain) log_action :create, unavailable_domain redirect_to admin_instance_path(@instance.domain) end @@ -57,12 +52,11 @@ module Admin @instance = Instance.find(params[:id]) end - def set_exhausted_deliveries_days - @exhausted_deliveries_days = @instance.delivery_failure_tracker.exhausted_deliveries_days - end - def set_instances @instances = filtered_instances.page(params[:page]) + end + + def preload_delivery_failures! warning_domains_map = DeliveryFailureTracker.warning_domains_map @instances.each do |instance| @@ -70,10 +64,6 @@ module Admin end end - def unavailable_domain - UnavailableDomain.find_by(domain: @instance.domain) - end - def filtered_instances InstanceFilter.new(whitelist_mode? ? { allowed: true } : filter_params).results end diff --git a/app/javascript/mastodon/components/admin/Counter.js b/app/javascript/mastodon/components/admin/Counter.js index 047e864b2..6edb7bcfc 100644 --- a/app/javascript/mastodon/components/admin/Counter.js +++ b/app/javascript/mastodon/components/admin/Counter.js @@ -68,12 +68,12 @@ export default class Counter extends React.PureComponent { ); } else { const measure = data[0]; - const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1); + const percentChange = measure.previous_total && percIncrease(measure.previous_total * 1, measure.total * 1); content = ( - - 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'} + {measure.human_value || } + {measure.previous_total && ( 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'})} ); } diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 42f055618..dc4d65edd 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -367,6 +367,21 @@ body, } } + .positive-hint, + .negative-hint, + .neutral-hint { + a { + color: inherit; + text-decoration: underline; + + &:focus, + &:hover, + &:active { + text-decoration: none; + } + } + } + .positive-hint { color: $valid-value-color; font-weight: 500; @@ -1596,3 +1611,38 @@ a.sparkline { } } } + +.availability-indicator { + display: flex; + align-items: center; + margin-bottom: 30px; + font-size: 14px; + line-height: 21px; + + &__hint { + padding: 0 15px; + } + + &__graphic { + display: flex; + margin: 0 -2px; + + &__item { + display: block; + flex: 0 0 auto; + width: 4px; + height: 21px; + background: lighten($ui-base-color, 8%); + margin: 0 2px; + border-radius: 2px; + + &.positive { + background: $valid-value-color; + } + + &.negative { + background: $error-value-color; + } + } + } +} diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss index 1f7e71776..431b8a73a 100644 --- a/app/javascript/styles/mastodon/tables.scss +++ b/app/javascript/styles/mastodon/tables.scss @@ -65,6 +65,24 @@ } } + &.horizontal-table { + border-collapse: collapse; + border-style: hidden; + + & > tbody > tr > th, + & > tbody > tr > td { + padding: 11px 10px; + background: transparent; + border: 1px solid lighten($ui-base-color, 8%); + color: $secondary-text-color; + } + + & > tbody > tr > th { + color: $darker-text-color; + font-weight: 600; + } + } + &.batch-table { & > thead > tr > th { background: $ui-base-color; diff --git a/app/lib/admin/metrics/dimension.rb b/app/lib/admin/metrics/dimension.rb index d8392ddfc..81b89d9b3 100644 --- a/app/lib/admin/metrics/dimension.rb +++ b/app/lib/admin/metrics/dimension.rb @@ -9,6 +9,8 @@ class Admin::Metrics::Dimension software_versions: Admin::Metrics::Dimension::SoftwareVersionsDimension, tag_servers: Admin::Metrics::Dimension::TagServersDimension, tag_languages: Admin::Metrics::Dimension::TagLanguagesDimension, + instance_accounts: Admin::Metrics::Dimension::InstanceAccountsDimension, + instance_languages: Admin::Metrics::Dimension::InstanceLanguagesDimension, }.freeze def self.retrieve(dimension_keys, start_at, end_at, limit, params) diff --git a/app/lib/admin/metrics/dimension/instance_accounts_dimension.rb b/app/lib/admin/metrics/dimension/instance_accounts_dimension.rb new file mode 100644 index 000000000..4eac8e611 --- /dev/null +++ b/app/lib/admin/metrics/dimension/instance_accounts_dimension.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Admin::Metrics::Dimension::InstanceAccountsDimension < Admin::Metrics::Dimension::BaseDimension + include LanguagesHelper + + def self.with_params? + true + end + + def key + 'instance_accounts' + end + + protected + + def perform_query + sql = <<-SQL.squish + SELECT accounts.username, count(follows.*) AS value + FROM accounts + LEFT JOIN follows ON follows.target_account_id = accounts.id + WHERE accounts.domain = $1 + GROUP BY accounts.id, follows.target_account_id + ORDER BY value DESC + LIMIT $2 + SQL + + rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:domain]], [nil, @limit]]) + + rows.map { |row| { key: row['username'], human_key: row['username'], value: row['value'].to_s } } + end + + def params + @params.permit(:domain) + end +end diff --git a/app/lib/admin/metrics/dimension/instance_languages_dimension.rb b/app/lib/admin/metrics/dimension/instance_languages_dimension.rb new file mode 100644 index 000000000..1ede1a56e --- /dev/null +++ b/app/lib/admin/metrics/dimension/instance_languages_dimension.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Admin::Metrics::Dimension::InstanceLanguagesDimension < Admin::Metrics::Dimension::BaseDimension + include LanguagesHelper + + def self.with_params? + true + end + + def key + 'instance_languages' + end + + protected + + def perform_query + sql = <<-SQL.squish + SELECT COALESCE(statuses.language, 'und') AS language, count(*) AS value + FROM statuses + INNER JOIN accounts ON accounts.id = statuses.account_id + WHERE accounts.domain = $1 + AND statuses.id BETWEEN $2 AND $3 + AND statuses.reblog_of_id IS NULL + GROUP BY COALESCE(statuses.language, 'und') + ORDER BY count(*) DESC + LIMIT $4 + SQL + + rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:domain]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]]) + + rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } } + end + + def params + @params.permit(:domain) + end +end diff --git a/app/lib/admin/metrics/measure.rb b/app/lib/admin/metrics/measure.rb index a839498a1..0b510eb25 100644 --- a/app/lib/admin/metrics/measure.rb +++ b/app/lib/admin/metrics/measure.rb @@ -10,6 +10,12 @@ class Admin::Metrics::Measure tag_accounts: Admin::Metrics::Measure::TagAccountsMeasure, tag_uses: Admin::Metrics::Measure::TagUsesMeasure, tag_servers: Admin::Metrics::Measure::TagServersMeasure, + instance_accounts: Admin::Metrics::Measure::InstanceAccountsMeasure, + instance_media_attachments: Admin::Metrics::Measure::InstanceMediaAttachmentsMeasure, + instance_reports: Admin::Metrics::Measure::InstanceReportsMeasure, + instance_statuses: Admin::Metrics::Measure::InstanceStatusesMeasure, + instance_follows: Admin::Metrics::Measure::InstanceFollowsMeasure, + instance_followers: Admin::Metrics::Measure::InstanceFollowersMeasure, }.freeze def self.retrieve(measure_keys, start_at, end_at, params) diff --git a/app/lib/admin/metrics/measure/base_measure.rb b/app/lib/admin/metrics/measure/base_measure.rb index ed1df9c7d..e33a6c494 100644 --- a/app/lib/admin/metrics/measure/base_measure.rb +++ b/app/lib/admin/metrics/measure/base_measure.rb @@ -26,6 +26,14 @@ class Admin::Metrics::Measure::BaseMeasure raise NotImplementedError end + def unit + nil + end + + def total_in_time_range? + true + end + def total load[:total] end diff --git a/app/lib/admin/metrics/measure/instance_accounts_measure.rb b/app/lib/admin/metrics/measure/instance_accounts_measure.rb new file mode 100644 index 000000000..4c61a064a --- /dev/null +++ b/app/lib/admin/metrics/measure/instance_accounts_measure.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class Admin::Metrics::Measure::InstanceAccountsMeasure < Admin::Metrics::Measure::BaseMeasure + def self.with_params? + true + end + + def key + 'instance_accounts' + end + + def total_in_time_range? + false + end + + protected + + def perform_total_query + Account.where(domain: params[:domain]).count + end + + def perform_previous_total_query + nil + end + + def perform_data_query + sql = <<-SQL.squish + SELECT axis.*, ( + WITH new_accounts AS ( + SELECT accounts.id + FROM accounts + WHERE date_trunc('day', accounts.created_at)::date = axis.period + AND accounts.domain = $3::text + ) + SELECT count(*) FROM new_accounts + ) 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], [nil, params[:domain]]]) + + rows.map { |row| { date: row['period'], value: row['value'].to_s } } + 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 + + def params + @params.permit(:domain) + end +end diff --git a/app/lib/admin/metrics/measure/instance_followers_measure.rb b/app/lib/admin/metrics/measure/instance_followers_measure.rb new file mode 100644 index 000000000..caa60013b --- /dev/null +++ b/app/lib/admin/metrics/measure/instance_followers_measure.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class Admin::Metrics::Measure::InstanceFollowersMeasure < Admin::Metrics::Measure::BaseMeasure + def self.with_params? + true + end + + def key + 'instance_followers' + end + + def total_in_time_range? + false + end + + protected + + def perform_total_query + Follow.joins(:account).merge(Account.where(domain: params[:domain])).count + end + + def perform_previous_total_query + nil + end + + def perform_data_query + sql = <<-SQL.squish + SELECT axis.*, ( + WITH new_followers AS ( + SELECT follows.id + FROM follows + INNER JOIN accounts ON follows.account_id = accounts.id + WHERE date_trunc('day', follows.created_at)::date = axis.period + AND accounts.domain = $3::text + ) + SELECT count(*) FROM new_followers + ) 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], [nil, params[:domain]]]) + + rows.map { |row| { date: row['period'], value: row['value'].to_s } } + 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 + + def params + @params.permit(:domain) + end +end diff --git a/app/lib/admin/metrics/measure/instance_follows_measure.rb b/app/lib/admin/metrics/measure/instance_follows_measure.rb new file mode 100644 index 000000000..b026c7e6d --- /dev/null +++ b/app/lib/admin/metrics/measure/instance_follows_measure.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class Admin::Metrics::Measure::InstanceFollowsMeasure < Admin::Metrics::Measure::BaseMeasure + def self.with_params? + true + end + + def key + 'instance_follows' + end + + def total_in_time_range? + false + end + + protected + + def perform_total_query + Follow.joins(:target_account).merge(Account.where(domain: params[:domain])).count + end + + def perform_previous_total_query + nil + end + + def perform_data_query + sql = <<-SQL.squish + SELECT axis.*, ( + WITH new_follows AS ( + SELECT follows.id + FROM follows + INNER JOIN accounts ON follows.target_account_id = accounts.id + WHERE date_trunc('day', follows.created_at)::date = axis.period + AND accounts.domain = $3::text + ) + SELECT count(*) FROM new_follows + ) 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], [nil, params[:domain]]]) + + rows.map { |row| { date: row['period'], value: row['value'].to_s } } + 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 + + def params + @params.permit(:domain) + end +end diff --git a/app/lib/admin/metrics/measure/instance_media_attachments_measure.rb b/app/lib/admin/metrics/measure/instance_media_attachments_measure.rb new file mode 100644 index 000000000..2e2154c92 --- /dev/null +++ b/app/lib/admin/metrics/measure/instance_media_attachments_measure.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class Admin::Metrics::Measure::InstanceMediaAttachmentsMeasure < Admin::Metrics::Measure::BaseMeasure + include ActionView::Helpers::NumberHelper + + def self.with_params? + true + end + + def key + 'instance_media_attachments' + end + + def unit + 'bytes' + end + + def value_to_human_value(value) + number_to_human_size(value) + end + + def total_in_time_range? + false + end + + protected + + def perform_total_query + MediaAttachment.joins(:account).merge(Account.where(domain: params[:domain])).sum('COALESCE(file_file_size, 0) + COALESCE(thumbnail_file_size, 0)') + end + + def perform_previous_total_query + nil + end + + def perform_data_query + sql = <<-SQL.squish + SELECT axis.*, ( + WITH new_media_attachments AS ( + SELECT COALESCE(media_attachments.file_file_size, 0) + COALESCE(media_attachments.thumbnail_file_size, 0) AS size + FROM media_attachments + INNER JOIN accounts ON accounts.id = media_attachments.account_id + WHERE date_trunc('day', media_attachments.created_at)::date = axis.period + AND accounts.domain = $3::text + ) + SELECT SUM(size) FROM new_media_attachments + ) 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], [nil, params[:domain]]]) + + rows.map { |row| { date: row['period'], value: row['value'].to_s } } + 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 + + def params + @params.permit(:domain) + end +end diff --git a/app/lib/admin/metrics/measure/instance_reports_measure.rb b/app/lib/admin/metrics/measure/instance_reports_measure.rb new file mode 100644 index 000000000..6b3f35067 --- /dev/null +++ b/app/lib/admin/metrics/measure/instance_reports_measure.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class Admin::Metrics::Measure::InstanceReportsMeasure < Admin::Metrics::Measure::BaseMeasure + def self.with_params? + true + end + + def key + 'instance_reports' + end + + def total_in_time_range? + false + end + + protected + + def perform_total_query + Report.where(target_account: Account.where(domain: params[:domain])).count + end + + def perform_previous_total_query + nil + end + + def perform_data_query + sql = <<-SQL.squish + SELECT axis.*, ( + WITH new_reports AS ( + SELECT reports.id + FROM reports + INNER JOIN accounts ON accounts.id = reports.target_account_id + WHERE date_trunc('day', reports.created_at)::date = axis.period + AND accounts.domain = $3::text + ) + 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], [nil, params[:domain]]]) + + rows.map { |row| { date: row['period'], value: row['value'].to_s } } + 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 + + def params + @params.permit(:domain) + end +end diff --git a/app/lib/admin/metrics/measure/instance_statuses_measure.rb b/app/lib/admin/metrics/measure/instance_statuses_measure.rb new file mode 100644 index 000000000..86b10da6c --- /dev/null +++ b/app/lib/admin/metrics/measure/instance_statuses_measure.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Admin::Metrics::Measure::InstanceStatusesMeasure < Admin::Metrics::Measure::BaseMeasure + def self.with_params? + true + end + + def key + 'instance_statuses' + end + + def total_in_time_range? + false + end + + protected + + def perform_total_query + Status.joins(:account).merge(Account.where(domain: params[:domain])).count + end + + def perform_previous_total_query + nil + end + + def perform_data_query + sql = <<-SQL.squish + SELECT axis.*, ( + WITH new_statuses AS ( + SELECT statuses.id + FROM statuses + INNER JOIN accounts ON accounts.id = statuses.account_id + WHERE statuses.id BETWEEN $3 AND $4 + AND accounts.domain = $5::text + AND date_trunc('day', statuses.created_at)::date = axis.period + ) + SELECT count(*) FROM new_statuses + ) 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], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, params[:domain]]]) + + rows.map { |row| { date: row['period'], value: row['value'].to_s } } + 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 + + def params + @params.permit(:domain) + end +end diff --git a/app/lib/delivery_failure_tracker.rb b/app/lib/delivery_failure_tracker.rb index 8907ade4c..7b800fc0b 100644 --- a/app/lib/delivery_failure_tracker.rb +++ b/app/lib/delivery_failure_tracker.rb @@ -30,7 +30,7 @@ class DeliveryFailureTracker end def exhausted_deliveries_days - Redis.current.smembers(exhausted_deliveries_key).sort.map { |date| Date.new(date.slice(0, 4).to_i, date.slice(4, 2).to_i, date.slice(6, 2).to_i) } + @exhausted_deliveries_days ||= Redis.current.smembers(exhausted_deliveries_key).sort.map { |date| Date.new(date.slice(0, 4).to_i, date.slice(4, 2).to_i, date.slice(6, 2).to_i) } end alias reset! track_success! diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index bba04c603..b06fa09da 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -30,6 +30,14 @@ class DomainBlock < ApplicationRecord scope :with_user_facing_limitations, -> { where(severity: [:silence, :suspend]).or(where(reject_media: true)) } scope :by_severity, -> { order(Arel.sql('(CASE severity WHEN 0 THEN 1 WHEN 1 THEN 2 WHEN 2 THEN 0 END), reject_media, domain')) } + def policies + if suspend? + :suspend + else + [severity.to_sym, reject_media? ? :reject_media : nil, reject_reports? ? :reject_reports : nil].reject { |policy| policy == :noop || policy.nil? } + end + end + class << self def suspend?(domain) !!rule_for(domain)&.suspend? diff --git a/app/models/instance.rb b/app/models/instance.rb index 8949be054..7434d3322 100644 --- a/app/models/instance.rb +++ b/app/models/instance.rb @@ -32,35 +32,27 @@ class Instance < ApplicationRecord @delivery_failure_tracker ||= DeliveryFailureTracker.new(domain) end - def following_count - @following_count ||= Follow.where(account: accounts).count + def unavailable? + unavailable_domain.present? end - def followers_count - @followers_count ||= Follow.where(target_account: accounts).count - end - - def reports_count - @reports_count ||= Report.where(target_account: accounts).count - end - - def blocks_count - @blocks_count ||= Block.where(target_account: accounts).count - end - - def public_comment - domain_block&.public_comment - end - - def private_comment - domain_block&.private_comment - end - - def media_storage - @media_storage ||= MediaAttachment.where(account: accounts).sum(:file_file_size) + def failing? + failure_days.present? || unavailable? end def to_param domain end + + delegate :exhausted_deliveries_days, to: :delivery_failure_tracker + + def availability_over_days(num_days, end_date = Time.now.utc.to_date) + failures_map = exhausted_deliveries_days.index_with { true } + period_end_at = exhausted_deliveries_days.last || end_date + period_start_at = period_end_at - num_days.days + + (period_start_at..period_end_at).map do |date| + [date, failures_map[date]] + end + end end diff --git a/app/models/instance_filter.rb b/app/models/instance_filter.rb index 9e533c4aa..e7e5166a1 100644 --- a/app/models/instance_filter.rb +++ b/app/models/instance_filter.rb @@ -4,8 +4,7 @@ class InstanceFilter KEYS = %i( limited by_domain - warning - unavailable + availability ).freeze attr_reader :params @@ -34,12 +33,21 @@ class InstanceFilter Instance.joins(:domain_allow).reorder(Arel.sql('domain_allows.id desc')) when 'by_domain' Instance.matches_domain(value) - when 'warning' - Instance.where(domain: DeliveryFailureTracker.warning_domains) - when 'unavailable' - Instance.joins(:unavailable_domain) + when 'availability' + availability_scope(value) else raise "Unknown filter: #{key}" end end + + def availability_scope(value) + case value + when 'failing' + Instance.where(domain: DeliveryFailureTracker.warning_domains) + when 'unavailable' + Instance.joins(:unavailable_domain) + else + raise "Unknown availability: #{value}" + end + end end diff --git a/app/serializers/rest/admin/measure_serializer.rb b/app/serializers/rest/admin/measure_serializer.rb index 81d655c1a..fc16b85f2 100644 --- a/app/serializers/rest/admin/measure_serializer.rb +++ b/app/serializers/rest/admin/measure_serializer.rb @@ -1,12 +1,20 @@ # frozen_string_literal: true class REST::Admin::MeasureSerializer < ActiveModel::Serializer - attributes :key, :total, :previous_total, :data + attributes :key, :unit, :total + + attribute :human_value, if: -> { object.respond_to?(:value_to_human_value) } + attribute :previous_total, if: -> { object.total_in_time_range? } + attribute :data def total object.total.to_s end + def human_value + object.value_to_human_value(object.total) + end + def previous_total object.previous_total.to_s end diff --git a/app/views/admin/domain_blocks/edit.html.haml b/app/views/admin/domain_blocks/edit.html.haml index 6fe2edc82..39c6d108a 100644 --- a/app/views/admin/domain_blocks/edit.html.haml +++ b/app/views/admin/domain_blocks/edit.html.haml @@ -24,10 +24,10 @@ = f.input :obfuscate, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.obfuscate'), hint: I18n.t('admin.domain_blocks.obfuscate_hint') .field-group - = f.input :private_comment, wrapper: :with_label, label: I18n.t('admin.domain_blocks.private_comment'), hint: t('admin.domain_blocks.private_comment_hint'), rows: 6 + = f.input :private_comment, wrapper: :with_label, label: I18n.t('admin.domain_blocks.private_comment'), hint: t('admin.domain_blocks.private_comment_hint'), as: :string .field-group - = f.input :public_comment, wrapper: :with_label, label: I18n.t('admin.domain_blocks.public_comment'), hint: t('admin.domain_blocks.public_comment_hint'), rows: 6 + = f.input :public_comment, wrapper: :with_label, label: I18n.t('admin.domain_blocks.public_comment'), hint: t('admin.domain_blocks.public_comment_hint'), as: :string .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/admin/domain_blocks/new.html.haml b/app/views/admin/domain_blocks/new.html.haml index 8b78f71f2..bcaa331b5 100644 --- a/app/views/admin/domain_blocks/new.html.haml +++ b/app/views/admin/domain_blocks/new.html.haml @@ -24,10 +24,10 @@ = f.input :obfuscate, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.obfuscate'), hint: I18n.t('admin.domain_blocks.obfuscate_hint') .field-group - = f.input :private_comment, wrapper: :with_label, label: I18n.t('admin.domain_blocks.private_comment'), hint: t('admin.domain_blocks.private_comment_hint'), rows: 6 + = f.input :private_comment, wrapper: :with_label, label: I18n.t('admin.domain_blocks.private_comment'), hint: t('admin.domain_blocks.private_comment_hint'), as: :string .field-group - = f.input :public_comment, wrapper: :with_label, label: I18n.t('admin.domain_blocks.public_comment'), hint: t('admin.domain_blocks.public_comment_hint'), rows: 6 + = f.input :public_comment, wrapper: :with_label, label: I18n.t('admin.domain_blocks.public_comment'), hint: t('admin.domain_blocks.public_comment_hint'), as: :string .actions = f.button :button, t('.create'), type: :submit diff --git a/app/views/admin/domain_blocks/show.html.haml b/app/views/admin/domain_blocks/show.html.haml deleted file mode 100644 index e64aaa629..000000000 --- a/app/views/admin/domain_blocks/show.html.haml +++ /dev/null @@ -1,25 +0,0 @@ -- content_for :page_title do - = t('admin.domain_blocks.show.title', domain: @domain_block.domain) - -- if @domain_block.private_comment.present? - .speech-bubble - .speech-bubble__bubble - = simple_format(h(@domain_block.private_comment)) - .speech-bubble__owner= t 'admin.instances.private_comment' - -- if @domain_block.public_comment.present? - .speech-bubble - .speech-bubble__bubble - = simple_format(h(@domain_block.public_comment)) - .speech-bubble__owner= t 'admin.instances.public_comment' - -= simple_form_for @domain_block, url: admin_domain_block_path(@domain_block), method: :delete do |f| - - - unless (@domain_block.noop?) - %p= t(".retroactive.#{@domain_block.severity}") - %p.hint= t(:affected_accounts, - scope: [:admin, :domain_blocks, :show], - count: @domain_block.affected_accounts_count) - - .actions - = f.button :button, t('.undo'), type: :submit diff --git a/app/views/admin/instances/_exhausted_deliveries_days.haml b/app/views/admin/instances/_exhausted_deliveries_days.haml deleted file mode 100644 index e581f542d..000000000 --- a/app/views/admin/instances/_exhausted_deliveries_days.haml +++ /dev/null @@ -1,2 +0,0 @@ -%li.negative-hint - = l(exhausted_deliveries_days) diff --git a/app/views/admin/instances/_instance.html.haml b/app/views/admin/instances/_instance.html.haml index dc81007ac..8a4396002 100644 --- a/app/views/admin/instances/_instance.html.haml +++ b/app/views/admin/instances/_instance.html.haml @@ -1,33 +1,15 @@ .directory__tag = link_to admin_instance_path(instance) do %h4 + = fa_icon 'warning fw' if instance.failing? = instance.domain + %small - if instance.domain_block - - first_item = true - - if !instance.domain_block.noop? - = t("admin.domain_blocks.severity.#{instance.domain_block.severity}") - - first_item = false - - unless instance.domain_block.suspend? - - if instance.domain_block.reject_media? - - unless first_item - • - = t('admin.domain_blocks.rejecting_media') - - first_item = false - - if instance.domain_block.reject_reports? - - unless first_item - • - = t('admin.domain_blocks.rejecting_reports') - - elsif whitelist_mode? + = instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' • ') + - elsif instance.domain_allow = t('admin.accounts.whitelisted') - else = t('admin.accounts.no_limits_imposed') - - if instance.failure_days - = ' / ' - %span.negative-hint - = t('admin.instances.delivery.warning_message', count: instance.failure_days) - - if instance.unavailable_domain - = ' / ' - %span.negative-hint - = t('admin.instances.delivery.unavailable_message') + .trends__item__current{ title: t('admin.instances.known_accounts', count: instance.accounts_count) }= friendly_number_to_human instance.accounts_count diff --git a/app/views/admin/instances/index.html.haml b/app/views/admin/instances/index.html.haml index 797948d94..f8273718d 100644 --- a/app/views/admin/instances/index.html.haml +++ b/app/views/admin/instances/index.html.haml @@ -17,22 +17,11 @@ %li= filter_link_to t('admin.instances.moderation.limited'), limited: '1' .filter-subset - %strong= t('admin.instances.delivery.title') + %strong= t('admin.instances.availability.title') %ul - %li= filter_link_to t('admin.instances.delivery.all'), warning: nil, unavailable: nil - %li= filter_link_to t('admin.instances.delivery.warning'), warning: '1', unavailable: nil - %li= filter_link_to t('admin.instances.delivery.unavailable'), warning: nil, unavailable: '1' - - .back-link - = link_to admin_instances_path() do - %i.fa.fa-chevron-left.fa-fw - = t('admin.instances.back_to_all') - = link_to admin_instances_path(limited: 1) do - %i.fa.fa-chevron-left.fa-fw - = t('admin.instances.back_to_limited') - = link_to admin_instances_path(warning: 1) do - %i.fa.fa-chevron-left.fa-fw - = t('admin.instances.back_to_warning') + %li= filter_link_to t('admin.instances.delivery.all'), availability: nil + %li= filter_link_to t('admin.instances.delivery.failing'), availability: 'failing' + %li= filter_link_to t('admin.instances.delivery.unavailable'), availability: 'unavailable' - unless whitelist_mode? = form_tag admin_instances_url, method: 'GET', class: 'simple_form' do diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml index 4db8fd15c..bed94f3fe 100644 --- a/app/views/admin/instances/show.html.haml +++ b/app/views/admin/instances/show.html.haml @@ -1,88 +1,95 @@ - content_for :page_title do = @instance.domain -.filters - .back-link - = link_to admin_instances_path() do - %i.fa.fa-chevron-left.fa-fw - = t('admin.instances.back_to_all') - = link_to admin_instances_path(limited: 1) do - %i.fa.fa-chevron-left.fa-fw - = t('admin.instances.back_to_limited') - = link_to admin_instances_path(warning: 1) do - %i.fa.fa-chevron-left.fa-fw - = t('admin.instances.back_to_warning') +- content_for :header_tags do + = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' -.dashboard__counters - %div - = link_to admin_accounts_path(origin: 'remote', by_domain: @instance.domain) do - .dashboard__counters__num= number_with_delimiter @instance.accounts_count - .dashboard__counters__label= t 'admin.accounts.title' - %div - = link_to admin_reports_path(by_target_domain: @instance.domain) do - .dashboard__counters__num= number_with_delimiter @instance.reports_count - .dashboard__counters__label= t 'admin.instances.total_reported' - %div - %div - .dashboard__counters__num= number_to_human_size @instance.media_storage - .dashboard__counters__label= t 'admin.instances.total_storage' - %div - %div - .dashboard__counters__num= number_with_delimiter @instance.following_count - .dashboard__counters__label= t 'admin.instances.total_followed_by_them' - %div - %div - .dashboard__counters__num= number_with_delimiter @instance.followers_count - .dashboard__counters__label= t 'admin.instances.total_followed_by_us' - %div - %div - .dashboard__counters__num= number_with_delimiter @instance.blocks_count - .dashboard__counters__label= t 'admin.instances.total_blocked_by_us' +- content_for :heading_actions do + = l(@time_period.first) + = ' - ' + = l(@time_period.last) - %div - %div - .dashboard__counters__num - - if @instance.delivery_failure_tracker.available? - = fa_icon 'check' - - else - = fa_icon 'times' - .dashboard__counters__label= t 'admin.instances.delivery_available' +%p + = fa_icon 'info fw' + = t('admin.instances.totals_time_period_hint_html') -- if @instance.private_comment.present? - .speech-bubble - .speech-bubble__bubble - = simple_format(h(@instance.private_comment)) - .speech-bubble__owner= t 'admin.instances.private_comment' - -- if @instance.public_comment.present? - .speech-bubble - .speech-bubble__bubble - = simple_format(h(@instance.public_comment)) - .speech-bubble__owner= t 'admin.instances.public_comment' - -- unless @exhausted_deliveries_days.empty? - %h4= t 'admin.instances.delivery_error_days' - %ul - = render partial: 'exhausted_deliveries_days', collection: @exhausted_deliveries_days - %p.hint - = t 'admin.instances.delivery_error_hint', count: DeliveryFailureTracker::FAILURE_DAYS_THRESHOLD +.dashboard + .dashboard__item + = react_admin_component :counter, measure: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_accounts_measure'), href: admin_accounts_path(origin: 'remote', by_domain: @instance.domain) + .dashboard__item + = react_admin_component :counter, measure: 'instance_statuses', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_statuses_measure') + .dashboard__item + = react_admin_component :counter, measure: 'instance_media_attachments', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_media_attachments_measure') + .dashboard__item + = react_admin_component :counter, measure: 'instance_follows', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_follows_measure') + .dashboard__item + = react_admin_component :counter, measure: 'instance_followers', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_followers_measure') + .dashboard__item + = react_admin_component :counter, measure: 'instance_reports', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_reports_measure'), href: admin_reports_path(by_target_domain: @instance.domain) + .dashboard__item + = react_admin_component :dimension, dimension: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_accounts_dimension') + .dashboard__item + = react_admin_component :dimension, dimension: 'instance_languages', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_languages_dimension') %hr.spacer/ -%div.action-buttons - %div - - if @instance.domain_allow - = link_to t('admin.domain_allows.undo'), admin_domain_allow_path(@instance.domain_allow), class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete } - - elsif @instance.domain_block - = link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@instance.domain_block), class: 'button' - = link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@instance.domain_block), class: 'button' +%h3= t('admin.instances.content_policies.title') + +- if whitelist_mode? + %p= t('admin.instances.content_policies.limited_federation_mode_description_html') + + - if @instance.domain_allow + = link_to t('admin.domain_allows.undo'), admin_domain_allow_path(@instance.domain_allow), class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete } + - else + = link_to t('admin.domain_allows.add_new'), admin_domain_allows_path(domain_allow: { domain: @instance.domain }), class: 'button', method: :post +- else + %p= t('admin.instances.content_policies.description_html') + + - if @instance.domain_block + .table-wrapper + %table.table.horizontal-table + %tbody + %tr + %th= t('admin.instances.content_policies.comment') + %td= @instance.domain_block.private_comment + %tr + %th= t('admin.instances.content_policies.reason') + %td= @instance.domain_block.public_comment + %tr + %th= t('admin.instances.content_policies.policy') + %td= @instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' • ') + + = link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@instance.domain_block), class: 'button' + = link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@instance.domain_block), class: 'button', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete } + - else + = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @instance.domain), class: 'button' + +%hr.spacer/ + +%h3= t('admin.instances.availability.title') + +%p + = t('admin.instances.availability.description_html', count: DeliveryFailureTracker::FAILURE_DAYS_THRESHOLD) + +.availability-indicator + %ul.availability-indicator__graphic + - @instance.availability_over_days(14).each do |(date, failing)| + %li.availability-indicator__graphic__item{ class: failing ? 'negative' : 'neutral', title: l(date) } + .availability-indicator__hint + - if @instance.unavailable? + %span.negative-hint + = t('admin.instances.availability.failure_threshold_reached', date: l(@instance.unavailable_domain.created_at.to_date)) + = link_to t('admin.instances.delivery.restart'), restart_delivery_admin_instance_path(@instance), data: { confirm: t('admin.accounts.are_you_sure'), method: :post } + - elsif @instance.exhausted_deliveries_days.empty? + %span.positive-hint + = t('admin.instances.availability.no_failures_recorded') + = link_to t('admin.instances.delivery.stop'), stop_delivery_admin_instance_path(@instance), data: { confirm: t('admin.accounts.are_you_sure'), method: :post } - else - = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @instance.domain), class: 'button' - - if @instance.delivery_failure_tracker.available? - - unless @exhausted_deliveries_days.empty? - = link_to t('admin.instances.delivery.clear'), clear_delivery_errors_admin_instance_path(@instance), data: { confirm: t('admin.accounts.are_you_sure'), method: :post }, class: 'button' - = link_to t('admin.instances.delivery.stop'), stop_delivery_admin_instance_path(@instance), data: { confirm: t('admin.accounts.are_you_sure'), method: :post }, class: 'button' - - else - = link_to t('admin.instances.delivery.restart'), restart_delivery_admin_instance_path(@instance), data: { confirm: t('admin.accounts.are_you_sure'), method: :post }, class: 'button' - - if !@instance.delivery_failure_tracker.available? || @instance.accounts_count.zero? || @instance.domain_block&.suspend? - = link_to t('admin.instances.purge'), admin_instance_path(@instance), data: { confirm: t('admin.instances.confirm_purge'), method: :delete }, class: 'button' + %span.negative-hint + = t('admin.instances.availability.failures_recorded', count: @instance.delivery_failure_tracker.days) + = link_to t('admin.instances.delivery.clear'), clear_delivery_errors_admin_instance_path(@instance), data: { confirm: t('admin.accounts.are_you_sure'), method: :post } unless @instance.exhausted_deliveries_days.empty? + +- if @instance.unavailable? + %p= t('admin.instances.purge_description_html') + + = link_to t('admin.instances.purge'), admin_instance_path(@instance), data: { confirm: t('admin.instances.confirm_purge'), method: :delete }, class: 'button button--destructive' diff --git a/app/workers/admin/domain_purge_worker.rb b/app/workers/admin/domain_purge_worker.rb index 7cba2c89e..095232a6d 100644 --- a/app/workers/admin/domain_purge_worker.rb +++ b/app/workers/admin/domain_purge_worker.rb @@ -3,6 +3,8 @@ class Admin::DomainPurgeWorker include Sidekiq::Worker + sidekiq_options queue: 'pull', lock: :until_executed + def perform(domain) PurgeDomainService.new.call(domain) end diff --git a/config/locales/en.yml b/config/locales/en.yml index 1964285b0..acedf84c2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -450,21 +450,6 @@ en: reject_media_hint: Removes locally stored media files and refuses to download any in the future. Irrelevant for suspensions reject_reports: Reject reports reject_reports_hint: Ignore all reports coming from this domain. Irrelevant for suspensions - rejecting_media: rejecting media files - rejecting_reports: rejecting reports - severity: - silence: limited - suspend: suspended - show: - affected_accounts: - one: One account in the database affected - other: "%{count} accounts in the database affected" - zero: No account in the database is affected - retroactive: - silence: Undo limit of existing affected accounts from this domain - suspend: Unsuspend existing affected accounts from this domain - title: Undo domain block for %{domain} - undo: Undo undo: Undo domain block view: View domain block email_domain_blocks: @@ -495,23 +480,47 @@ en: title: Follow recommendations unsuppress: Restore follow recommendation instances: + availability: + description_html: + one: If delivering to the domain fails %{count} day without succeeding, no further delivery attempts will be made unless a delivery from the domain is received. + other: If delivering to the domain fails on %{count} different days without succeeding, no further delivery attempts will be made unless a delivery from the domain is received. + failure_threshold_reached: Failure threshold reached on %{date}. + failures_recorded: + one: Failed attempt on %{count} day. + other: Failed attempts on %{count} different days. + no_failures_recorded: No failures on record. + title: Availability back_to_all: All back_to_limited: Limited back_to_warning: Warning by_domain: Domain confirm_purge: Are you sure you want to permanently delete data from this domain? + content_policies: + comment: Internal note + description_html: You can define content policies that will be applied to all accounts from this domain and any of its subdomains. + policies: + reject_media: Reject media + reject_reports: Reject reports + silence: Limit + suspend: Suspend + policy: Policy + reason: Public reason + title: Content policies + dashboard: + instance_accounts_dimension: Most followed accounts + instance_accounts_measure: stored accounts + instance_followers_measure: our followers there + instance_follows_measure: their followers here + instance_languages_dimension: Top languages + instance_media_attachments_measure: stored media attachments + instance_reports_measure: reports about them + instance_statuses_measure: stored posts delivery: all: All clear: Clear delivery errors restart: Restart delivery stop: Stop delivery - title: Delivery unavailable: Unavailable - unavailable_message: Delivery unavailable - warning: Warning - warning_message: - one: Delivery failure %{count} day - other: Delivery failure %{count} days delivery_available: Delivery is available delivery_error_days: Delivery error days delivery_error_hint: If delivery is not possible for %{count} days, it will be automatically marked as undeliverable. @@ -528,12 +537,14 @@ en: private_comment: Private comment public_comment: Public comment purge: Purge + purge_description_html: If you believe this domain is offline for good, you can delete all account records and associated data from this domain from your storage. This may take a while. title: Federation total_blocked_by_us: Blocked by us total_followed_by_them: Followed by them total_followed_by_us: Followed by us total_reported: Reports about them total_storage: Media attachments + totals_time_period_hint_html: The totals displayed below include data for all time. invites: deactivate_all: Deactivate all filter: diff --git a/config/routes.rb b/config/routes.rb index 9e2f7a648..c108e2ec4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -191,7 +191,7 @@ Rails.application.routes.draw do get '/dashboard', to: 'dashboard#index' resources :domain_allows, only: [:new, :create, :show, :destroy] - resources :domain_blocks, only: [:new, :create, :show, :destroy, :update, :edit] + resources :domain_blocks, only: [:new, :create, :destroy, :update, :edit] resources :email_domain_blocks, only: [:index, :new, :create] do collection do diff --git a/spec/controllers/admin/domain_blocks_controller_spec.rb b/spec/controllers/admin/domain_blocks_controller_spec.rb index fb23658c0..ecc79292b 100644 --- a/spec/controllers/admin/domain_blocks_controller_spec.rb +++ b/spec/controllers/admin/domain_blocks_controller_spec.rb @@ -16,15 +16,6 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do end end - describe 'GET #show' do - it 'returns http success' do - domain_block = Fabricate(:domain_block) - get :show, params: { id: domain_block.id } - - expect(response).to have_http_status(200) - end - end - describe 'POST #create' do it 'blocks the domain when succeeded to save' do allow(DomainBlockWorker).to receive(:perform_async).and_return(true)