rails_error_dashboard 0.14.1 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Drops every RED table, for uninstalling. DESTRUCTIVE: all error data goes.
6
+ #
7
+ # Used by the rails_error_dashboard:db:drop task and the uninstall
8
+ # generator. Drops on the connection RED's models use, so a separate or
9
+ # shared error database is covered, in the order Queries::UninstallPlan
10
+ # derives from the foreign keys. Where the adapter runs DDL in a transaction
11
+ # (PostgreSQL, SQLite) it is all-or-nothing. Errors are raised, never
12
+ # swallowed: a half-finished uninstall must not report success.
13
+ #
14
+ # @example
15
+ # RailsErrorDashboard::Commands::DropAllTables.call
16
+ # # => ["rails_error_dashboard_error_comments", ...]
17
+ class DropAllTables
18
+ class Error < StandardError; end
19
+
20
+ # The connection RED's models use.
21
+ #
22
+ # @raise [Error] when use_separate_database is on but the engine fell back
23
+ # to the primary database because database.yml has no entry for it:
24
+ # dropping there would leave the real error database untouched.
25
+ # @return [ActiveRecord::ConnectionAdapters::AbstractAdapter]
26
+ def self.connection
27
+ config = RailsErrorDashboard.configuration
28
+ record = RailsErrorDashboard::ErrorLogsRecord
29
+
30
+ if config.use_separate_database
31
+ expected = config.database.to_s
32
+ actual = record.connection_db_config&.name.to_s
33
+ if actual != expected
34
+ raise Error, "use_separate_database is on, but RED is connected to the '#{actual}' database, " \
35
+ "not '#{expected}': database.yml has no '#{expected}' entry for #{Rails.env}. " \
36
+ "Add it (see docs/guides/DATABASE_OPTIONS.md) and run this again, or RED's tables " \
37
+ "in the error database would be left behind."
38
+ end
39
+ end
40
+
41
+ record.connection
42
+ end
43
+
44
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
45
+ # @return [Array<String>] the dropped tables, in drop order
46
+ # @raise [Error] when a host table has a foreign key into a RED table
47
+ def self.call(connection: self.connection)
48
+ tables = Queries::UninstallPlan.call(connection).map { |entry| entry[:table] }
49
+ return [] if tables.empty?
50
+
51
+ blocking = host_foreign_keys(connection, tables)
52
+ if blocking.any?
53
+ raise Error, "Not dropping anything: these tables have foreign keys into RED's tables. " \
54
+ "Remove the foreign keys first: #{blocking.join('; ')}"
55
+ end
56
+
57
+ drop = -> { tables.each { |table| connection.drop_table(table, if_exists: true) } }
58
+ if connection.supports_ddl_transactions?
59
+ connection.transaction(requires_new: true) { drop.call }
60
+ else
61
+ drop.call
62
+ end
63
+
64
+ tables
65
+ end
66
+
67
+ def self.host_foreign_keys(connection, tables)
68
+ (connection.tables - tables).flat_map do |table|
69
+ connection.foreign_keys(table)
70
+ .select { |fk| tables.include?(fk.to_table) }
71
+ .map { |fk| "#{table}.#{fk.column} -> #{fk.to_table} (#{fk.name})" }
72
+ end
73
+ end
74
+ private_class_method :host_foreign_keys
75
+ end
76
+ end
77
+ end
@@ -2,6 +2,12 @@
2
2
 
3
3
  module RailsErrorDashboard
4
4
  class Configuration
5
+ # The built-in Basic auth credentials. They are published (README, demo,
6
+ # gemspec), so outside development and test they are refused unless
7
+ # ERROR_DASHBOARD_PASSWORD explicitly supplies the password.
8
+ DEFAULT_DASHBOARD_USERNAME = "gandalf"
9
+ DEFAULT_DASHBOARD_PASSWORD = "youshallnotpass"
10
+
5
11
  # Dashboard authentication (always required)
6
12
  attr_accessor :dashboard_username
7
13
  attr_accessor :dashboard_password
@@ -269,8 +275,8 @@ module RailsErrorDashboard
269
275
 
270
276
  def initialize
271
277
  # Default values - Authentication is ALWAYS required
