mirror of
https://github.com/discourse/discourse.git
synced 2024-11-26 15:03:38 +08:00
a72dc2f420
Previously, problem checks were all added as either class methods or blocks in AdminDashboardData. Another set of class methods were used to add and run problem checks. As of this PR, problem checks are promoted to first-class citizens. Each problem check receives their own class. This class of course contains the implementation for running the check, but also configuration items like retry strategies (for scheduled checks.) In addition, the parent class ProblemCheck also serves as a registry for checks. For example we can get a list of all existing check classes through ProblemCheck.checks, or just the ones running on a schedule through ProblemCheck.scheduled. After this refactor, the task of adding a new check is significantly simplified. You add a class that inherits ProblemCheck, you implement it, add a test, and you're good to go.
416 lines
12 KiB
Ruby
416 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class AdminDashboardData
|
|
include StatsCacheable
|
|
|
|
cattr_reader :problem_syms, :problem_blocks, :problem_messages
|
|
|
|
class Problem
|
|
VALID_PRIORITIES = %w[low high].freeze
|
|
|
|
attr_reader :message, :priority, :identifier
|
|
|
|
def initialize(message, priority: "low", identifier: nil)
|
|
@message = message
|
|
@priority = VALID_PRIORITIES.include?(priority) ? priority : "low"
|
|
@identifier = identifier
|
|
end
|
|
|
|
def to_s
|
|
@message
|
|
end
|
|
|
|
def to_h
|
|
{ message: message, priority: priority, identifier: identifier }
|
|
end
|
|
|
|
def self.from_h(h)
|
|
h = h.with_indifferent_access
|
|
return if h[:message].blank?
|
|
new(h[:message], priority: h[:priority], identifier: h[:identifier])
|
|
end
|
|
end
|
|
|
|
# kept for backward compatibility
|
|
GLOBAL_REPORTS ||= []
|
|
|
|
PROBLEM_MESSAGE_PREFIX = "admin-problem:"
|
|
SCHEDULED_PROBLEM_STORAGE_KEY = "admin-found-scheduled-problems-list"
|
|
|
|
def initialize(opts = {})
|
|
@opts = opts
|
|
end
|
|
|
|
def get_json
|
|
{}
|
|
end
|
|
|
|
def as_json(_options = nil)
|
|
@json ||= get_json
|
|
end
|
|
|
|
def problems
|
|
problems = []
|
|
self.class.problem_syms.each do |sym|
|
|
message = public_send(sym)
|
|
problems << Problem.new(message) if message.present?
|
|
end
|
|
self.class.problem_blocks.each do |blk|
|
|
message = instance_exec(&blk)
|
|
problems << Problem.new(message) if message.present?
|
|
end
|
|
self.class.problem_messages.each do |i18n_key|
|
|
message = self.class.problem_message_check(i18n_key)
|
|
problems << Problem.new(message) if message.present?
|
|
end
|
|
problems += self.class.load_found_scheduled_check_problems
|
|
problems.compact!
|
|
|
|
if problems.empty?
|
|
self.class.clear_problems_started
|
|
else
|
|
self.class.set_problems_started
|
|
end
|
|
|
|
problems
|
|
end
|
|
|
|
def self.add_problem_check(*syms, &blk)
|
|
@@problem_syms.push(*syms) if syms
|
|
@@problem_blocks << blk if blk
|
|
end
|
|
|
|
def self.add_found_scheduled_check_problem(problem)
|
|
problems = load_found_scheduled_check_problems
|
|
if problem.identifier.present?
|
|
return if problems.find { |p| p.identifier == problem.identifier }
|
|
end
|
|
set_found_scheduled_check_problem(problem)
|
|
end
|
|
|
|
def self.set_found_scheduled_check_problem(problem)
|
|
Discourse.redis.rpush(SCHEDULED_PROBLEM_STORAGE_KEY, JSON.dump(problem.to_h))
|
|
end
|
|
|
|
def self.clear_found_scheduled_check_problems
|
|
Discourse.redis.del(SCHEDULED_PROBLEM_STORAGE_KEY)
|
|
end
|
|
|
|
def self.clear_found_problem(identifier)
|
|
problems = load_found_scheduled_check_problems
|
|
problem = problems.find { |p| p.identifier == identifier }
|
|
Discourse.redis.lrem(SCHEDULED_PROBLEM_STORAGE_KEY, 1, JSON.dump(problem.to_h))
|
|
end
|
|
|
|
def self.load_found_scheduled_check_problems
|
|
found_problems = Discourse.redis.lrange(SCHEDULED_PROBLEM_STORAGE_KEY, 0, -1)
|
|
|
|
return [] if found_problems.blank?
|
|
|
|
found_problems.filter_map do |problem|
|
|
begin
|
|
Problem.from_h(JSON.parse(problem))
|
|
rescue JSON::ParserError => err
|
|
Discourse.warn_exception(
|
|
err,
|
|
message: "Error parsing found problem JSON in admin dashboard: #{problem}",
|
|
)
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
|
|
##
|
|
# We call this method in the class definition below
|
|
# so all of the problem checks in this class are registered on
|
|
# boot. These problem checks are run when the problems are loaded in
|
|
# the admin dashboard controller.
|
|
#
|
|
# This method also can be used in testing to reset checks between
|
|
# tests. It will also fire multiple times in development mode because
|
|
# classes are not cached.
|
|
def self.reset_problem_checks
|
|
@@problem_syms = []
|
|
@@problem_blocks = []
|
|
|
|
@@problem_messages = %w[
|
|
dashboard.bad_favicon_url
|
|
dashboard.poll_pop3_timeout
|
|
dashboard.poll_pop3_auth_error
|
|
]
|
|
|
|
add_problem_check :rails_env_check,
|
|
:host_names_check,
|
|
:force_https_check,
|
|
:ram_check,
|
|
:google_oauth2_config_check,
|
|
:facebook_config_check,
|
|
:twitter_config_check,
|
|
:github_config_check,
|
|
:s3_config_check,
|
|
:s3_cdn_check,
|
|
:image_magick_check,
|
|
:failing_emails_check,
|
|
:subfolder_ends_in_slash_check,
|
|
:email_polling_errored_recently,
|
|
:out_of_date_themes,
|
|
:unreachable_themes,
|
|
:watched_words_check,
|
|
:google_analytics_version_check,
|
|
:translation_overrides_check,
|
|
:ember_version_check
|
|
|
|
add_problem_check { sidekiq_check || queue_size_check }
|
|
end
|
|
reset_problem_checks
|
|
|
|
def self.fetch_stats
|
|
new.as_json
|
|
end
|
|
|
|
def self.reports(source)
|
|
source.map { |type| Report.find(type).as_json }
|
|
end
|
|
|
|
def self.stats_cache_key
|
|
"dashboard-data-#{Report::SCHEMA_VERSION}"
|
|
end
|
|
|
|
def self.problems_started_key
|
|
"dash-problems-started-at"
|
|
end
|
|
|
|
def self.set_problems_started
|
|
existing_time = Discourse.redis.get(problems_started_key)
|
|
Discourse.redis.setex(problems_started_key, 14.days.to_i, existing_time || Time.zone.now.to_s)
|
|
end
|
|
|
|
def self.clear_problems_started
|
|
Discourse.redis.del problems_started_key
|
|
end
|
|
|
|
def self.problems_started_at
|
|
s = Discourse.redis.get(problems_started_key)
|
|
s ? Time.zone.parse(s) : nil
|
|
end
|
|
|
|
def self.fetch_problems(opts = {})
|
|
new(opts).problems
|
|
end
|
|
|
|
def self.problem_message_check(i18n_key)
|
|
if Discourse.redis.get(problem_message_key(i18n_key))
|
|
I18n.t(i18n_key, base_path: Discourse.base_path)
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
##
|
|
# Arbitrary messages cannot be added here, they must already be defined
|
|
# in the @problem_messages array which is defined in reset_problem_checks.
|
|
# The array is iterated over and each key that exists in redis will be added
|
|
# to the final problems output in #problems.
|
|
def self.add_problem_message(i18n_key, expire_seconds = nil)
|
|
if expire_seconds.to_i > 0
|
|
Discourse.redis.setex problem_message_key(i18n_key), expire_seconds.to_i, 1
|
|
else
|
|
Discourse.redis.set problem_message_key(i18n_key), 1
|
|
end
|
|
end
|
|
|
|
def self.clear_problem_message(i18n_key)
|
|
Discourse.redis.del problem_message_key(i18n_key)
|
|
end
|
|
|
|
def self.problem_message_key(i18n_key)
|
|
"#{PROBLEM_MESSAGE_PREFIX}#{i18n_key}"
|
|
end
|
|
|
|
def rails_env_check
|
|
I18n.t("dashboard.rails_env_warning", env: Rails.env) unless Rails.env.production?
|
|
end
|
|
|
|
def host_names_check
|
|
if %w[localhost production.localhost].include?(Discourse.current_hostname)
|
|
I18n.t("dashboard.host_names_warning")
|
|
end
|
|
end
|
|
|
|
def sidekiq_check
|
|
last_job_performed_at = Jobs.last_job_performed_at
|
|
if Jobs.queued > 0 && (last_job_performed_at.nil? || last_job_performed_at < 2.minutes.ago)
|
|
I18n.t("dashboard.sidekiq_warning")
|
|
end
|
|
end
|
|
|
|
def queue_size_check
|
|
queue_size = Jobs.queued
|
|
I18n.t("dashboard.queue_size_warning", queue_size: queue_size) if queue_size >= 100_000
|
|
end
|
|
|
|
def ram_check
|
|
I18n.t("dashboard.memory_warning") if MemInfo.new.mem_total && MemInfo.new.mem_total < 950_000
|
|
end
|
|
|
|
def google_oauth2_config_check
|
|
if SiteSetting.enable_google_oauth2_logins &&
|
|
(
|
|
SiteSetting.google_oauth2_client_id.blank? ||
|
|
SiteSetting.google_oauth2_client_secret.blank?
|
|
)
|
|
I18n.t("dashboard.google_oauth2_config_warning", base_path: Discourse.base_path)
|
|
end
|
|
end
|
|
|
|
def facebook_config_check
|
|
if SiteSetting.enable_facebook_logins &&
|
|
(SiteSetting.facebook_app_id.blank? || SiteSetting.facebook_app_secret.blank?)
|
|
I18n.t("dashboard.facebook_config_warning", base_path: Discourse.base_path)
|
|
end
|
|
end
|
|
|
|
def twitter_config_check
|
|
if SiteSetting.enable_twitter_logins &&
|
|
(SiteSetting.twitter_consumer_key.blank? || SiteSetting.twitter_consumer_secret.blank?)
|
|
I18n.t("dashboard.twitter_config_warning", base_path: Discourse.base_path)
|
|
end
|
|
end
|
|
|
|
def github_config_check
|
|
if SiteSetting.enable_github_logins &&
|
|
(SiteSetting.github_client_id.blank? || SiteSetting.github_client_secret.blank?)
|
|
I18n.t("dashboard.github_config_warning", base_path: Discourse.base_path)
|
|
end
|
|
end
|
|
|
|
def s3_config_check
|
|
# if set via global setting it is validated during the `use_s3?` call
|
|
if !GlobalSetting.use_s3?
|
|
bad_keys =
|
|
(SiteSetting.s3_access_key_id.blank? || SiteSetting.s3_secret_access_key.blank?) &&
|
|
!SiteSetting.s3_use_iam_profile
|
|
|
|
if SiteSetting.enable_s3_uploads && (bad_keys || SiteSetting.s3_upload_bucket.blank?)
|
|
return I18n.t("dashboard.s3_config_warning", base_path: Discourse.base_path)
|
|
end
|
|
|
|
if SiteSetting.backup_location == BackupLocationSiteSetting::S3 &&
|
|
(bad_keys || SiteSetting.s3_backup_bucket.blank?)
|
|
return I18n.t("dashboard.s3_backup_config_warning", base_path: Discourse.base_path)
|
|
end
|
|
end
|
|
nil
|
|
end
|
|
|
|
def s3_cdn_check
|
|
if (GlobalSetting.use_s3? || SiteSetting.enable_s3_uploads) &&
|
|
SiteSetting.Upload.s3_cdn_url.blank?
|
|
I18n.t("dashboard.s3_cdn_warning")
|
|
end
|
|
end
|
|
|
|
def translation_overrides_check
|
|
if TranslationOverride.exists?(status: %i[outdated invalid_interpolation_keys])
|
|
I18n.t("dashboard.outdated_translations_warning", base_path: Discourse.base_path)
|
|
end
|
|
end
|
|
|
|
def image_magick_check
|
|
if SiteSetting.create_thumbnails && !system("command -v convert >/dev/null;")
|
|
I18n.t("dashboard.image_magick_warning")
|
|
end
|
|
end
|
|
|
|
def failing_emails_check
|
|
num_failed_jobs = Jobs.num_email_retry_jobs
|
|
if num_failed_jobs > 0
|
|
I18n.t(
|
|
"dashboard.failing_emails_warning",
|
|
num_failed_jobs: num_failed_jobs,
|
|
base_path: Discourse.base_path,
|
|
)
|
|
end
|
|
end
|
|
|
|
def subfolder_ends_in_slash_check
|
|
I18n.t("dashboard.subfolder_ends_in_slash") if Discourse.base_path =~ %r{/\z}
|
|
end
|
|
|
|
def google_analytics_version_check
|
|
I18n.t("dashboard.v3_analytics_deprecated") if SiteSetting.ga_version == "v3_analytics"
|
|
end
|
|
|
|
def email_polling_errored_recently
|
|
errors = Jobs::PollMailbox.errors_in_past_24_hours
|
|
if errors > 0
|
|
I18n.t(
|
|
"dashboard.email_polling_errored_recently",
|
|
count: errors,
|
|
base_path: Discourse.base_path,
|
|
)
|
|
end
|
|
end
|
|
|
|
def missing_mailgun_api_key
|
|
return unless SiteSetting.reply_by_email_enabled
|
|
return unless ActionMailer::Base.smtp_settings[:address]["smtp.mailgun.org"]
|
|
return unless SiteSetting.mailgun_api_key.blank?
|
|
I18n.t("dashboard.missing_mailgun_api_key")
|
|
end
|
|
|
|
def force_https_check
|
|
return unless @opts[:check_force_https]
|
|
unless SiteSetting.force_https
|
|
I18n.t("dashboard.force_https_warning", base_path: Discourse.base_path)
|
|
end
|
|
end
|
|
|
|
def watched_words_check
|
|
WatchedWord.actions.keys.each do |action|
|
|
begin
|
|
WordWatcher.compiled_regexps_for_action(action, raise_errors: true)
|
|
rescue RegexpError => e
|
|
translated_action = I18n.t("admin_js.admin.watched_words.actions.#{action}")
|
|
I18n.t(
|
|
"dashboard.watched_word_regexp_error",
|
|
base_path: Discourse.base_path,
|
|
action: translated_action,
|
|
)
|
|
end
|
|
end
|
|
nil
|
|
end
|
|
|
|
def ember_version_check
|
|
I18n.t("dashboard.ember_version_warning") if ENV["EMBER_VERSION"] == "3"
|
|
end
|
|
|
|
def out_of_date_themes
|
|
old_themes = RemoteTheme.out_of_date_themes
|
|
return unless old_themes.present?
|
|
|
|
themes_html_format(old_themes, "dashboard.out_of_date_themes")
|
|
end
|
|
|
|
def unreachable_themes
|
|
themes = RemoteTheme.unreachable_themes
|
|
return unless themes.present?
|
|
|
|
themes_html_format(themes, "dashboard.unreachable_themes")
|
|
end
|
|
|
|
private
|
|
|
|
def themes_html_format(themes, i18n_key)
|
|
html =
|
|
themes
|
|
.map do |name, id|
|
|
"<li><a href=\"/admin/customize/themes/#{id}\">#{CGI.escapeHTML(name)}</a></li>"
|
|
end
|
|
.join("\n")
|
|
|
|
"#{I18n.t(i18n_key)}<ul>#{html}</ul>"
|
|
end
|
|
end
|