discourse/app/models/admin_dashboard_data.rb
Ted Johansson a72dc2f420
DEV: Introduce a problem checks API (#25783)
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.
2024-02-23 11:20:32 +08:00

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