272
- @dashboard_username = ENV.fetch("ERROR_DASHBOARD_USER", "gandalf")
273
- @dashboard_password = ENV.fetch("ERROR_DASHBOARD_PASSWORD", "youshallnotpass")
278
+ @dashboard_username = ENV.fetch("ERROR_DASHBOARD_USER", DEFAULT_DASHBOARD_USERNAME)
279
+ @dashboard_password = ENV.fetch("ERROR_DASHBOARD_PASSWORD", DEFAULT_DASHBOARD_PASSWORD)
274
280
  @authenticate_with = nil
275
281
 
276
282
  @user_model = nil # Auto-detect if not set
@@ -489,7 +495,7 @@ module RailsErrorDashboard
489
495
 
490
496
  # Internal logging defaults - SILENT by default
491
497
  @enable_internal_logging = false # Opt-in for debugging
492
- @log_level = :silent # Silent by default, use :debug, :info, :warn, :error, or :silent
498
+ @log_level = :silent # Silent by default; see Logger::LOG_LEVELS for the levels
493
499
 
494
500
  # Dashboard UI
495
501
  @accent_color = :crimson # :crimson, :ruby, :ember, :violet
@@ -536,12 +542,24 @@ module RailsErrorDashboard
536
542
  # (GHSA-qhgm-3pxf-mvc6). Every future environment name a team invents is
537
543
  # now refused by default and has to be added here on purpose.
538
544
  #
539
- # Skip during asset precompilation (SECRET_KEY_BASE_DUMMY=1) — ENV vars aren't available at build time
540
- if default_credentials? &&
541
- defined?(Rails) && Rails.respond_to?(:env) &&
542
- !Rails.env.development? && !Rails.env.test? &&
543
- ENV["SECRET_KEY_BASE_DUMMY"].blank?
544
- errors << "Default or blank credentials cannot be used in #{Rails.env}. Only development and test may run on the built-in credentials. Set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD environment variables, or use authenticate_with for custom auth."
545
+ # Skip during asset precompilation (SECRET_KEY_BASE_DUMMY=1) — ENV vars aren't available at build time.
546
+ # The login refuses the same credentials on its own, so SECRET_KEY_BASE_DUMMY
547
+ # left set at runtime does not reopen them.
548
+ if refuse_default_credentials? && ENV["SECRET_KEY_BASE_DUMMY"].blank?
549
+ reason = if credentials_problem == :blank
550
+ "the dashboard username or password is blank"
551
+ else
552
+ "the dashboard password is the published default and was not set by ERROR_DASHBOARD_PASSWORD"
553
+ end
554
+ errors << "Default or blank credentials cannot be used in #{Rails.env}: #{reason}. Only development and test may run on the built-in credentials. Set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD environment variables and make sure the initializer does not overwrite them, or use authenticate_with for custom auth."
555
+ end
556
+
557
+ # In development the app boots on a blank credential, and the login then
558
+ # denies everyone: say so, or the developer is locked out with no clue
559
+ # why. Development only, because a test run boots on every CI job.
560
+ if default_credentials? && credentials_problem == :blank &&
561
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
562
+ warnings.concat(blank_credential_warnings)
545
563
  end
546
564
 
547
565
  # Validate sampling_rate (must be between 0.0 and 1.0)
@@ -776,7 +794,9 @@ module RailsErrorDashboard
776
794
 
777
795
  # Validate log level (must be valid symbol)
778
796
  if log_level
779
- valid_log_levels = %i[debug info warn error fatal silent]
797
+ # Referenced here, not as a class-body constant: this file loads before
798
+ # logger.rb, and a bare Logger in the class body would be ::Logger.
799
+ valid_log_levels = RailsErrorDashboard::Logger::LOG_LEVELS.keys
780
800
  unless valid_log_levels.include?(log_level)
781
801
  errors << "log_level must be one of #{valid_log_levels.inspect} (got: #{log_level.inspect})"
782
802
  end
@@ -925,23 +945,86 @@ module RailsErrorDashboard
925
945
  list.empty? ? nil : list
926
946
  end
927
947
 
928
- # Check if using default or blank demo credentials with basic auth
948
+ # Why the Basic auth credentials cannot be trusted outside development and
949
+ # test, or nil when they can.
929
950
  #
