DEV: Database backed admin notices (#26192)

This PR introduces a basic AdminNotice model to store these notices. Admin notices are categorized by their source/type (currently only notices from problem check.) They also have a priority.
This commit is contained in:
Ted Johansson 2024-05-23 09:29:08 +08:00 committed by GitHub
parent 7e8e803785
commit 3137e60653
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 512 additions and 264 deletions

View File

@ -26,7 +26,9 @@ class Admin::DashboardController < Admin::StaffController
end
def problems
render_json_dump(problems: AdminDashboardData.fetch_problems(check_force_https: request.ssl?))
ProblemCheck.realtime.run_all
render json: { problems: serialize_data(AdminNotice.problem.all, AdminNoticeSerializer) }
end
def new_features

View File

@ -162,7 +162,7 @@ class StaticController < ApplicationController
file&.read || ""
rescue => e
AdminDashboardData.add_problem_message("dashboard.bad_favicon_url", 1800)
ProblemCheckTracker[:bad_favicon_url].problem!
Rails.logger.debug("Failed to fetch favicon #{favicon.url}: #{e}\n#{e.backtrace}")
""
ensure

View File

@ -15,14 +15,8 @@ module Jobs
check = ProblemCheck[identifier]
problems = check.call
raise RetrySignal if problems.present? && retry_count < check.max_retries
if problems.present?
problems.each { |problem| AdminDashboardData.add_found_scheduled_check_problem(problem) }
ProblemCheckTracker[identifier].problem!(next_run_at: check.perform_every.from_now)
else
ProblemCheckTracker[identifier].no_problem!(next_run_at: check.perform_every.from_now)
check.run do |problems|
raise RetrySignal if problems.present? && retry_count < check.max_retries
end
rescue RetrySignal
Jobs.enqueue_in(

View File

@ -68,7 +68,7 @@ module Jobs
if count > 3
Discourse.redis.del(POLL_MAILBOX_TIMEOUT_ERROR_KEY)
mark_as_errored!
add_admin_dashboard_problem_message("dashboard.poll_pop3_timeout")
track_problem(:poll_pop3_timeout)
Discourse.handle_job_exception(
e,
error_context(
@ -79,7 +79,7 @@ module Jobs
end
rescue Net::POPAuthenticationError => e
mark_as_errored!
add_admin_dashboard_problem_message("dashboard.poll_pop3_auth_error")
track_problem(:poll_pop3_auth_error)
Discourse.handle_job_exception(e, error_context(@args, "Signing in to poll incoming emails."))
end
@ -104,11 +104,8 @@ module Jobs
Discourse.redis.zadd(POLL_MAILBOX_ERRORS_KEY, now, now.to_s)
end
def add_admin_dashboard_problem_message(i18n_key)
AdminDashboardData.add_problem_message(
i18n_key,
SiteSetting.pop3_polling_period_mins.minutes + 5.minutes,
)
def track_problem(identifier)
ProblemCheckTracker[identifier].problem!
end
end
end

View File

@ -3,7 +3,7 @@
class AdminDashboardData
include StatsCacheable
cattr_reader :problem_messages
cattr_reader :problem_messages, default: []
# kept for backward compatibility
GLOBAL_REPORTS ||= []
@ -100,13 +100,8 @@ class AdminDashboardData
# tests. It will also fire multiple times in development mode because
# classes are not cached.
def self.reset_problem_checks
@@problem_messages = %w[
dashboard.bad_favicon_url
dashboard.poll_pop3_timeout
dashboard.poll_pop3_auth_error
]
@@problem_messages = []
end
reset_problem_checks
def self.fetch_stats
new.as_json

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
class AdminNotice < ActiveRecord::Base
validates :identifier, presence: true
enum :priority, %i[low high].freeze
enum :subject, %i[problem].freeze
def message
I18n.t(
"dashboard.#{subject}.#{identifier}",
**details.symbolize_keys.merge(base_path: Discourse.base_path),
)
end
end
# == Schema Information
#
# Table name: admin_notices
#
# id :bigint not null, primary key
# subject :integer not null
# priority :integer not null
# identifier :string not null
# details :json not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_admin_notices_on_subject (subject)
# index_admin_notices_on_identifier (identifier)
#

View File

@ -1,6 +1,26 @@
# frozen_string_literal: true
class ProblemCheck
class Collection
include Enumerable
def initialize(checks)
@checks = checks
end
def each(...)
checks.each(...)
end
def run_all
each(&:run)
end
private
attr_reader :checks
end
include ActiveSupport::Configurable
config_accessor :priority, default: "low", instance_writer: false
@ -33,6 +53,7 @@ class ProblemCheck
# Note: This list must come after the `config_accessor` declarations.
#
CORE_PROBLEM_CHECKS = [
ProblemCheck::BadFaviconUrl,
ProblemCheck::EmailPollingErroredRecently,
ProblemCheck::FacebookConfig,
ProblemCheck::FailingEmails,
@ -45,6 +66,8 @@ class ProblemCheck
ProblemCheck::ImageMagick,
ProblemCheck::MissingMailgunApiKey,
ProblemCheck::OutOfDateThemes,
ProblemCheck::PollPop3Timeout,
ProblemCheck::PollPop3AuthError,
ProblemCheck::RailsEnv,
ProblemCheck::Ram,
ProblemCheck::S3BackupConfig,
@ -66,15 +89,15 @@ class ProblemCheck
end
def self.checks
CORE_PROBLEM_CHECKS | DiscoursePluginRegistry.problem_checks
Collection.new(DiscoursePluginRegistry.problem_checks.concat(CORE_PROBLEM_CHECKS))
end
def self.scheduled
checks.select(&:scheduled?)
Collection.new(checks.select(&:scheduled?))
end
def self.realtime
checks.reject(&:scheduled?)
Collection.new(checks.select(&:realtime?))
end
def self.identifier
@ -96,6 +119,10 @@ class ProblemCheck
new(data).call
end
def self.run(data = {}, &)
new(data).run(&)
end
def initialize(data = {})
@data = OpenStruct.new(data)
end
@ -106,9 +133,40 @@ class ProblemCheck
raise NotImplementedError
end
def run
problems = call
yield(problems) if block_given?
next_run_at = perform_every&.from_now
if problems.empty?
targets.each { |t| tracker(t).no_problem!(next_run_at:) }
else
problems
.uniq(&:target)
.each do |problem|
tracker(problem.target).problem!(
next_run_at:,
details: translation_data.merge(problem.details).merge(base_path: Discourse.base_path),
)
end
end
problems
end
private
def problem(override_key = nil, override_data = {})
def tracker(target = nil)
ProblemCheckTracker[identifier, target]
end
def targets
[nil]
end
def problem(override_key: nil, override_data: {})
[
Problem.new(
message ||
@ -132,8 +190,7 @@ class ProblemCheck
end
def translation_key
# TODO: Infer a default based on class name, then move translations in locale file.
raise NotImplementedError
"dashboard.problem.#{identifier}"
end
def translation_data

View File

@ -1,27 +1,42 @@
# frozen_string_literal: true
class ProblemCheckTracker < ActiveRecord::Base
validates :identifier, presence: true, uniqueness: true
validates :identifier, presence: true, uniqueness: { scope: :target }
validates :blips, presence: true, numericality: { greater_than_or_equal_to: 0 }
def self.[](identifier)
find_or_create_by(identifier:)
scope :failing, -> { where("last_problem_at = last_run_at") }
scope :passing, -> { where("last_success_at = last_run_at") }
def self.[](identifier, target = nil)
find_or_create_by(identifier:, target:)
end
def ready_to_run?
next_run_at.blank? || next_run_at.past?
end
def problem!(next_run_at: nil)
def failing?
last_problem_at == last_run_at
end
def passing?
last_success_at == last_run_at
end
def problem!(next_run_at: nil, details: {})
now = Time.current
update!(blips: blips + 1, last_run_at: now, last_problem_at: now, next_run_at:)
update!(blips: blips + 1, details:, last_run_at: now, last_problem_at: now, next_run_at:)
sound_the_alarm if sound_the_alarm?
end
def no_problem!(next_run_at: nil)
now = Time.current
update!(blips: 0, last_run_at: now, last_success_at: now, next_run_at:)
silence_the_alarm
end
def check
@ -31,7 +46,26 @@ class ProblemCheckTracker < ActiveRecord::Base
private
def sound_the_alarm?
blips > check.max_blips
failing? && blips > check.max_blips
end
def sound_the_alarm
admin_notice.create_with(
priority: check.priority,
details: details.merge(target:),
).find_or_create_by(identifier:)
end
def silence_the_alarm
admin_notice.where(identifier:).delete_all
end
def admin_notice
if target.present?
AdminNotice.problem.where("details->>'target' = ?", target)
else
AdminNotice.problem.where("(details->>'target') IS NULL")
end
end
end
@ -46,8 +80,10 @@ end
# next_run_at :datetime
# last_success_at :datetime
# last_problem_at :datetime
# details :json
# target :string
#
# Indexes
#
# index_problem_check_trackers_on_identifier (identifier) UNIQUE
# index_problem_check_trackers_on_identifier_and_target (identifier,target) UNIQUE
#

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class AdminNoticeSerializer < ApplicationSerializer
attributes :priority, :message, :identifier
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class ProblemCheck::BadFaviconUrl < ProblemCheck::InlineProblemCheck
self.priority = "low"
end

View File

@ -15,10 +15,6 @@ class ProblemCheck::EmailPollingErroredRecently < ProblemCheck
@polling_error_count ||= Jobs::PollMailbox.errors_in_past_24_hours
end
def translation_key
"dashboard.email_polling_errored_recently"
end
def translation_data
{ count: polling_error_count }
end

View File

@ -12,10 +12,6 @@ class ProblemCheck::FacebookConfig < ProblemCheck
private
def translation_key
"dashboard.facebook_config_warning"
end
def facebook_credentials_present?
SiteSetting.facebook_app_id.present? && SiteSetting.facebook_app_secret.present?
end

View File

@ -15,10 +15,6 @@ class ProblemCheck::FailingEmails < ProblemCheck
@failed_job_count ||= Jobs.num_email_retry_jobs
end
def translation_key
"dashboard.failing_emails_warning"
end
def translation_data
{ num_failed_jobs: failed_job_count }
end

View File

@ -9,10 +9,4 @@ class ProblemCheck::ForceHttps < ProblemCheck
problem
end
private
def translation_key
"dashboard.force_https_warning"
end
end

View File

@ -12,10 +12,6 @@ class ProblemCheck::GithubConfig < ProblemCheck
private
def translation_key
"dashboard.github_config_warning"
end
def github_credentials_present?
SiteSetting.github_client_id.present? && SiteSetting.github_client_secret.present?
end

View File

@ -8,10 +8,4 @@ class ProblemCheck::GoogleAnalyticsVersion < ProblemCheck
problem
end
private
def translation_key
"dashboard.v3_analytics_deprecated"
end
end

View File

@ -12,10 +12,6 @@ class ProblemCheck::GoogleOauth2Config < ProblemCheck
private
def translation_key
"dashboard.google_oauth2_config_warning"
end
def google_oauth2_credentials_present?
SiteSetting.google_oauth2_client_id.present? && SiteSetting.google_oauth2_client_secret.present?
end

View File

@ -7,6 +7,7 @@
# problem checks, and if any credentials have issues they will show up on
# the admin dashboard as a high priority issue.
class ProblemCheck::GroupEmailCredentials < ProblemCheck
self.priority = "high"
self.perform_every = 30.minutes
def call
@ -15,6 +16,10 @@ class ProblemCheck::GroupEmailCredentials < ProblemCheck
private
def targets
[*Group.with_smtp_configured.pluck(:name), *Group.with_imap_configured.pluck(:name)]
end
def smtp_errors
return [] if !SiteSetting.enable_smtp
@ -52,7 +57,7 @@ class ProblemCheck::GroupEmailCredentials < ProblemCheck
rescue *EmailSettingsExceptionHandler::EXPECTED_EXCEPTIONS => err
message =
I18n.t(
"dashboard.group_email_credentials_warning",
"dashboard.problem.group_email_credentials",
{
base_path: Discourse.base_path,
group_name: group.name,
@ -61,7 +66,17 @@ class ProblemCheck::GroupEmailCredentials < ProblemCheck
},
)
Problem.new(message, priority: "high", identifier: "group_#{group.id}_email_credentials")
Problem.new(
message,
priority: "high",
identifier: "group_email_credentials",
target: group.id,
details: {
group_name: group.name,
group_full_name: group.full_name,
error: EmailSettingsExceptionHandler.friendly_exception_message(err, group.smtp_server),
},
)
rescue => err
Discourse.warn_exception(
err,

View File

@ -8,10 +8,4 @@ class ProblemCheck::HostNames < ProblemCheck
problem
end
private
def translation_key
"dashboard.host_names_warning"
end
end

View File

@ -9,10 +9,4 @@ class ProblemCheck::ImageMagick < ProblemCheck
problem
end
private
def translation_key
"dashboard.image_magick_warning"
end
end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
class ProblemCheck::InlineProblemCheck < ProblemCheck
def call
# The logic of this problem check is performed inline, so this class is
# purely here to support its configuration.
#
no_problem
end
end

View File

@ -10,10 +10,4 @@ class ProblemCheck::MaxmindDbConfiguration < ProblemCheck
no_problem
end
end
private
def translation_key
"dashboard.maxmind_db_configuration_warning"
end
end

View File

@ -10,10 +10,4 @@ class ProblemCheck::MissingMailgunApiKey < ProblemCheck
problem
end
private
def translation_key
"dashboard.missing_mailgun_api_key"
end
end

View File

@ -16,7 +16,7 @@ class ProblemCheck::OutOfDateThemes < ProblemCheck
end
def message
"#{I18n.t("dashboard.out_of_date_themes")}<ul>#{themes_list}</ul>"
"#{I18n.t("dashboard.problem.out_of_date_themes")}<ul>#{themes_list}</ul>"
end
def themes_list

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class ProblemCheck::PollPop3AuthError < ProblemCheck::InlineProblemCheck
self.priority = "low"
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class ProblemCheck::PollPop3Timeout < ProblemCheck::InlineProblemCheck
self.priority = "low"
end

View File

@ -3,12 +3,14 @@
class ProblemCheck::Problem
PRIORITIES = %w[low high].freeze
attr_reader :message, :priority, :identifier
attr_reader :message, :priority, :identifier, :target, :details
def initialize(message, priority: "low", identifier: nil)
def initialize(message, priority: "low", identifier: nil, target: nil, details: {})
@message = message
@priority = PRIORITIES.include?(priority) ? priority : "low"
@identifier = identifier
@target = target
@details = details
end
def to_s

View File

@ -11,10 +11,6 @@ class ProblemCheck::RailsEnv < ProblemCheck
private
def translation_key
"dashboard.rails_env_warning"
end
def translation_data
{ env: Rails.env }
end

View File

@ -11,10 +11,4 @@ class ProblemCheck::Ram < ProblemCheck
problem
end
private
def translation_key
"dashboard.memory_warning"
end
end

View File

@ -18,8 +18,4 @@ class ProblemCheck::S3BackupConfig < ProblemCheck
SiteSetting.s3_access_key_id.blank? || SiteSetting.s3_secret_access_key.blank?
end
def translation_key
"dashboard.s3_backup_config_warning"
end
end

View File

@ -9,10 +9,4 @@ class ProblemCheck::S3Cdn < ProblemCheck
problem
end
private
def translation_key
"dashboard.s3_cdn_warning"
end
end

View File

@ -18,8 +18,4 @@ class ProblemCheck::S3UploadConfig < ProblemCheck
SiteSetting.s3_access_key_id.blank? || SiteSetting.s3_secret_access_key.blank?
end
def translation_key
"dashboard.s3_config_warning"
end
end

View File

@ -4,8 +4,20 @@ class ProblemCheck::SidekiqCheck < ProblemCheck
self.priority = "low"
def call
return problem("dashboard.sidekiq_warning") if jobs_in_queue? && !jobs_performed_recently?
return problem("dashboard.queue_size_warning", queue_size: Jobs.queued) if massive_queue?
if jobs_in_queue? && !jobs_performed_recently?
return problem(override_key: "dashboard.problem.sidekiq")
end
if massive_queue?
return(
problem(
override_key: "dashboard.problem.queue_size",
override_data: {
queue_size: Jobs.queued,
},
)
)
end
no_problem
end

View File

@ -8,10 +8,4 @@ class ProblemCheck::SubfolderEndsInSlash < ProblemCheck
problem
end
private
def translation_key
"dashboard.subfolder_ends_in_slash"
end
end

View File

@ -10,10 +10,4 @@ class ProblemCheck::TranslationOverrides < ProblemCheck
problem
end
private
def translation_key
"dashboard.outdated_translations_warning"
end
end

View File

@ -12,10 +12,6 @@ class ProblemCheck::TwitterConfig < ProblemCheck
private
def translation_key
"dashboard.twitter_config_warning"
end
def twitter_credentials_present?
SiteSetting.twitter_consumer_key.present? && SiteSetting.twitter_consumer_secret.present?
end

View File

@ -2,8 +2,6 @@
class ProblemCheck::TwitterLogin < ProblemCheck
self.priority = "high"
# TODO: Implement.
self.perform_every = 24.hours
def call
@ -18,8 +16,4 @@ class ProblemCheck::TwitterLogin < ProblemCheck
def authenticator
@authenticator ||= Auth::TwitterAuthenticator.new
end
def translation_key
"dashboard.twitter_login_warning"
end
end

View File

@ -16,7 +16,7 @@ class ProblemCheck::UnreachableThemes < ProblemCheck
end
def message
"#{I18n.t("dashboard.unreachable_themes")}<ul>#{themes_list}</ul>"
"#{I18n.t("dashboard.problem.unreachable_themes")}<ul>#{themes_list}</ul>"
end
def themes_list

View File

@ -11,10 +11,6 @@ class ProblemCheck::WatchedWords < ProblemCheck
private
def translation_key
"dashboard.watched_word_regexp_error"
end
def translation_data
{ action: invalid_regexp_actions.map { |w| "'#{w}'" }.join(", ") }
end

View File

@ -1615,38 +1615,39 @@ en:
description: "Top 10 users who have had likes from a wide range of people."
dashboard:
twitter_login_warning: 'Twitter login appears to not be working at the moment. Check the credentials in <a href="%{base_path}/admin/site_settings/category/login?filter=twitter">the Site Settings</a>.'
group_email_credentials_warning: 'There was an issue with the email credentials for the group <a href="%{base_path}/g/%{group_name}/manage/email">%{group_full_name}</a>. No emails will be sent from the group inbox until this problem is addressed. %{error}'
rails_env_warning: "Your server is running in %{env} mode."
host_names_warning: "Your config/database.yml file is using the default localhost hostname. Update it to use your site's hostname."
sidekiq_warning: 'Sidekiq is not running. Many tasks, like sending emails, are executed asynchronously by Sidekiq. Please ensure at least one Sidekiq process is running. <a href="https://github.com/mperham/sidekiq" target="_blank">Learn about Sidekiq here</a>.'
queue_size_warning: "The number of queued jobs is %{queue_size}, which is high. This could indicate a problem with the Sidekiq process(es), or you may need to add more Sidekiq workers."
memory_warning: "Your server is running with less than 1 GB of total memory. At least 1 GB of memory is recommended."
maxmind_db_configuration_warning: 'The server has been configured to use MaxMind databases for reverse IP lookups but a valid MaxMind account ID has not been configured which may result in MaxMind databases failing to download in the future. <a href="https://meta.discourse.org/t/configure-maxmind-for-reverse-ip-lookups/173941" target="_blank">See this guide to learn more</a>.'
google_oauth2_config_warning: 'The server is configured to allow signup and login with Google OAuth2 (enable_google_oauth2_logins), but the client id and client secret values are not set. Go to <a href="%{base_path}/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://meta.discourse.org/t/configuring-google-login-for-discourse/15858" target="_blank">See this guide to learn more</a>.'
facebook_config_warning: 'The server is configured to allow signup and login with Facebook (enable_facebook_logins), but the app id and app secret values are not set. Go to <a href="%{base_path}/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://meta.discourse.org/t/configuring-facebook-login-for-discourse/13394" target="_blank">See this guide to learn more</a>.'
twitter_config_warning: 'The server is configured to allow signup and login with Twitter (enable_twitter_logins), but the key and secret values are not set. Go to <a href="%{base_path}/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://meta.discourse.org/t/configuring-twitter-login-for-discourse/13395" target="_blank">See this guide to learn more</a>.'
github_config_warning: 'The server is configured to allow signup and login with GitHub (enable_github_logins), but the client id and secret values are not set. Go to <a href="%{base_path}/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://meta.discourse.org/t/configuring-github-login-for-discourse/13745" target="_blank">See this guide to learn more</a>.'
s3_config_warning: 'The server is configured to upload files to S3, but at least one the following setting is not set: s3_access_key_id, s3_secret_access_key, s3_use_iam_profile, or s3_upload_bucket. Go to <a href="%{base_path}/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://meta.discourse.org/t/how-to-set-up-image-uploads-to-s3/7229" target="_blank">See "How to set up image uploads to S3?" to learn more</a>.'
s3_backup_config_warning: 'The server is configured to upload backups to S3, but at least one the following setting is not set: s3_access_key_id, s3_secret_access_key, s3_use_iam_profile, or s3_backup_bucket. Go to <a href="%{base_path}/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://meta.discourse.org/t/how-to-set-up-image-uploads-to-s3/7229" target="_blank">See "How to set up image uploads to S3?" to learn more</a>.'
s3_cdn_warning: 'The server is configured to upload files to S3, but there is no S3 CDN configured. This can lead to expensive S3 costs and slower site performance. <a href="https://meta.discourse.org/t/-/148916" target="_blank">See "Using Object Storage for Uploads" to learn more</a>.'
image_magick_warning: 'The server is configured to create thumbnails of large images, but ImageMagick is not installed. Install ImageMagick using your favorite package manager or <a href="https://www.imagemagick.org/script/download.php" target="_blank">download the latest release</a>.'
failing_emails_warning: 'There are %{num_failed_jobs} email jobs that failed. Check your app.yml and ensure that the mail server settings are correct. <a href="%{base_path}/sidekiq/retries" target="_blank">See the failed jobs in Sidekiq</a>.'
subfolder_ends_in_slash: "Your subfolder setup is incorrect; the DISCOURSE_RELATIVE_URL_ROOT ends in a slash."
outdated_translations_warning: "Some of your translation overrides are out of date. Please check your <a href='%{base_path}/admin/customize/site_texts?outdated=true'>text customizations</a>."
email_polling_errored_recently:
one: "Email polling has generated an error in the past 24 hours. Look at <a href='%{base_path}/logs' target='_blank'>the logs</a> for more details."
other: "Email polling has generated %{count} errors in the past 24 hours. Look at <a href='%{base_path}/logs' target='_blank'>the logs</a> for more details."
missing_mailgun_api_key: "The server is configured to send emails via Mailgun but you haven't provided an API key used to verify the webhook messages."
bad_favicon_url: "The favicon is failing to load. Check your favicon setting in <a href='%{base_path}/admin/site_settings'>Site Settings</a>."
poll_pop3_timeout: "Connection to the POP3 server is timing out. Incoming email could not be retrieved. Please check your <a href='%{base_path}/admin/site_settings/category/email'>POP3 settings</a> and service provider."
poll_pop3_auth_error: "Connection to the POP3 server is failing with an authentication error. Please check your <a href='%{base_path}/admin/site_settings/category/email'>POP3 settings</a>."
force_https_warning: "Your website is using SSL. But `<a href='%{base_path}/admin/site_settings/category/all_results?filter=force_https'>force_https</a>` is not yet enabled in your site settings."
out_of_date_themes: "Updates are available for the following themes:"
unreachable_themes: "We were unable to check for updates on the following themes:"
watched_word_regexp_error: "The regular expression for %{action} watched words is invalid. Please check your <a href='%{base_path}/admin/customize/watched_words'>Watched Word settings</a>, or disable the 'watched words regular expressions' site setting."
v3_analytics_deprecated: "Your Discourse is currently using Google Analytics 3, which will no longer be supported after July 2023. <a href='https://meta.discourse.org/t/260498'>Upgrade to Google Analytics 4</a> now to continue receiving valuable insights and analytics for your website's performance."
category_style_deprecated: "Your Discourse is currently using a deprecated category style which will be removed before the final beta release of Discourse 3.2. Please refer to <a href='https://meta.discourse.org/t/282441'>Moving to a Single Category Style Site Setting</a> for instructions on how to keep your selected category style."
problem:
twitter_login: 'Twitter login appears to not be working at the moment. Check the credentials in <a href="%{base_path}/admin/site_settings/category/login?filter=twitter">the Site Settings</a>.'
group_email_credentials: 'There was an issue with the email credentials for the group <a href="%{base_path}/g/%{group_name}/manage/email">%{group_full_name}</a>. No emails will be sent from the group inbox until this problem is addressed. %{error}'
rails_env: "Your server is running in %{env} mode."
host_names: "Your config/database.yml file is using the default localhost hostname. Update it to use your site's hostname."
sidekiq: 'Sidekiq is not running. Many tasks, like sending emails, are executed asynchronously by Sidekiq. Please ensure at least one Sidekiq process is running. <a href="https://github.com/mperham/sidekiq" target="_blank">Learn about Sidekiq here</a>.'
queue_size: "The number of queued jobs is %{queue_size}, which is high. This could indicate a problem with the Sidekiq process(es), or you may need to add more Sidekiq workers."
ram: "Your server is running with less than 1 GB of total memory. At least 1 GB of memory is recommended."
google_oauth2_config: 'The server is configured to allow signup and login with Google OAuth2 (enable_google_oauth2_logins), but the client id and client secret values are not set. Go to <a href="%{base_path}/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://meta.discourse.org/t/configuring-google-login-for-discourse/15858" target="_blank">See this guide to learn more</a>.'
facebook_config: 'The server is configured to allow signup and login with Facebook (enable_facebook_logins), but the app id and app secret values are not set. Go to <a href="%{base_path}/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://meta.discourse.org/t/configuring-facebook-login-for-discourse/13394" target="_blank">See this guide to learn more</a>.'
twitter_config: 'The server is configured to allow signup and login with Twitter (enable_twitter_logins), but the key and secret values are not set. Go to <a href="%{base_path}/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://meta.discourse.org/t/configuring-twitter-login-for-discourse/13395" target="_blank">See this guide to learn more</a>.'
github_config: 'The server is configured to allow signup and login with GitHub (enable_github_logins), but the client id and secret values are not set. Go to <a href="%{base_path}/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://meta.discourse.org/t/configuring-github-login-for-discourse/13745" target="_blank">See this guide to learn more</a>.'
s3_upload_config: 'The server is configured to upload files to S3, but at least one the following setting is not set: s3_access_key_id, s3_secret_access_key, s3_use_iam_profile, or s3_upload_bucket. Go to <a href="%{base_path}/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://meta.discourse.org/t/how-to-set-up-image-uploads-to-s3/7229" target="_blank">See "How to set up image uploads to S3?" to learn more</a>.'
s3_backup_config: 'The server is configured to upload backups to S3, but at least one the following setting is not set: s3_access_key_id, s3_secret_access_key, s3_use_iam_profile, or s3_backup_bucket. Go to <a href="%{base_path}/admin/site_settings">the Site Settings</a> and update the settings. <a href="https://meta.discourse.org/t/how-to-set-up-image-uploads-to-s3/7229" target="_blank">See "How to set up image uploads to S3?" to learn more</a>.'
s3_cdn: 'The server is configured to upload files to S3, but there is no S3 CDN configured. This can lead to expensive S3 costs and slower site performance. <a href="https://meta.discourse.org/t/-/148916" target="_blank">See "Using Object Storage for Uploads" to learn more</a>.'
image_magick: 'The server is configured to create thumbnails of large images, but ImageMagick is not installed. Install ImageMagick using your favorite package manager or <a href="https://www.imagemagick.org/script/download.php" target="_blank">download the latest release</a>.'
failing_emails: 'There are %{num_failed_jobs} email jobs that failed. Check your app.yml and ensure that the mail server settings are correct. <a href="%{base_path}/sidekiq/retries" target="_blank">See the failed jobs in Sidekiq</a>.'
subfolder_ends_in_slash: "Your subfolder setup is incorrect; the DISCOURSE_RELATIVE_URL_ROOT ends in a slash."
translation_overrides: "Some of your translation overrides are out of date. Please check your <a href='%{base_path}/admin/customize/site_texts?outdated=true'>text customizations</a>."
email_polling_errored_recently:
one: "Email polling has generated an error in the past 24 hours. Look at <a href='%{base_path}/logs' target='_blank'>the logs</a> for more details."
other: "Email polling has generated %{count} errors in the past 24 hours. Look at <a href='%{base_path}/logs' target='_blank'>the logs</a> for more details."
missing_mailgun_api_key: "The server is configured to send emails via Mailgun but you haven't provided an API key used to verify the webhook messages."
bad_favicon_url: "The favicon is failing to load. Check your favicon setting in <a href='%{base_path}/admin/site_settings'>Site Settings</a>."
poll_pop3_timeout: "Connection to the POP3 server is timing out. Incoming email could not be retrieved. Please check your <a href='%{base_path}/admin/site_settings/category/email'>POP3 settings</a> and service provider."
poll_pop3_auth_error: "Connection to the POP3 server is failing with an authentication error. Please check your <a href='%{base_path}/admin/site_settings/category/email'>POP3 settings</a>."
force_https: "Your website is using SSL. But `<a href='%{base_path}/admin/site_settings/category/all_results?filter=force_https'>force_https</a>` is not yet enabled in your site settings."
out_of_date_themes: "Updates are available for the following themes:"
unreachable_themes: "We were unable to check for updates on the following themes:"
watched_words: "The regular expression for %{action} watched words is invalid. Please check your <a href='%{base_path}/admin/customize/watched_words'>Watched Word settings</a>, or disable the 'watched words regular expressions' site setting."
google_analytics_version: "Your Discourse is currently using Google Analytics 3, which will no longer be supported after July 2023. <a href='https://meta.discourse.org/t/260498'>Upgrade to Google Analytics 4</a> now to continue receiving valuable insights and analytics for your website's performance."
category_style_deprecated: "Your Discourse is currently using a deprecated category style which will be removed before the final beta release of Discourse 3.2. Please refer to <a href='https://meta.discourse.org/t/282441'>Moving to a Single Category Style Site Setting</a> for instructions on how to keep your selected category style."
maxmind_db_configuration: 'The server has been configured to use MaxMind databases for reverse IP lookups but a valid MaxMind account ID has not been configured which may result in MaxMind databases failing to download in the future. <a href="https://meta.discourse.org/t/configure-maxmind-for-reverse-ip-lookups/173941" target="_blank">See this guide to learn more</a>.'
back_from_logster_text: "Back to site"
site_settings:

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class CreateAdminNotices < ActiveRecord::Migration[7.0]
def change
create_table :admin_notices do |t|
t.integer :subject, null: false, index: true
t.integer :priority, null: false
t.string :identifier, null: false, index: true
t.json :details, null: false, default: {}
t.timestamps
end
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddDetailsToProblemCheckTrackers < ActiveRecord::Migration[7.0]
def change
add_column :problem_check_trackers, :details, :json, default: {}
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddTargetToProblemCheckTrackers < ActiveRecord::Migration[7.0]
def change
add_column :problem_check_trackers, :target, :string, null: true
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class DisambiguateProblemCheckTrackerUniqueness < ActiveRecord::Migration[7.0]
def change
remove_index :problem_check_trackers, name: "index_problem_check_trackers_on_identifier"
add_index :problem_check_trackers, %i[identifier target], unique: true
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
Fabricator(:admin_notice) do
priority { "low" }
identifier { "test_notice" }
end

View File

@ -45,9 +45,9 @@ RSpec.describe Jobs::PollMailbox do
poller.poll_pop3
i18n_key = "dashboard.poll_pop3_auth_error"
i18n_key = "dashboard.problem.poll_pop3_auth_error"
expect(AdminDashboardData.problem_message_check(i18n_key)).to eq(
expect(AdminNotice.find_by(identifier: "poll_pop3_auth_error").message).to eq(
I18n.t(i18n_key, base_path: Discourse.base_path),
)
end
@ -57,9 +57,9 @@ RSpec.describe Jobs::PollMailbox do
4.times { poller.poll_pop3 }
i18n_key = "dashboard.poll_pop3_timeout"
i18n_key = "dashboard.problem.poll_pop3_timeout"
expect(AdminDashboardData.problem_message_check(i18n_key)).to eq(
expect(AdminNotice.find_by(identifier: "poll_pop3_timeout").message).to eq(
I18n.t(i18n_key, base_path: Discourse.base_path),
)
end

View File

@ -27,49 +27,10 @@ RSpec.describe Jobs::RunProblemCheck do
ProblemCheck.send(:remove_const, "TestCheck")
end
it "adds the messages to the Redis problems array" do
described_class.new.execute(check_identifier: :test_check)
problems = AdminDashboardData.load_found_scheduled_check_problems
expect(problems.map(&:to_s)).to contain_exactly("Big problem", "Yuge problem")
end
end
context "with multiple problems with the same identifier" do
around do |example|
ProblemCheck::TestCheck =
Class.new(ProblemCheck) do
self.perform_every = 30.minutes
self.max_retries = 0
def call
[
ProblemCheck::Problem.new(
"Yuge problem",
priority: "high",
identifier: "config_is_a_mess",
),
ProblemCheck::Problem.new(
"Nasty problem",
priority: "high",
identifier: "config_is_a_mess",
),
]
end
end
stub_const(ProblemCheck, "CORE_PROBLEM_CHECKS", [ProblemCheck::TestCheck], &example)
ProblemCheck.send(:remove_const, "TestCheck")
end
it "does not add the same problem twice" do
described_class.new.execute(check_identifier: :test_check)
problems = AdminDashboardData.load_found_scheduled_check_problems
expect(problems.map(&:to_s)).to match_array(["Yuge problem"])
it "updates the problem check tracker" do
expect {
described_class.new.execute(check_identifier: :test_check, retry_count: 0)
}.to change { ProblemCheckTracker.failing.count }.by(1)
end
end

View File

@ -6,24 +6,6 @@ RSpec.describe AdminDashboardData do
Discourse.redis.flushdb
end
describe "#fetch_problems" do
describe "adding problem messages" do
it "adds the message and returns it when the problems are fetched" do
AdminDashboardData.add_problem_message("dashboard.bad_favicon_url")
problems = AdminDashboardData.fetch_problems.map(&:to_s)
expect(problems).to include(
I18n.t("dashboard.bad_favicon_url", { base_path: Discourse.base_path }),
)
end
it "does not allow adding of arbitrary problem messages, they must exist in AdminDashboardData.problem_messages" do
AdminDashboardData.add_problem_message("errors.messages.invalid")
problems = AdminDashboardData.fetch_problems.map(&:to_s)
expect(problems).not_to include(I18n.t("errors.messages.invalid"))
end
end
end
describe "adding scheduled checks" do
it "does not add duplicate problems with the same identifier" do
prob1 = ProblemCheck::Problem.new("test problem", identifier: "test")

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
RSpec.describe AdminNotice do
it { is_expected.to validate_presence_of(:identifier) }
describe "#message" do
let(:notice) do
Fabricate(
:admin_notice,
identifier: "test",
subject: "problem",
priority: "high",
details: {
thing: "world",
},
)
end
before do
I18n.backend.store_translations(
:en,
{ "dashboard" => { "problem" => { "test" => "Something is wrong with the %{thing}" } } },
)
end
it { expect(notice.message).to eq("Something is wrong with the world") }
end
end

View File

@ -1,11 +1,13 @@
# frozen_string_literal: true
RSpec.describe ProblemCheckTracker do
before { described_class.any_instance.stubs(:check).returns(stub(max_blips: 1, priority: "low")) }
describe "validations" do
let(:record) { described_class.new(identifier: "twitter_login") }
it { expect(record).to validate_presence_of(:identifier) }
it { expect(record).to validate_uniqueness_of(:identifier) }
it { expect(record).to validate_uniqueness_of(:identifier).scoped_to(:target) }
it { expect(record).to validate_numericality_of(:blips).is_greater_than_or_equal_to(0) }
end
@ -44,14 +46,63 @@ RSpec.describe ProblemCheckTracker do
end
end
describe "#failing?" do
before { freeze_time }
let(:problem_tracker) { described_class.new(last_problem_at:, last_run_at:, last_success_at:) }
context "when the last run passed" do
let(:last_run_at) { 1.minute.ago }
let(:last_success_at) { 1.minute.ago }
let(:last_problem_at) { 11.minutes.ago }
it { expect(problem_tracker).not_to be_failing }
end
context "when the last run had a problem" do
let(:last_run_at) { 1.minute.ago }
let(:last_success_at) { 11.minutes.ago }
let(:last_problem_at) { 1.minute.ago }
it { expect(problem_tracker).to be_failing }
end
end
describe "#passing?" do
before { freeze_time }
let(:problem_tracker) { described_class.new(last_problem_at:, last_run_at:, last_success_at:) }
context "when the last run passed" do
let(:last_run_at) { 1.minute.ago }
let(:last_success_at) { 1.minute.ago }
let(:last_problem_at) { 11.minutes.ago }
it { expect(problem_tracker).to be_passing }
end
context "when the last run had a problem" do
let(:last_run_at) { 1.minute.ago }
let(:last_success_at) { 11.minutes.ago }
let(:last_problem_at) { 1.minute.ago }
it { expect(problem_tracker).not_to be_passing }
end
end
describe "#problem!" do
let(:problem_tracker) do
Fabricate(:problem_check_tracker, identifier: "twitter_login", **original_attributes)
Fabricate(
:problem_check_tracker,
identifier: "twitter_login",
target: "foo",
**original_attributes,
)
end
let(:original_attributes) do
{
blips: 0,
blips:,
last_problem_at: 1.week.ago,
last_success_at: 24.hours.ago,
last_run_at: 24.hours.ago,
@ -59,6 +110,7 @@ RSpec.describe ProblemCheckTracker do
}
end
let(:blips) { 0 }
let(:updated_attributes) { { blips: 1 } }
it do
@ -68,6 +120,61 @@ RSpec.describe ProblemCheckTracker do
problem_tracker.attributes
}.to(hash_including(updated_attributes))
end
context "when the maximum number of blips have been surpassed" do
let(:blips) { 1 }
it "sounds the alarm" do
expect { problem_tracker.problem!(next_run_at: 24.hours.from_now) }.to change {
AdminNotice.problem.count
}.by(1)
end
end
context "when there's an alarm sounding for multi-target trackers" do
let(:blips) { 1 }
before do
Fabricate(
:admin_notice,
subject: "problem",
identifier: "twitter_login",
details: {
target: target,
},
)
end
context "when the alarm is for a different target" do
let(:target) { "bar" }
it "sounds the alarm" do
expect { problem_tracker.problem!(next_run_at: 24.hours.from_now) }.to change {
AdminNotice.problem.count
}.by(1)
end
end
context "when the alarm is for a the same target" do
let(:target) { "foo" }
it "does not duplicate the alarm" do
expect { problem_tracker.problem!(next_run_at: 24.hours.from_now) }.not_to change {
AdminNotice.problem.count
}
end
end
end
context "when there are still blips to go" do
let(:blips) { 0 }
it "does not sound the alarm" do
expect { problem_tracker.problem!(next_run_at: 24.hours.from_now) }.not_to change {
AdminNotice.problem.count
}
end
end
end
describe "#no_problem!" do
@ -94,5 +201,15 @@ RSpec.describe ProblemCheckTracker do
problem_tracker.attributes
}.to(hash_including(updated_attributes))
end
context "when there's an alarm sounding" do
before { Fabricate(:admin_notice, subject: "problem", identifier: "twitter_login") }
it "silences the alarm" do
expect { problem_tracker.no_problem!(next_run_at: 24.hours.from_now) }.to change {
AdminNotice.problem.count
}.by(-1)
end
end
end
end

View File

@ -98,12 +98,12 @@ RSpec.describe Admin::DashboardController do
end
describe "#problems" do
before { ProblemCheck.stubs(:realtime).returns(stub(run_all: [])) }
context "when logged in as an admin" do
before { sign_in(admin) }
context "when there are no problems" do
before { AdminDashboardData.stubs(:fetch_problems).returns([]) }
it "returns an empty array" do
get "/admin/dashboard/problems.json"
@ -115,7 +115,8 @@ RSpec.describe Admin::DashboardController do
context "when there are problems" do
before do
AdminDashboardData.stubs(:fetch_problems).returns(["Not enough awesome", "Too much sass"])
Fabricate(:admin_notice, subject: "problem", identifier: "foo")
Fabricate(:admin_notice, subject: "problem", identifier: "bar")
end
it "returns an array of strings" do
@ -123,8 +124,6 @@ RSpec.describe Admin::DashboardController do
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["problems"].size).to eq(2)
expect(json["problems"][0]).to be_a(String)
expect(json["problems"][1]).to be_a(String)
end
end
end
@ -132,7 +131,9 @@ RSpec.describe Admin::DashboardController do
context "when logged in as a moderator" do
before do
sign_in(moderator)
AdminDashboardData.stubs(:fetch_problems).returns(["Not enough awesome", "Too much sass"])
Fabricate(:admin_notice, subject: "problem", identifier: "foo")
Fabricate(:admin_notice, subject: "problem", identifier: "bar")
end
it "returns a list of problems" do
@ -141,7 +142,6 @@ RSpec.describe Admin::DashboardController do
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["problems"].size).to eq(2)
expect(json["problems"]).to contain_exactly("Not enough awesome", "Too much sass")
end
end

View File

@ -54,6 +54,14 @@ RSpec.describe StaticController do
expect(response.media_type).to eq("image/png")
expect(response.body.bytesize).to eq(upload.filesize)
end
context "when favicon fails to load" do
before { FileHelper.stubs(:download).raises(SocketError) }
it "creates an admin notice" do
expect { get "/favicon/proxied" }.to change { AdminNotice.problem.count }.by(1)
end
end
end
end

View File

@ -41,7 +41,8 @@ RSpec.describe ProblemCheck::GroupEmailCredentials do
expect(described_class.new.call).to contain_exactly(
have_attributes(
identifier: "group_#{group2.id}_email_credentials",
identifier: "group_email_credentials",
target: group2.id,
priority: "high",
message:
"There was an issue with the email credentials for the group <a href=\"/g/#{group2.name}/manage/email\"></a>. No emails will be sent from the group inbox until this problem is addressed. There was an issue with the SMTP credentials provided, check the username and password and try again.",
@ -58,7 +59,8 @@ RSpec.describe ProblemCheck::GroupEmailCredentials do
expect(described_class.new.call).to contain_exactly(
have_attributes(
identifier: "group_#{group3.id}_email_credentials",
identifier: "group_email_credentials",
target: group3.id,
priority: "high",
message:
"There was an issue with the email credentials for the group <a href=\"/g/#{group3.name}/manage/email\"></a>. No emails will be sent from the group inbox until this problem is addressed. There was an issue with the IMAP credentials provided, check the username and password and try again.",

View File

@ -20,7 +20,7 @@ RSpec.describe ProblemCheck::MaxmindDbConfiguration do
global_setting :maxmind_license_key, "license_key"
expect(check).to have_a_problem.with_priority("low").with_message(
I18n.t("dashboard.maxmind_db_configuration_warning"),
I18n.t("dashboard.problem.maxmind_db_configuration"),
)
end
end

View File

@ -5,16 +5,46 @@ RSpec.describe ProblemCheck do
ScheduledCheck = Class.new(described_class) { self.perform_every = 30.minutes }
RealtimeCheck = Class.new(described_class)
PluginCheck = Class.new(described_class)
FailingCheck =
Class.new(described_class) do
def call
problem
end
stub_const(described_class, "CORE_PROBLEM_CHECKS", [ScheduledCheck, RealtimeCheck], &example)
def translation_key
"failing_check"
end
end
PassingCheck =
Class.new(described_class) do
def call
no_problem
end
def translation_key
"passing_check"
end
end
stub_const(
described_class,
"CORE_PROBLEM_CHECKS",
[ScheduledCheck, RealtimeCheck, FailingCheck, PassingCheck],
&example
)
Object.send(:remove_const, ScheduledCheck.name)
Object.send(:remove_const, RealtimeCheck.name)
Object.send(:remove_const, PluginCheck.name)
Object.send(:remove_const, FailingCheck.name)
Object.send(:remove_const, PassingCheck.name)
end
let(:scheduled_check) { ScheduledCheck }
let(:realtime_check) { RealtimeCheck }
let(:plugin_check) { PluginCheck }
let(:failing_check) { FailingCheck }
let(:passing_check) { PassingCheck }
describe ".[]" do
it { expect(described_class[:scheduled_check]).to eq(scheduled_check) }
@ -57,13 +87,23 @@ RSpec.describe ProblemCheck do
context "when the plugin is enabled" do
let(:enabled) { true }
it { expect(described_class.checks).to include(PluginCheck) }
it { expect(described_class.checks).to include(plugin_check) }
end
context "when the plugin is disabled" do
let(:enabled) { false }
it { expect(described_class.checks).not_to include(PluginCheck) }
it { expect(described_class.checks).not_to include(plugin_check) }
end
end
describe "#run" do
context "when check is failing" do
it { expect { failing_check.run }.to change { ProblemCheckTracker.failing.count }.by(1) }
end
context "when check is passing" do
it { expect { passing_check.run }.to change { ProblemCheckTracker.passing.count }.by(1) }
end
end
end