about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/domain_blocks_controller.rb4
-rw-r--r--app/controllers/admin/instances_controller.rb26
-rw-r--r--app/javascript/mastodon/components/admin/Counter.js6
-rw-r--r--app/javascript/styles/mastodon/admin.scss50
-rw-r--r--app/javascript/styles/mastodon/tables.scss18
-rw-r--r--app/lib/admin/metrics/dimension.rb2
-rw-r--r--app/lib/admin/metrics/dimension/instance_accounts_dimension.rb35
-rw-r--r--app/lib/admin/metrics/dimension/instance_languages_dimension.rb37
-rw-r--r--app/lib/admin/metrics/measure.rb6
-rw-r--r--app/lib/admin/metrics/measure/base_measure.rb8
-rw-r--r--app/lib/admin/metrics/measure/instance_accounts_measure.rb58
-rw-r--r--app/lib/admin/metrics/measure/instance_followers_measure.rb59
-rw-r--r--app/lib/admin/metrics/measure/instance_follows_measure.rb59
-rw-r--r--app/lib/admin/metrics/measure/instance_media_attachments_measure.rb69
-rw-r--r--app/lib/admin/metrics/measure/instance_reports_measure.rb59
-rw-r--r--app/lib/admin/metrics/measure/instance_statuses_measure.rb60
-rw-r--r--app/lib/delivery_failure_tracker.rb2
-rw-r--r--app/models/domain_block.rb8
-rw-r--r--app/models/instance.rb36
-rw-r--r--app/models/instance_filter.rb16
-rw-r--r--app/serializers/rest/admin/measure_serializer.rb10
-rw-r--r--app/views/admin/domain_blocks/edit.html.haml4
-rw-r--r--app/views/admin/domain_blocks/new.html.haml4
-rw-r--r--app/views/admin/domain_blocks/show.html.haml25
-rw-r--r--app/views/admin/instances/_exhausted_deliveries_days.haml2
-rw-r--r--app/views/admin/instances/_instance.html.haml28
-rw-r--r--app/views/admin/instances/index.html.haml19
-rw-r--r--app/views/admin/instances/show.html.haml169
-rw-r--r--app/workers/admin/domain_purge_worker.rb2
29 files changed, 678 insertions, 203 deletions
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 = (
         <React.Fragment>
-          <span className='sparkline__value__total'><FormattedNumber value={measure.total} /></span>
-          <span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>
+          <span className='sparkline__value__total'>{measure.human_value || <FormattedNumber value={measure.total} />}</span>
+          {measure.previous_total && (<span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>)}
         </React.Fragment>
       );
     }
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
+  def failing?
+    failure_days.present? || unavailable?
   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
+  def to_param
+    domain
   end
 
-  def private_comment
-    domain_block&.private_comment
-  end
+  delegate :exhausted_deliveries_days, to: :delivery_failure_tracker
 
-  def media_storage
-    @media_storage ||= MediaAttachment.where(account: accounts).sum(:file_file_size)
-  end
+  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
 
-  def to_param
-    domain
+    (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'
+    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 filter: #{key}"
+      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
-                &bull;
-              = t('admin.domain_blocks.rejecting_media')
-              - first_item = false
-            - if instance.domain_block.reject_reports?
-              - unless first_item
-                &bull;
-              = 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')
-
-.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'
-
-  %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'
-
-- 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
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+
+- content_for :heading_actions do
+  = l(@time_period.first)
+  = ' - '
+  = l(@time_period.last)
+
+%p
+  = fa_icon 'info fw'
+  = t('admin.instances.totals_time_period_hint_html')
+
+.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'
-    - 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'
+%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.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