930
- # Returns false if the user explicitly set ENV vars (even to the same default values),
931
- # because that's a deliberate choice. Only blocks when credentials are untouched defaults
932
- # or blank.
951
+ # - :blank - the username or password is empty or whitespace. An explicitly
952
+ # empty variable is not a choice: a compose file passing an unset variable
953
+ # through produces exactly that, and "" would match an empty login.
954
+ # - :published_password - the password is the published default and did not
955
+ # come from ERROR_DASHBOARD_PASSWORD. Setting that variable, even to the
956
+ # default, is a deliberate choice (the live demo runs that way). It has to
957
+ # be THAT variable: setting only ERROR_DASHBOARD_USER used to leave the
958
+ # password on the published default, and an initializer can hardcode it.
933
959
  #
934
- # @return [Boolean] true if basic auth is active with untouched default or blank credentials
960
+ # Both compare values the way the login does, with to_s: a Symbol holding the
961
+ # published password logs in just the same.
962
+ #
963
+ # @return [Symbol, nil]
964
+ def credentials_problem
965
+ return :blank if self.class.blank_credential?(dashboard_username) ||
966
+ self.class.blank_credential?(dashboard_password)
967
+
968
+ if dashboard_password.to_s == DEFAULT_DASHBOARD_PASSWORD &&
969
+ ENV["ERROR_DASHBOARD_PASSWORD"] != DEFAULT_DASHBOARD_PASSWORD
970
+ return :published_password
971
+ end
972
+
973
+ nil
974
+ end
975
+
976
+ # True for a credential that cannot be a secret: nil, empty, or only
977
+ # whitespace, including Unicode whitespace such as a no-break space, which
978
+ # String#strip leaves in place. The boot check and the login both use it.
979
+ #
980
+ # @return [Boolean]
981
+ def self.blank_credential?(value)
982
+ value.to_s.blank?
983
+ end
984
+
985
+ # Check if basic auth is active with blank credentials or the published
986
+ # default password (see #credentials_problem)
987
+ #
988
+ # Basic auth is active whenever authenticate_with is falsy, not only nil:
989
+ # the login falls back to it for `false` too, which is what
990
+ # `Rails.env.production? && -> { ... }` evaluates to in staging.
991
+ #
992
+ # @return [Boolean]
935
993
  def default_credentials?
936
- return false unless authenticate_with.nil?
994
+ !authenticate_with && !credentials_problem.nil?
995
+ end
937
996
 
938
- # If user explicitly set ENV vars, respect their choice
939
- return false if ENV.key?("ERROR_DASHBOARD_USER") || ENV.key?("ERROR_DASHBOARD_PASSWORD")
997
+ # True where default_credentials? has to stop the dashboard: anywhere that is
998
+ # not local development or test. The boot check and the login both use it,
999
+ # so the login still refuses when the boot check was skipped.
1000
+ #
1001
+ # @return [Boolean]
1002
+ def refuse_default_credentials?
1003
+ return false unless default_credentials?
1004
+ return false unless defined?(Rails) && Rails.respond_to?(:env)
940
1005
 
941
- default = dashboard_username == "gandalf" && dashboard_password == "youshallnotpass"
942
- blank = dashboard_username.to_s.strip.empty? || dashboard_password.to_s.strip.empty?
1006
+ !Rails.env.development? && !Rails.env.test?
1007
+ end
943
1008
 
944
- default || blank
1009
+ # One warning per blank credential, naming where the blank came from: an
1010
+ # environment variable that is set but empty, or the initializer.
1011
+ #
1012
+ # @return [Array<String>]
1013
+ private def blank_credential_warnings
1014
+ {
1015
+ "ERROR_DASHBOARD_USER" => [ :dashboard_username, dashboard_username ],
1016
+ "ERROR_DASHBOARD_PASSWORD" => [ :dashboard_password, dashboard_password ]
1017
+ }.filter_map do |variable, (setting, value)|
1018
+ next unless self.class.blank_credential?(value)
1019
+
1020
+ if ENV.key?(variable) && ENV[variable].to_s == value.to_s
1021
+ "#{variable} is set but empty, so every dashboard login will be denied. " \
1022
+ "Unset it to use the development default, or give it a value."
1023
+ else
1024
+ "config.#{setting} is blank, so every dashboard login will be denied. " \
1025
+ "Remove that line from the initializer to use the development default, or give it a value."
1026
+ end
1027
+ end
945
1028
  end
