rails_error_dashboard 0.8.3 → 0.9.0
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.
- checksums.yaml +4 -4
- data/README.md +35 -0
- data/app/controllers/rails_error_dashboard/application_controller.rb +156 -0
- data/app/controllers/rails_error_dashboard/errors_controller.rb +95 -73
- data/app/controllers/rails_error_dashboard/locales_controller.rb +87 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +20 -14
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +15 -6
- data/app/helpers/rails_error_dashboard/i18n_helper.rb +154 -0
- data/app/helpers/rails_error_dashboard/mailer_i18n_helper.rb +69 -0
- data/app/helpers/rails_error_dashboard/overview_helper.rb +18 -6
- data/app/helpers/rails_error_dashboard/user_agent_helper.rb +25 -11
- data/app/jobs/rails_error_dashboard/application_job.rb +5 -0
- data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +18 -16
- data/app/jobs/rails_error_dashboard/concerns/localized_job.rb +83 -0
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +4 -2
- data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +4 -2
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +6 -2
- data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +8 -3
- data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +6 -4
- data/app/jobs/rails_error_dashboard/storm_notification_job.rb +26 -8
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +4 -2
- data/app/mailers/rails_error_dashboard/application_mailer.rb +14 -0
- data/app/mailers/rails_error_dashboard/digest_mailer.rb +18 -2
- data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +19 -2
- data/app/views/layouts/rails_error_dashboard.html.erb +255 -92
- data/app/views/rails_error_dashboard/digest_mailer/digest_summary.html.erb +23 -22
- data/app/views/rails_error_dashboard/digest_mailer/digest_summary.text.erb +30 -19
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +18 -16
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +21 -14
- data/app/views/rails_error_dashboard/errors/_ai_help_panel.html.erb +11 -7
- data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +45 -38
- data/app/views/rails_error_dashboard/errors/_co_occurring_errors.html.erb +17 -14
- data/app/views/rails_error_dashboard/errors/_discussion.html.erb +27 -9
- data/app/views/rails_error_dashboard/errors/_error_cascades.html.erb +25 -21
- data/app/views/rails_error_dashboard/errors/_error_info.html.erb +25 -22
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +24 -12
- data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +12 -11
- data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +19 -13
- data/app/views/rails_error_dashboard/errors/_llm_summary.html.erb +14 -14
- data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +8 -8
- data/app/views/rails_error_dashboard/errors/_modals.html.erb +60 -52
- data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +59 -49
- data/app/views/rails_error_dashboard/errors/_request_context.html.erb +23 -20
- data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +14 -14
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +118 -93
- data/app/views/rails_error_dashboard/errors/_similar_errors.html.erb +18 -12
- data/app/views/rails_error_dashboard/errors/_source_code.html.erb +20 -5
- data/app/views/rails_error_dashboard/errors/_stats.html.erb +5 -5
- data/app/views/rails_error_dashboard/errors/_timeline.html.erb +7 -7
- data/app/views/rails_error_dashboard/errors/_user_errors_table.html.erb +15 -12
- data/app/views/rails_error_dashboard/errors/actioncable_health_summary.html.erb +25 -25
- data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -28
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +82 -82
- data/app/views/rails_error_dashboard/errors/cache_health_summary.html.erb +27 -27
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +61 -57
- data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +64 -65
- data/app/views/rails_error_dashboard/errors/deprecations.html.erb +23 -23
- data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +48 -36
- data/app/views/rails_error_dashboard/errors/index.html.erb +121 -70
- data/app/views/rails_error_dashboard/errors/job_health_summary.html.erb +30 -30
- data/app/views/rails_error_dashboard/errors/llm_health_summary.html.erb +38 -39
- data/app/views/rails_error_dashboard/errors/n_plus_one_summary.html.erb +28 -25
- data/app/views/rails_error_dashboard/errors/overview.html.erb +45 -39
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +45 -44
- data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +39 -28
- data/app/views/rails_error_dashboard/errors/releases.html.erb +46 -47
- data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +48 -44
- data/app/views/rails_error_dashboard/errors/settings.html.erb +122 -112
- data/app/views/rails_error_dashboard/errors/show.html.erb +44 -37
- data/app/views/rails_error_dashboard/errors/storms.html.erb +30 -24
- data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +26 -26
- data/app/views/rails_error_dashboard/errors/user_impact.html.erb +31 -30
- data/config/locales/de.yml +2007 -0
- data/config/locales/en.yml +2719 -0
- data/config/locales/es.yml +2079 -0
- data/config/locales/fr.yml +2082 -0
- data/config/locales/it.yml +2060 -0
- data/config/locales/ja.yml +1802 -0
- data/config/locales/pl.yml +2108 -0
- data/config/locales/pt-BR.yml +2064 -0
- data/config/locales/ru.yml +2109 -0
- data/config/locales/uk.yml +2106 -0
- data/config/locales/zh-CN.yml +1791 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20260325000001_fix_swallowed_exceptions_index_for_mysql.rb +9 -3
- data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +3 -1
- data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +4 -2
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +4 -2
- data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +4 -2
- data/lib/rails_error_dashboard/commands/create_issue.rb +5 -3
- data/lib/rails_error_dashboard/commands/link_existing_issue.rb +4 -2
- data/lib/rails_error_dashboard/commands/log_error.rb +1 -1
- data/lib/rails_error_dashboard/configuration.rb +23 -0
- data/lib/rails_error_dashboard/current.rb +56 -0
- data/lib/rails_error_dashboard/i18n_store.rb +293 -0
- data/lib/rails_error_dashboard/private_backend.rb +213 -0
- data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +89 -22
- data/lib/rails_error_dashboard/services/digest_builder.rb +21 -7
- data/lib/rails_error_dashboard/services/discord_payload_builder.rb +27 -15
- data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +10 -5
- data/lib/rails_error_dashboard/services/localized_time_formatter.rb +117 -0
- data/lib/rails_error_dashboard/services/notification_helpers.rb +87 -4
- data/lib/rails_error_dashboard/services/pagerduty_payload_builder.rb +22 -5
- data/lib/rails_error_dashboard/services/slack_payload_builder.rb +48 -28
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +2 -1
- data/lib/rails_error_dashboard/services/webhook_payload_builder.rb +15 -1
- data/lib/rails_error_dashboard/translation.rb +39 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +4 -0
- data/lib/tasks/error_dashboard.rake +5 -1
- metadata +24 -4
data/config/routes.rb
CHANGED
|
@@ -7,6 +7,11 @@ RailsErrorDashboard::Engine.routes.draw do
|
|
|
7
7
|
# Settings page
|
|
8
8
|
get "settings", to: "errors#settings", as: :settings
|
|
9
9
|
|
|
10
|
+
# Per-user dashboard language, persisted in the session (P5-T1).
|
|
11
|
+
# POST only: changing it is a state change, and a GET would let any link or
|
|
12
|
+
# prefetch alter the user's language.
|
|
13
|
+
post "locale", to: "locales#create", as: :locale
|
|
14
|
+
|
|
10
15
|
# Webhook endpoint for two-way issue sync (GitHub/GitLab/Codeberg)
|
|
11
16
|
post "webhooks/:provider", to: "webhooks#receive", as: :webhook
|
|
12
17
|
|
|
@@ -13,8 +13,14 @@ class FixSwallowedExceptionsIndexForMysql < ActiveRecord::Migration[7.0]
|
|
|
13
13
|
return unless table_exists?(:rails_error_dashboard_swallowed_exceptions)
|
|
14
14
|
|
|
15
15
|
# Remove the oversized index if it exists (it may not exist on MySQL
|
|
16
|
-
# since the original migration would have failed at this point)
|
|
17
|
-
|
|
16
|
+
# since the original migration would have failed at this point).
|
|
17
|
+
#
|
|
18
|
+
# Checked by name rather than by columns. index_exists? takes the column
|
|
19
|
+
# list as a required positional argument — omitting it raises ArgumentError
|
|
20
|
+
# on Rails 8 — and passing it would be wrong here anyway: this migration
|
|
21
|
+
# wants to free the *name* before re-adding it, whatever columns the
|
|
22
|
+
# existing index happens to cover.
|
|
23
|
+
if index_name_exists?(:rails_error_dashboard_swallowed_exceptions, "index_swallowed_exceptions_upsert_key")
|
|
18
24
|
remove_index :rails_error_dashboard_swallowed_exceptions, name: "index_swallowed_exceptions_upsert_key"
|
|
19
25
|
end
|
|
20
26
|
|
|
@@ -33,7 +39,7 @@ class FixSwallowedExceptionsIndexForMysql < ActiveRecord::Migration[7.0]
|
|
|
33
39
|
def down
|
|
34
40
|
return unless table_exists?(:rails_error_dashboard_swallowed_exceptions)
|
|
35
41
|
|
|
36
|
-
if
|
|
42
|
+
if index_name_exists?(:rails_error_dashboard_swallowed_exceptions, "index_swallowed_exceptions_upsert_key")
|
|
37
43
|
remove_index :rails_error_dashboard_swallowed_exceptions, name: "index_swallowed_exceptions_upsert_key"
|
|
38
44
|
end
|
|
39
45
|
|
|
@@ -5,6 +5,8 @@ module RailsErrorDashboard
|
|
|
5
5
|
# Command: Delete multiple errors at once
|
|
6
6
|
# This is a write operation that destroys multiple ErrorLog records
|
|
7
7
|
class BatchDeleteErrors
|
|
8
|
+
include RailsErrorDashboard::Translation
|
|
9
|
+
|
|
8
10
|
def self.call(error_ids)
|
|
9
11
|
new(error_ids).call
|
|
10
12
|
end
|
|
@@ -14,7 +16,7 @@ module RailsErrorDashboard
|
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def call
|
|
17
|
-
return { success: false, count: 0, errors: [ "
|
|
19
|
+
return { success: false, count: 0, errors: [ red_t("red.commands.no_error_ids") ] } if @error_ids.empty?
|
|
18
20
|
|
|
19
21
|
errors = ErrorLog.where(id: @error_ids)
|
|
20
22
|
count = errors.count
|
|
@@ -4,6 +4,8 @@ module RailsErrorDashboard
|
|
|
4
4
|
module Commands
|
|
5
5
|
# Command: Mute multiple errors at once
|
|
6
6
|
class BatchMuteErrors
|
|
7
|
+
include RailsErrorDashboard::Translation
|
|
8
|
+
|
|
7
9
|
def self.call(error_ids, muted_by: nil, reason: nil)
|
|
8
10
|
new(error_ids, muted_by, reason).call
|
|
9
11
|
end
|
|
@@ -15,7 +17,7 @@ module RailsErrorDashboard
|
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
def call
|
|
18
|
-
return { success: false, count: 0, errors: [ "
|
|
20
|
+
return { success: false, count: 0, errors: [ red_t("red.commands.no_error_ids") ] } if @error_ids.empty?
|
|
19
21
|
|
|
20
22
|
errors = ErrorLog.where(id: @error_ids)
|
|
21
23
|
|
|
@@ -46,7 +48,7 @@ module RailsErrorDashboard
|
|
|
46
48
|
count: muted_count,
|
|
47
49
|
total: @error_ids.size,
|
|
48
50
|
failed_ids: failed_ids,
|
|
49
|
-
errors: failed_ids.empty? ? [] : [ "
|
|
51
|
+
errors: failed_ids.empty? ? [] : [ red_tp("red.commands.batch_failed.mute", count: failed_ids.size) ]
|
|
50
52
|
}
|
|
51
53
|
rescue => e
|
|
52
54
|
RailsErrorDashboard::Logger.error("Batch mute failed: #{e.message}")
|
|
@@ -5,6 +5,8 @@ module RailsErrorDashboard
|
|
|
5
5
|
# Command: Resolve multiple errors at once
|
|
6
6
|
# This is a write operation that updates multiple ErrorLog records
|
|
7
7
|
class BatchResolveErrors
|
|
8
|
+
include RailsErrorDashboard::Translation
|
|
9
|
+
|
|
8
10
|
def self.call(error_ids, resolved_by_name: nil, resolution_comment: nil)
|
|
9
11
|
new(error_ids, resolved_by_name, resolution_comment).call
|
|
10
12
|
end
|
|
@@ -16,7 +18,7 @@ module RailsErrorDashboard
|
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def call
|
|
19
|
-
return { success: false, count: 0, errors: [ "
|
|
21
|
+
return { success: false, count: 0, errors: [ red_t("red.commands.no_error_ids") ] } if @error_ids.empty?
|
|
20
22
|
|
|
21
23
|
errors = ErrorLog.where(id: @error_ids)
|
|
22
24
|
|
|
@@ -50,7 +52,7 @@ module RailsErrorDashboard
|
|
|
50
52
|
count: resolved_count,
|
|
51
53
|
total: @error_ids.size,
|
|
52
54
|
failed_ids: failed_ids,
|
|
53
|
-
errors: failed_ids.empty? ? [] : [ "
|
|
55
|
+
errors: failed_ids.empty? ? [] : [ red_tp("red.commands.batch_failed.resolve", count: failed_ids.size) ]
|
|
54
56
|
}
|
|
55
57
|
rescue => e
|
|
56
58
|
RailsErrorDashboard::Logger.error("Batch resolve failed: #{e.message}")
|
|
@@ -4,6 +4,8 @@ module RailsErrorDashboard
|
|
|
4
4
|
module Commands
|
|
5
5
|
# Command: Unmute multiple errors at once
|
|
6
6
|
class BatchUnmuteErrors
|
|
7
|
+
include RailsErrorDashboard::Translation
|
|
8
|
+
|
|
7
9
|
def self.call(error_ids)
|
|
8
10
|
new(error_ids).call
|
|
9
11
|
end
|
|
@@ -13,7 +15,7 @@ module RailsErrorDashboard
|
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def call
|
|
16
|
-
return { success: false, count: 0, errors: [ "
|
|
18
|
+
return { success: false, count: 0, errors: [ red_t("red.commands.no_error_ids") ] } if @error_ids.empty?
|
|
17
19
|
|
|
18
20
|
errors = ErrorLog.where(id: @error_ids)
|
|
19
21
|
|
|
@@ -44,7 +46,7 @@ module RailsErrorDashboard
|
|
|
44
46
|
count: unmuted_count,
|
|
45
47
|
total: @error_ids.size,
|
|
46
48
|
failed_ids: failed_ids,
|
|
47
|
-
errors: failed_ids.empty? ? [] : [ "
|
|
49
|
+
errors: failed_ids.empty? ? [] : [ red_tp("red.commands.batch_failed.unmute", count: failed_ids.size) ]
|
|
48
50
|
}
|
|
49
51
|
rescue => e
|
|
50
52
|
RailsErrorDashboard::Logger.error("Batch unmute failed: #{e.message}")
|
|
@@ -12,6 +12,8 @@ module RailsErrorDashboard
|
|
|
12
12
|
# result[:success] # => true
|
|
13
13
|
# result[:issue_url] # => "https://github.com/user/repo/issues/42"
|
|
14
14
|
class CreateIssue
|
|
15
|
+
include RailsErrorDashboard::Translation
|
|
16
|
+
|
|
15
17
|
def self.call(error_id, dashboard_url: nil)
|
|
16
18
|
new(error_id, dashboard_url: dashboard_url).call
|
|
17
19
|
end
|
|
@@ -26,11 +28,11 @@ module RailsErrorDashboard
|
|
|
26
28
|
|
|
27
29
|
# Don't create duplicate issues
|
|
28
30
|
if error.external_issue_url.present?
|
|
29
|
-
return { success: false, error: "
|
|
31
|
+
return { success: false, error: red_t("red.commands.issue.already_linked", url: error.external_issue_url) }
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
client = Services::IssueTrackerClient.from_config
|
|
33
|
-
return { success: false, error: "
|
|
35
|
+
return { success: false, error: red_t("red.commands.issue.not_configured") } unless client
|
|
34
36
|
|
|
35
37
|
config = RailsErrorDashboard.configuration
|
|
36
38
|
title = "[#{error.error_type}] #{error.message.to_s.truncate(100)}"
|
|
@@ -50,7 +52,7 @@ module RailsErrorDashboard
|
|
|
50
52
|
{ success: false, error: result[:error] }
|
|
51
53
|
end
|
|
52
54
|
rescue ActiveRecord::RecordNotFound
|
|
53
|
-
{ success: false, error: "
|
|
55
|
+
{ success: false, error: red_t("red.commands.error_not_found", id: @error_id) }
|
|
54
56
|
rescue => e
|
|
55
57
|
{ success: false, error: "#{e.class}: #{e.message}" }
|
|
56
58
|
end
|
|
@@ -11,6 +11,8 @@ module RailsErrorDashboard
|
|
|
11
11
|
# result = LinkExistingIssue.call(error_id, issue_url: "https://github.com/user/repo/issues/42")
|
|
12
12
|
# result[:success] # => true
|
|
13
13
|
class LinkExistingIssue
|
|
14
|
+
include RailsErrorDashboard::Translation
|
|
15
|
+
|
|
14
16
|
PROVIDER_PATTERNS = {
|
|
15
17
|
github: %r{github\.com/([^/]+/[^/]+)/issues/(\d+)}i,
|
|
16
18
|
gitlab: %r{gitlab\.com/([^/]+/[^/]+)/-/issues/(\d+)}i,
|
|
@@ -29,7 +31,7 @@ module RailsErrorDashboard
|
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
def call
|
|
32
|
-
return { success: false, error: "
|
|
34
|
+
return { success: false, error: red_t("red.commands.issue.url_required") } if @issue_url.blank?
|
|
33
35
|
|
|
34
36
|
error = ErrorLog.find(@error_id)
|
|
35
37
|
parsed = parse_issue_url(@issue_url)
|
|
@@ -42,7 +44,7 @@ module RailsErrorDashboard
|
|
|
42
44
|
|
|
43
45
|
{ success: true, issue_url: @issue_url, provider: parsed[:provider] }
|
|
44
46
|
rescue ActiveRecord::RecordNotFound
|
|
45
|
-
{ success: false, error: "
|
|
47
|
+
{ success: false, error: red_t("red.commands.error_not_found", id: @error_id) }
|
|
46
48
|
rescue => e
|
|
47
49
|
{ success: false, error: "#{e.class}: #{e.message}" }
|
|
48
50
|
end
|
|
@@ -505,7 +505,7 @@ module RailsErrorDashboard
|
|
|
505
505
|
return unless config.baseline_alert_severities.include?(anomaly[:level])
|
|
506
506
|
|
|
507
507
|
# Enqueue alert job (which will handle throttling)
|
|
508
|
-
BaselineAlertJob.perform_later(error_log.id, anomaly)
|
|
508
|
+
BaselineAlertJob.perform_later(error_log.id, anomaly, ApplicationJob.enqueue_locale)
|
|
509
509
|
|
|
510
510
|
RailsErrorDashboard::Logger.info(
|
|
511
511
|
"Baseline alert queued for #{error_log.error_type} on #{error_log.platform}: " \
|
|
@@ -222,6 +222,18 @@ module RailsErrorDashboard
|
|
|
222
222
|
# Dashboard UI appearance
|
|
223
223
|
attr_accessor :accent_color # :crimson (default), :ruby, :ember, :violet
|
|
224
224
|
|
|
225
|
+
# Locale the dashboard renders in, independent of the host app's locale.
|
|
226
|
+
#
|
|
227
|
+
# Drives both Pagy's pagination labels and RED's own translation lookups.
|
|
228
|
+
# Ships "en", "de", "fr", "es", "pt-BR" and "ja". Everything but English is
|
|
229
|
+
# machine-translated and has NOT been reviewed by a native speaker — see
|
|
230
|
+
# docs/guides/TRANSLATIONS.md. A missing or wrong translation falls back to
|
|
231
|
+
# English rather than breaking the page.
|
|
232
|
+
#
|
|
233
|
+
# Users can override this per-session with the dashboard's language picker.
|
|
234
|
+
# Unknown or wrong-cased values fall back to "en" (default: "en").
|
|
235
|
+
attr_accessor :dashboard_locale
|
|
236
|
+
|
|
225
237
|
# LLM-powered AI help (disabled unless provider and API key are configured)
|
|
226
238
|
attr_accessor :llm_provider # :openai or :anthropic
|
|
227
239
|
attr_accessor :llm_api_key # String or lambda/proc
|
|
@@ -433,6 +445,7 @@ module RailsErrorDashboard
|
|
|
433
445
|
|
|
434
446
|
# Dashboard UI
|
|
435
447
|
@accent_color = :crimson # :crimson, :ruby, :ember, :violet
|
|
448
|
+
@dashboard_locale = "en" # en, de, es, fr, pt-BR, ja, ru, uk, pl, it, zh-CN (non-English machine-translated)
|
|
436
449
|
|
|
437
450
|
# LLM-powered AI help defaults - OFF until provider and API key are configured
|
|
438
451
|
@llm_provider = ENV["RED_LLM_PROVIDER"]&.to_sym
|
|
@@ -590,6 +603,16 @@ module RailsErrorDashboard
|
|
|
590
603
|
if rack_attack_flush_interval && rack_attack_flush_interval < 1
|
|
591
604
|
errors << "rack_attack_flush_interval must be at least 1 (got: #{rack_attack_flush_interval})"
|
|
592
605
|
end
|
|
606
|
+
|
|
607
|
+
# Warn rather than auto-disable: validation may run before the host's
|
|
608
|
+
# Rack::Attack initializer has loaded, so a missing constant here does
|
|
609
|
+
# not prove it will still be missing at after_initialize (when the
|
|
610
|
+
# subscriber actually registers). Auto-disabling would break that case.
|
|
611
|
+
unless defined?(::Rack::Attack)
|
|
612
|
+
warnings << "enable_rack_attack_tracking is enabled but the rack-attack gem " \
|
|
613
|
+
"does not appear to be loaded. No events will be recorded until " \
|
|
614
|
+
"Rack::Attack is installed and configured."
|
|
615
|
+
end
|
|
593
616
|
end
|
|
594
617
|
|
|
595
618
|
# Validate actioncable tracking requires breadcrumbs
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module RailsErrorDashboard
|
|
2
|
+
# Request-scoped state for the dashboard.
|
|
3
|
+
#
|
|
4
|
+
# Currently holds only the locale. ActiveSupport resets CurrentAttributes
|
|
5
|
+
# between requests, but the dashboard does NOT rely on that alone — the
|
|
6
|
+
# around_action in ApplicationController clears this explicitly in an
|
|
7
|
+
# ensure block. Two reasons:
|
|
8
|
+
#
|
|
9
|
+
# 1. RED renders inside the host app's process, on Puma threads the host
|
|
10
|
+
# also uses. State that outlives its request is the shape behind both
|
|
11
|
+
# #143 and #148, where a value stranded on a recycled thread leaked
|
|
12
|
+
# into a later request.
|
|
13
|
+
# 2. The reset is a framework guarantee we do not want to depend on for a
|
|
14
|
+
# safety property.
|
|
15
|
+
#
|
|
16
|
+
# NOTE: locale intentionally has NO default. A getter that coerced nil to
|
|
17
|
+
# "en" would make restoration stamp "en" onto a thread that started clean —
|
|
18
|
+
# the exact trap documented in ApplicationController#with_dashboard_locale
|
|
19
|
+
# for Pagy. Callers resolve the default themselves via .locale_or_default.
|
|
20
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
21
|
+
attribute :locale
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# The locale the dashboard should render in, applying full precedence:
|
|
25
|
+
#
|
|
26
|
+
# Current.locale (set per request; later, the user's picker)
|
|
27
|
+
# -> config.dashboard_locale
|
|
28
|
+
# -> "en"
|
|
29
|
+
#
|
|
30
|
+
# Each candidate is validated against the locales RED actually ships, so
|
|
31
|
+
# a configured locale we cannot serve degrades to English instead of
|
|
32
|
+
# failing mid-render.
|
|
33
|
+
#
|
|
34
|
+
# @return [String] a locale RED ships. Never nil, never raises.
|
|
35
|
+
def locale_or_default
|
|
36
|
+
# to_s.strip rather than .presence — locale is a public attribute and
|
|
37
|
+
# a later caller (the session-backed picker in Phase 5) could put any
|
|
38
|
+
# object in it. Anything that is not a usable String falls through to
|
|
39
|
+
# the configured value.
|
|
40
|
+
candidate = locale.to_s.strip
|
|
41
|
+
candidate = configured_locale.to_s.strip if candidate.empty?
|
|
42
|
+
I18nStore.resolve(candidate)
|
|
43
|
+
rescue StandardError
|
|
44
|
+
I18nStore::DEFAULT_LOCALE
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def configured_locale
|
|
50
|
+
RailsErrorDashboard.configuration&.dashboard_locale
|
|
51
|
+
rescue StandardError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
require "rails_error_dashboard/private_backend"
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# RED's own translation store, deliberately isolated from the host app's I18n.
|
|
5
|
+
#
|
|
6
|
+
# WHY A PRIVATE BACKEND INSTEAD OF THE USUAL ENGINE LOAD PATH
|
|
7
|
+
#
|
|
8
|
+
# The conventional way to translate a Rails engine is to append
|
|
9
|
+
# config/locales to I18n.load_path and namespace the keys. That shares the
|
|
10
|
+
# host's backend, and sharing it hands the host three ways to break the
|
|
11
|
+
# dashboard:
|
|
12
|
+
#
|
|
13
|
+
# 1. config.i18n.raise_on_missing_translations = true turns any key we
|
|
14
|
+
# forgot into a 500 — on the error dashboard, the one page that has to
|
|
15
|
+
# work when everything else is broken.
|
|
16
|
+
# 2. enforce_available_locales with a short available_locales list raises
|
|
17
|
+
# I18n::InvalidLocale as soon as RED asks for its own locale.
|
|
18
|
+
# 3. A custom exception_handler can raise on anything it likes.
|
|
19
|
+
#
|
|
20
|
+
# RED's locale is also deliberately independent of the host's — that is the
|
|
21
|
+
# whole point of issue #148. Sharing a backend would re-create the coupling
|
|
22
|
+
# that bug was about.
|
|
23
|
+
#
|
|
24
|
+
# The trade-off: hosts cannot override RED's strings with their own locale
|
|
25
|
+
# files. That is the right default for a self-hosted ops tool, and it can be
|
|
26
|
+
# relaxed later without breaking anything.
|
|
27
|
+
#
|
|
28
|
+
# NOTHING IN HERE MAY RAISE. Every public method is total: it returns a
|
|
29
|
+
# String for any input, including garbage. See #translate.
|
|
30
|
+
module I18nStore
|
|
31
|
+
DEFAULT_LOCALE = "en".freeze
|
|
32
|
+
|
|
33
|
+
# The language's own name for itself. See .locale_options for why these are
|
|
34
|
+
# constants rather than translation keys.
|
|
35
|
+
ENDONYMS = {
|
|
36
|
+
"en" => "English",
|
|
37
|
+
"de" => "Deutsch",
|
|
38
|
+
"fr" => "Français",
|
|
39
|
+
"es" => "Español",
|
|
40
|
+
"pt-BR" => "Português (Brasil)",
|
|
41
|
+
"ja" => "日本語",
|
|
42
|
+
"ru" => "Русский",
|
|
43
|
+
"uk" => "Українська",
|
|
44
|
+
"pl" => "Polski",
|
|
45
|
+
"zh-CN" => "简体中文",
|
|
46
|
+
"it" => "Italiano"
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# Raw backend lookups signal a miss by throwing :exception rather than
|
|
50
|
+
# returning — I18n.translate is what normally catches it. We call the
|
|
51
|
+
# backend directly, so we catch it ourselves.
|
|
52
|
+
MISSING = Object.new.freeze
|
|
53
|
+
private_constant :MISSING
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
# Translate +key+ in +locale+, falling back to English, then to a
|
|
57
|
+
# readable last resort derived from the key itself.
|
|
58
|
+
#
|
|
59
|
+
# @param key [String, Symbol] dot-separated key, e.g. "red.nav.errors"
|
|
60
|
+
# @param locale [String, Symbol] target locale
|
|
61
|
+
# @return [String] always a String — never nil, never a raise
|
|
62
|
+
def translate(key, locale: DEFAULT_LOCALE, **options)
|
|
63
|
+
return "" if key.nil? || key.to_s.empty?
|
|
64
|
+
|
|
65
|
+
resolved = lookup(key, locale, options)
|
|
66
|
+
return resolved unless resolved.equal?(MISSING)
|
|
67
|
+
|
|
68
|
+
unless locale.to_s == DEFAULT_LOCALE
|
|
69
|
+
fallback = lookup(key, DEFAULT_LOCALE, options)
|
|
70
|
+
return fallback unless fallback.equal?(MISSING)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
humanized_key(key)
|
|
74
|
+
rescue StandardError
|
|
75
|
+
# Truly last resort. A translation lookup must never be the reason a
|
|
76
|
+
# dashboard page fails to render.
|
|
77
|
+
humanized_key(key)
|
|
78
|
+
end
|
|
79
|
+
alias_method :t, :translate
|
|
80
|
+
|
|
81
|
+
# Fetch a whole branch of the dictionary as a Hash, for callers that need
|
|
82
|
+
# the tree rather than one leaf — the JS payload is the only one today.
|
|
83
|
+
#
|
|
84
|
+
# #translate deliberately treats a Hash result as a miss: a key resolving
|
|
85
|
+
# to a subtree instead of a leaf is a caller bug when you asked for text.
|
|
86
|
+
# Here it is the point, so this is a separate method rather than a flag on
|
|
87
|
+
# #translate.
|
|
88
|
+
#
|
|
89
|
+
# Falls back to English as a whole branch, not key by key. A partially
|
|
90
|
+
# translated locale returning a half-English tree would be harder to
|
|
91
|
+
# debug than one that is cleanly English until it is finished.
|
|
92
|
+
#
|
|
93
|
+
# @param key [String, Symbol] dot-separated key, e.g. "red.js"
|
|
94
|
+
# @return [Hash] deep-frozen dup, or {} for a miss. Never nil, never raises.
|
|
95
|
+
def subtree(key, locale: DEFAULT_LOCALE)
|
|
96
|
+
return {} if key.nil? || key.to_s.empty?
|
|
97
|
+
|
|
98
|
+
resolved = lookup_subtree(key, locale)
|
|
99
|
+
return resolved unless resolved.equal?(MISSING)
|
|
100
|
+
|
|
101
|
+
unless locale.to_s == DEFAULT_LOCALE
|
|
102
|
+
fallback = lookup_subtree(key, DEFAULT_LOCALE)
|
|
103
|
+
return fallback unless fallback.equal?(MISSING)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
{}
|
|
107
|
+
rescue StandardError
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Locales RED ships, derived from the files actually present.
|
|
112
|
+
# @return [Array<Symbol>]
|
|
113
|
+
def available_locales
|
|
114
|
+
@available_locales ||= locale_files.map { |path| File.basename(path, ".yml").to_sym }.sort
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Each shipped locale paired with its ENDONYM — the language's own name
|
|
118
|
+
# for itself (Deutsch, not German).
|
|
119
|
+
#
|
|
120
|
+
# Deliberately NOT translation keys. An endonym is a property of the
|
|
121
|
+
# language, not of the locale you are viewing from: a picker that renders
|
|
122
|
+
# "German" when viewed from English and "Deutsch" when viewed from German
|
|
123
|
+
# is unusable precisely when you need it, which is when you are stuck in a
|
|
124
|
+
# language you cannot read. Every entry reads the same in every locale.
|
|
125
|
+
#
|
|
126
|
+
# A locale with a file but no entry here falls back to its own tag ("xh"),
|
|
127
|
+
# which is honest and still selectable, rather than being hidden.
|
|
128
|
+
#
|
|
129
|
+
# @return [Array<Array(String, String)>] [locale, endonym] pairs, sorted
|
|
130
|
+
# by locale so the picker's order is stable.
|
|
131
|
+
def locale_options
|
|
132
|
+
available_locales.map { |locale| [ locale.to_s, ENDONYMS.fetch(locale.to_s, locale.to_s) ] }
|
|
133
|
+
rescue StandardError
|
|
134
|
+
[ [ DEFAULT_LOCALE, ENDONYMS.fetch(DEFAULT_LOCALE) ] ]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Resolve an arbitrary value to a locale RED can actually serve.
|
|
138
|
+
# Matches case-insensitively ("EN" -> :en) because a wrong-cased tag that
|
|
139
|
+
# passes a format check but misses the dictionary is a mid-render failure.
|
|
140
|
+
#
|
|
141
|
+
# @return [String] a locale RED ships, or "en"
|
|
142
|
+
def resolve(value)
|
|
143
|
+
candidate = value.to_s.strip
|
|
144
|
+
return DEFAULT_LOCALE if candidate.empty?
|
|
145
|
+
|
|
146
|
+
match = available_locales.find { |locale| locale.to_s.casecmp?(candidate) }
|
|
147
|
+
match ? match.to_s : DEFAULT_LOCALE
|
|
148
|
+
rescue StandardError
|
|
149
|
+
DEFAULT_LOCALE
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [Boolean] whether RED ships this locale
|
|
153
|
+
#
|
|
154
|
+
# Compares case-insensitively rather than against a downcased copy —
|
|
155
|
+
# "pt-BR" is a real locale filename and downcasing it would report a
|
|
156
|
+
# locale we ship as unavailable.
|
|
157
|
+
def available?(value)
|
|
158
|
+
candidate = value.to_s.strip
|
|
159
|
+
return false if candidate.empty?
|
|
160
|
+
|
|
161
|
+
available_locales.any? { |locale| locale.to_s.casecmp?(candidate) }
|
|
162
|
+
rescue StandardError
|
|
163
|
+
false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Double-checked locking. The fast path reads a fully-built backend; the
|
|
167
|
+
# slow path builds it under the mutex. build_backend assigns only after
|
|
168
|
+
# load_translations returns, so no thread can observe a half-loaded
|
|
169
|
+
# backend through @backend.
|
|
170
|
+
def backend
|
|
171
|
+
cached = @backend
|
|
172
|
+
return cached if cached
|
|
173
|
+
|
|
174
|
+
load_mutex.synchronize do
|
|
175
|
+
@backend ||= build_backend
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Test seam. Clears memoized state so specs can reload from disk.
|
|
180
|
+
def reset!
|
|
181
|
+
load_mutex.synchronize do
|
|
182
|
+
@backend = nil
|
|
183
|
+
@available_locales = nil
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
# Returns the translated String, or MISSING. Never raises, never throws.
|
|
190
|
+
def lookup(key, locale, options)
|
|
191
|
+
result = catch(:exception) do
|
|
192
|
+
backend.translate(locale.to_s.to_sym, key.to_s, **options)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# A miss throws :exception carrying an I18n::MissingTranslation.
|
|
196
|
+
return MISSING if result.is_a?(::I18n::MissingTranslation)
|
|
197
|
+
return MISSING if result.nil?
|
|
198
|
+
|
|
199
|
+
# A key that resolves to a subtree rather than a leaf is a caller bug,
|
|
200
|
+
# not something to render.
|
|
201
|
+
return MISSING if result.is_a?(Hash)
|
|
202
|
+
|
|
203
|
+
result.to_s
|
|
204
|
+
rescue ::I18n::InvalidPluralizationData
|
|
205
|
+
# A locale supplying only :other while :count is 1 (or missing a plural
|
|
206
|
+
# category the count needs). Real risk for translated locales — English
|
|
207
|
+
# has one/other, but not every language's forms line up. Fall through so
|
|
208
|
+
# the caller gets English rather than a 500.
|
|
209
|
+
MISSING
|
|
210
|
+
rescue StandardError
|
|
211
|
+
MISSING
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Returns a Hash for a subtree key, or MISSING. Never raises, never throws.
|
|
215
|
+
def lookup_subtree(key, locale)
|
|
216
|
+
result = catch(:exception) do
|
|
217
|
+
backend.translate(locale.to_s.to_sym, key.to_s)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
return MISSING unless result.is_a?(Hash)
|
|
221
|
+
|
|
222
|
+
result
|
|
223
|
+
rescue StandardError
|
|
224
|
+
MISSING
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The backend is a PrivateBackend, not a bare Backend::Simple: two
|
|
228
|
+
# upstream load-time behaviours let the host app decide what RED's
|
|
229
|
+
# dictionary CONTAINS, and both are fixed there as invariants rather than
|
|
230
|
+
# worked around here. See private_backend.rb for the mechanism — the
|
|
231
|
+
# short version is that a host allowlist could strip RED's own locales
|
|
232
|
+
# out of RED's own dictionary, and the first lookup could merge the
|
|
233
|
+
# host's entire load_path in. Neither raises; both just render English.
|
|
234
|
+
#
|
|
235
|
+
# Found in P4-T3 by a spec fixture that kept resolving to English.
|
|
236
|
+
def build_backend
|
|
237
|
+
PrivateBackend.new.tap do |backend|
|
|
238
|
+
# Load each file individually rather than in one call, and never let
|
|
239
|
+
# one bad file take the dictionary down with it.
|
|
240
|
+
#
|
|
241
|
+
# load_translations raises for anything it cannot read: a file
|
|
242
|
+
# deleted between Dir[] and the open (locale_files is memoized, so
|
|
243
|
+
# the gap is real), a YAML syntax error, a permissions problem. A
|
|
244
|
+
# single call loses every locale to the first failure, and the raise
|
|
245
|
+
# escapes into whatever was rendering — which for RED is the error
|
|
246
|
+
# dashboard, the one page that must not break. NOTHING IN THIS FILE
|
|
247
|
+
# MAY RAISE (see the class comment); this was the last unguarded call.
|
|
248
|
+
#
|
|
249
|
+
# A file that fails is skipped and logged. The remaining locales load,
|
|
250
|
+
# and any key the skipped file would have supplied falls back to
|
|
251
|
+
# English through the normal path.
|
|
252
|
+
locale_files.each do |file|
|
|
253
|
+
backend.load_translations(file)
|
|
254
|
+
rescue StandardError => e
|
|
255
|
+
warn_unloadable_locale(file, e)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# A locale file that cannot be read is a real problem, but not one worth
|
|
261
|
+
# breaking the dashboard over. Logged rather than raised, and guarded
|
|
262
|
+
# because the logger itself must not be the thing that raises.
|
|
263
|
+
def warn_unloadable_locale(file, error)
|
|
264
|
+
::Rails.logger&.warn(
|
|
265
|
+
"[RailsErrorDashboard] Skipped unreadable locale file #{file}: #{error.class} - #{error.message}"
|
|
266
|
+
)
|
|
267
|
+
rescue StandardError
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def locale_files
|
|
272
|
+
Dir[File.join(locales_path, "*.yml")].sort
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def locales_path
|
|
276
|
+
File.expand_path("../../config/locales", __dir__)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# "red.nav.error_logs" -> "Error logs". Readable, and never the string
|
|
280
|
+
# "translation missing", which must never reach a user's screen.
|
|
281
|
+
def humanized_key(key)
|
|
282
|
+
segment = key.to_s.split(".").last.to_s
|
|
283
|
+
return "" if segment.empty?
|
|
284
|
+
|
|
285
|
+
segment.tr("_", " ").capitalize
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def load_mutex
|
|
289
|
+
@load_mutex ||= Mutex.new
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|