946
1029
 
947
1030
  # Resolve the effective issue tracker provider (auto-detect from git_repository_url).
@@ -18,12 +18,17 @@ module RailsErrorDashboard
18
18
  # config.log_level = :error
19
19
  # end
20
20
  module Logger
21
+ # The levels Configuration#validate! accepts: it reads this hash, so the two
22
+ # can't drift apart again (validate! once accepted :fatal while this hash
23
+ # lacked it, and every log call raised). RED emits nothing at :fatal, so it
24
+ # logs nothing, as with Ruby's Logger.
21
25
  LOG_LEVELS = {
22
26
  debug: 0,
23
27
  info: 1,
24
28
  warn: 2,
25
29
  error: 3,
26
- silent: 4
30
+ fatal: 4,
31
+ silent: 5
27
32
  }.freeze
28
33
 
29
34
  class << self
@@ -89,8 +94,11 @@ module RailsErrorDashboard
89
94
  # @param level [Symbol] The log level to check (:debug, :info, :warn, :error)
90
95
  # @return [Boolean]
91
96
  def log_level_enabled?(level)
92
- config_level = RailsErrorDashboard.configuration.log_level || :silent
93
- LOG_LEVELS[level] >= LOG_LEVELS[config_level]
97
+ config_level = RailsErrorDashboard.configuration.log_level.to_s.to_sym
98
+ LOG_LEVELS.fetch(level) >= LOG_LEVELS.fetch(config_level, LOG_LEVELS[:silent])
99
+ rescue StandardError
100
+ # The logger runs inside rescue blocks on the capture path; it must never raise.
101
+ false
94
102
  end
95
103
 
96
104
  # Format message with gem prefix
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # Every RED table on a connection, in an order that can be dropped.
6
+ #
7
+ # The tables are discovered rather than listed by hand: a hand-kept list
8
+ # knew 5 of 13 tables, so uninstall left the rest behind (rack_attack_events
9
+ # among them, which holds IP addresses). The order comes from the real
10
+ # foreign keys: a table that references another is dropped first.
11
+ #
12
+ # Uses the raw connection only, never the models, so no model's schema
13
+ # cache ever records a table as dropped.
14
+ #
15
+ # @example
16
+ # UninstallPlan.call(RailsErrorDashboard::ErrorLogsRecord.connection)
17
+ # # => [{ table: "rails_error_dashboard_error_comments", rows: 12 }, ...]
18
+ class UninstallPlan
19
+ PREFIX = "rails_error_dashboard_"
20
+
21
+ class CycleError < StandardError; end
22
+
23
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
24
+ # @return [Array<Hash>] { table: String, rows: Integer or nil } in drop order
25
+ def self.call(connection)
26
+ tables = connection.tables.select { |table| table.start_with?(PREFIX) }
27
+ references = tables.to_h do |table|
28
+ [ table, connection.foreign_keys(table).map(&:to_table) & tables ]
29
+ end
30
+
31
+ order(references).map { |table| { table: table, rows: row_count(connection, table) } }
32
+ end
33
+
34
+ # @param references [Hash{String => Array<String>}] each table and the tables it references
35
+ # @return [Array<String>] every table, each before the tables it references
36
+ # @raise [CycleError] when the foreign keys form a cycle
37
+ def self.order(references)
38
+ remaining = references.keys.sort
39
+ ordered = []
40
+
41
+ until remaining.empty?
42
+ # Droppable once no other remaining table references it.
43
+ droppable = remaining.reject do |table|
44
+ remaining.any? { |other| other != table && references.fetch(other, []).include?(table) }
45
+ end
46
+ raise CycleError, "foreign-key cycle among: #{remaining.join(', ')}" if droppable.empty?
47
+
48
+ ordered.concat(droppable)
49
+ remaining -= droppable
50
+ end
51
+
52
+ ordered
53
+ end
54
+
55
+ def self.row_count(connection, table)
56
+ connection.select_value("SELECT COUNT(*) FROM #{connection.quote_table_name(table)}").to_i
57
+ rescue ActiveRecord::ActiveRecordError
58
+ nil
59
+ end
60
+ private_class_method :row_count
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module RailsErrorDashboard
6
+ module Services
7
+ # Pure algorithm: what in a Solid Queue config would stop jobs from running
8
+ #
9
+ # Mirrors Solid Queue 1.7.0's own rules, so it reports exactly what Solid
10
+ # Queue would do with the file:
11
+ # - SolidQueue::Configuration reads the section for the environment, or the
12
+ # whole file when there is none, keeps workers/dispatchers/scheduler, and
13
+ # only falls back to its defaults (a "*" worker and a dispatcher) when
14
+ # none of the three is there. A section with workers and no dispatchers
15
+ # therefore runs NO dispatcher: nothing scheduled ever becomes ready, so
16
+ # no `retry_on wait:` or `perform_later(wait:)` runs, the host app's own
17
+ # jobs included. That is what the retired rails_error_dashboard:solid_queue
18
+ # generator wrote.
19
+ # - SolidQueue::QueueSelector treats a worker without queues as "*", "*" as
20
+ # every queue, and a trailing "*" as a prefix.
21
+ #
22
+ # @example
23
+ # path = SolidQueueConfigCheck.config_path(Rails.root)
24
+ # SolidQueueConfigCheck.call(path, environments: %w[development production])
25
+ # # => { problems: ["production: workers but no dispatcher ..."], skipped: nil }
26
+ class SolidQueueConfigCheck
27
+ DEFAULT_CONFIG_FILE = "config/queue.yml"
28
+ PROCESS_KEYS = %i[workers dispatchers scheduler].freeze
29
+ # Before 1.4.0, scheduler: was not a process key: a section with only a
30
+ # scheduler fell back to the defaults, a "*" worker and a dispatcher.
31
+ SCHEDULER_KEY_SINCE = Gem::Version.new("1.4.0")
32
+ GUIDE_URL = "https://anjanj.github.io/rails_error_dashboard/docs/guides/solid-queue-setup/#what-red-needs-from-solid-queue"
33
+
34
+ # The fix printed with a reported problem. It is never Solid Queue's
35
+ # installer: whoever sees this already uses Solid Queue, and its 1.7.0
36
+ # installer also rewrites production.rb to use a separate queue database.
37
+ FIX = [
38
+ "Fix: edit the file. Give each environment a dispatchers: block and a worker",
39
+ "for RED's queues (a \"*\" worker covers them). Guide: #{GUIDE_URL}"
40
+ ].freeze
41
+
42
+ # The config file Solid Queue reads, honouring SOLID_QUEUE_CONFIG as it does.
43
+ #
44
+ # @param root [Pathname, String] the application root
45
+ # @return [Pathname]
46
+ def self.config_path(root)
47
+ Pathname(root.to_s).join(ENV["SOLID_QUEUE_CONFIG"].presence || DEFAULT_CONFIG_FILE)
48
+ end
49
+
50
+ # The environments an app defines, from config/environments/*.rb.
51
+ #
52
+ # @param root [Pathname, String] the application root
53
+ # @return [Array<String>]
54
+ def self.app_environments(root)
55
+ Dir[Pathname(root.to_s).join("config", "environments", "*.rb").to_s]
56
+ .map { |file| File.basename(file, ".rb") }
57
+ .sort
58
+ end
59
+
60
+ # The queues RED's jobs are enqueued on, as this app names them
61
+ # (queue_name_prefix included).
62
+ #
63
+ # @return [Array<String>]
64
+ def self.red_queue_names
65
+ [ RailsErrorDashboard::AsyncErrorLoggingJob, RailsErrorDashboard::SlackErrorNotificationJob ]
66
+ .map { |job| job.queue_name.to_s }
67
+ .uniq
68
+ rescue => e
69
+ RailsErrorDashboard::Logger.debug("SolidQueueConfigCheck could not read queue names: #{e.class}: #{e.message}")
70
+ %w[default error_notifications]
71
+ end
72
+
73
+ # Read the file the way Solid Queue does (ERB, then YAML with aliases) and
74
+ # check each environment. Never raises: an unreadable file is reported as
75
+ # skipped.
76
+ #
77
+ # @param path [Pathname, String]
78
+ # @param environments [Array<String>]
79
+ # @param queue_names [Array<String>]
80
+ # @return [Hash] { problems: Array<String>, skipped: String or nil }
81
+ def self.call(path, environments:, queue_names: red_queue_names)
82
+ { problems: check(parse(path), environments: environments, queue_names: queue_names), skipped: nil }
83
+ rescue StandardError, ScriptError => e # ScriptError: ERB in the file that doesn't compile
84
+ { problems: [], skipped: "could not read #{path}: #{e.class}: #{e.message.lines.first.to_s.strip}" }
85
+ end
86
+
87
+ # @param path [Pathname, String]
88
+ # @return [Hash] the parsed file, symbolized keys, as Solid Queue reads it
89
+ def self.parse(path)
90
+ ActiveSupport::ConfigurationFile.parse(Pathname(path.to_s)).deep_symbolize_keys
91
+ end
92
+
93
+ # @param config [Hash] the parsed file, symbolized keys
94
+ # @param environments [Array<String>]
95
+ # @param queue_names [Array<String>]
96
+ # @return [Array<String>] one line per problem, prefixed with the environment
97
+ def self.check(config, environments:, queue_names:)
98
+ environments.flat_map do |env|
99
+ facts = processes_for(config, environment: env, queue_names: queue_names)
100
+ problems_in(facts, queue_names).map { |problem| "#{env}: #{problem}" }
101
+ end
102
+ end
103
+
104
+ # What Solid Queue would start for one environment. The contract spec
105
+ # compares this with the real SolidQueue::Configuration and QueueSelector.
106
+ #
107
+ # @param config [Hash] the parsed file, symbolized keys
108
+ # @param environment [String]
109
+ # @param queue_names [Array<String>]
110
+ # @param solid_queue_version [String, nil] the rules to follow; the loaded
111
+ # Solid Queue's by default, the newest when none is loaded
112
+ # @return [Hash] { dispatchers: Integer, workers: Integer,
113
+ # processed: Array<String> (the queue_names a worker picks up), error: String or nil }
114
+ def self.processes_for(config, environment:, queue_names:, solid_queue_version: loaded_solid_queue_version)
115
+ section = section_for(config, environment)
116
+ return cannot_start("the #{environment} section is not a set of settings") unless section.is_a?(Hash)
117
+
118
+ section = section.slice(*process_keys(solid_queue_version))
119
+ # Empty: Solid Queue runs its defaults, a "*" worker and a dispatcher.
120
+ return { dispatchers: 1, workers: 1, processed: queue_names.dup, error: nil } if section.empty?
121
+
122
+ error = shape_error(section)
123
+ return cannot_start(error) if error
124
+
125
+ # bin/jobs runs in fork mode: each worker entry starts `processes` of them.
126
+ workers = section.fetch(:workers, []).select { |worker| worker.fetch(:processes, 1).positive? }
127
+ {
128
+ dispatchers: section.fetch(:dispatchers, []).size,
129
+ workers: workers.sum { |worker| worker.fetch(:processes, 1) },
130
+ processed: queue_names.select { |name| workers.any? { |worker| processes?(worker, name) } },
131
+ error: nil
132
+ }
133
+ end
134
+
135
+ def self.loaded_solid_queue_version
136
+ ::SolidQueue::VERSION if defined?(::SolidQueue::VERSION)
137
+ end
138
+
139
+ def self.process_keys(solid_queue_version)
140
+ return PROCESS_KEYS if solid_queue_version.nil? || Gem::Version.new(solid_queue_version) >= SCHEDULER_KEY_SINCE
141
+
142
+ PROCESS_KEYS - [ :scheduler ]
143
+ rescue ArgumentError # a version string Gem::Version can't read
144
+ PROCESS_KEYS
145
+ end
146
+ private_class_method :process_keys
147
+
148
+ # SolidQueue::Configuration#config_from: the environment's section when
149
+ # present, else the whole file.
150
+ def self.section_for(config, env)
151
+ config[env.to_sym] ? config[env.to_sym] : config
152
+ end
153
+ private_class_method :section_for
154
+
155
+ # Settings Solid Queue raises on while it builds its processes, so
156
+ # bin/jobs can't start: a key present with no value, a map where a list
157
+ # belongs, a list entry that isn't a set of settings, or processes that
158
+ # isn't a whole number.
159
+ def self.shape_error(section)
160
+ %i[workers dispatchers].each do |key|
161
+ next unless section.key?(key)
162
+
163
+ list = section[key]
164
+ return "#{key}: must be a list, each entry starting with \"- \"" unless list.is_a?(Array)
165
+ return "every entry under #{key}: must be a set of settings" unless list.all?(Hash)
166
+ end
167
+
168
+ if section.fetch(:workers, []).any? { |worker| worker.key?(:processes) && !worker[:processes].is_a?(Integer) }
169
+ return "a worker's processes: must be a whole number"
170
+ end
171
+
172
+ nil
173
+ end
174
+ private_class_method :shape_error
175
+
176
+ def self.cannot_start(reason)
177
+ { dispatchers: 0, workers: 0, processed: [], error: reason }
178
+ end
179
+ private_class_method :cannot_start
180
+
181
+ def self.problems_in(facts, queue_names)
182
+ return [ "Solid Queue can't start with this config: #{facts[:error]}" ] if facts[:error]
183
+
184
+ dispatchers = facts[:dispatchers]
185
+ workers = facts[:workers]
186
+ return [ "no worker and no dispatcher, so no job ever runs" ] if workers.zero? && dispatchers.zero?
187
+ return [ "no worker process starts (no workers, or processes: 0), so no job is ever performed" ] if workers.zero?
188
+
189
+ problems = []
190
+ if dispatchers.zero?
191
+ problems << "workers but no dispatcher, so scheduled jobs and retries " \
192
+ "(retry_on wait:, perform_later(wait:)) never run, the app's own jobs included"
193
+ end
194
+
195
+ uncovered = queue_names - facts[:processed]
196
+ problems << "no worker processes #{uncovered.join(' or ')}" if uncovered.any?
197
+ problems
198
+ end
199
+ private_class_method :problems_in
200
+
201
+ # SolidQueue::QueueSelector: no queues means "*"; "*" is every queue; a
202
+ # trailing "*" is a prefix.
203
+ def self.processes?(worker, queue_name)
204
+ queues = worker.is_a?(Hash) ? Array(worker[:queues]).map { |queue| queue.to_s.strip } : []
205
+ queues = [ "*" ] if queues.empty?
206
+
207
+ queues.any? do |queue|
208
+ queue == "*" || queue == queue_name || (queue.end_with?("*") && queue_name.start_with?(queue.delete_suffix("*")))
209
+ end
210
+ end
211
+ private_class_method :processes?
212
+ end
213
+ end
214
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.14.1"
2
+ VERSION = "0.14.3"
3
3
  end
@@ -57,6 +57,7 @@ require "rails_error_dashboard/services/statistical_classifier"
57
57
  require "rails_error_dashboard/services/source_code_reader"
58
58
  require "rails_error_dashboard/services/git_blame_reader"
59
59
  require "rails_error_dashboard/services/git_head_reader"
60
+ require "rails_error_dashboard/services/solid_queue_config_check"
60
61
  require "rails_error_dashboard/services/github_link_generator"
61
62
  require "rails_error_dashboard/services/cause_chain_extractor"
62
63
  require "rails_error_dashboard/services/environment_snapshot"
@@ -137,6 +138,8 @@ require "rails_error_dashboard/commands/flush_swallowed_exceptions"
137
138
  require "rails_error_dashboard/commands/flush_rack_attack_events"
138
139
  require "rails_error_dashboard/commands/scrub_invalid_encoding"
139
140
  require "rails_error_dashboard/commands/backfill_resolved_at"
141
+ require "rails_error_dashboard/queries/uninstall_plan"
142
+ require "rails_error_dashboard/commands/drop_all_tables"
140
143
  require "rails_error_dashboard/queries/errors_list"
141
144
  # Before the queries that use it: window volume is shared by dashboard and
142
145
  # analytics, and both would otherwise rescue a NameError into a silent zero.
@@ -171,19 +171,53 @@ namespace :error_dashboard do
171
171
  if config.authenticate_with
172
172
  puts "OK (custom authentication)"
173
173
  checks_passed += 1
174
- elsif config.dashboard_username == "gandalf" && config.dashboard_password == "youshallnotpass"
175
- if Rails.env.production?
176
- puts "WARNING - using default credentials in production!"
174
+ elsif (problem = config.credentials_problem)
175
+ # Same rule and environment allowlist as the boot check in validate!
176
+ label = problem == :blank ? "blank credentials" : "default credentials"
177
+ if config.refuse_default_credentials?
178
+ puts "WARNING - #{label} outside development and test!"
177
179
  checks_failed += 1
180
+ elsif problem == :blank
181
+ # The login denies everyone on a blank credential, in every environment.
182
+ puts "WARNING - #{label}: every login is denied"
183
+ warnings += 1
178
184
  else
179
- puts "OK (default credentials - change before production)"
185
+ puts "OK (#{label} - set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD before deploying)"
180
186
  warnings += 1
181
187
  end
188
+ elsif config.dashboard_password == RailsErrorDashboard::Configuration::DEFAULT_DASHBOARD_PASSWORD
189
+ puts "OK (the published default password, set explicitly by ERROR_DASHBOARD_PASSWORD)"
190
+ warnings += 1
182
191
  else
183
192
  puts "OK (custom credentials)"
184
193
  checks_passed += 1
185
194
  end
186
195
 
196
+ # 9. Solid Queue config. A section with workers and no dispatchers runs no
197
+ # dispatcher, so no delayed job ever runs, the app's own included; the
198
+ # retired rails_error_dashboard:solid_queue generator wrote exactly that.
199
+ # Checked whenever Solid Queue is loaded, not only when it is the current
200
+ # adapter: Rails 8 sets it in production only, and verify usually runs locally.
201
+ queue_check = RailsErrorDashboard::Services::SolidQueueConfigCheck
202
+ queue_config = queue_check.config_path(Rails.root)
203
+ if defined?(SolidQueue) && queue_config.exist?
204
+ print " Solid Queue config... "
205
+ environments = queue_check.app_environments(Rails.root).presence || [ Rails.env.to_s ]
206
+ result = queue_check.call(queue_config, environments: environments)
207
+ if result[:skipped]
208
+ puts "SKIPPED (#{result[:skipped]})"
209
+ warnings += 1
210
+ elsif result[:problems].any?
211
+ puts "FAILED"
212
+ result[:problems].each { |problem| puts " - #{problem}" }
213
+ queue_check::FIX.each { |line| puts " #{line}" }
214
+ checks_failed += 1
215
+ else
216
+ puts "OK"
217
+ checks_passed += 1
218
+ end
219
+ end
220
+
187
221
  # Summary
188
222
  puts "\n" + "-" * 70
189
223
  puts " Results: #{checks_passed} passed, #{checks_failed} failed, #{warnings} warnings"
@@ -448,7 +482,7 @@ namespace :error_dashboard do
448
482
 
449
483
  # Confirm before proceeding
450
484
  print "\nProceed with deletion? (y/N): "
451
- confirmation = $stdin.gets.chomp.downcase
485
+ confirmation = $stdin.gets.to_s.chomp.downcase
452
486
 
453
487
  unless confirmation == "y" || confirmation == "yes"
454
488
  puts "\n✗ Cleanup cancelled"
@@ -458,7 +492,9 @@ namespace :error_dashboard do
458
492
 
459
493
  puts "\nDeleting errors..."
460
494
  start_time = Time.current
461
- deleted = scope.delete_all
495
+ # Dependents first, in batches: occurrences, comments and cascade patterns
496
+ # hold foreign keys to the log, so a plain delete_all failed on them.
497
+ deleted = RailsErrorDashboard::RetentionCleanupJob.delete_with_dependents(scope)
462
498
  # delete_all skips callbacks, and the stat cards are cached.
463
499
  RailsErrorDashboard::Services::AnalyticsCacheManager.clear
464
500
  elapsed = (Time.current - start_time).round(2)