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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -0
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +156 -0
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +95 -73
  5. data/app/controllers/rails_error_dashboard/locales_controller.rb +87 -0
  6. data/app/helpers/rails_error_dashboard/application_helper.rb +20 -14
  7. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +15 -6
  8. data/app/helpers/rails_error_dashboard/i18n_helper.rb +154 -0
  9. data/app/helpers/rails_error_dashboard/mailer_i18n_helper.rb +69 -0
  10. data/app/helpers/rails_error_dashboard/overview_helper.rb +18 -6
  11. data/app/helpers/rails_error_dashboard/user_agent_helper.rb +25 -11
  12. data/app/jobs/rails_error_dashboard/application_job.rb +5 -0
  13. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +18 -16
  14. data/app/jobs/rails_error_dashboard/concerns/localized_job.rb +83 -0
  15. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +4 -2
  16. data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +4 -2
  17. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +6 -2
  18. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +8 -3
  19. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +6 -4
  20. data/app/jobs/rails_error_dashboard/storm_notification_job.rb +26 -8
  21. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +4 -2
  22. data/app/mailers/rails_error_dashboard/application_mailer.rb +14 -0
  23. data/app/mailers/rails_error_dashboard/digest_mailer.rb +18 -2
  24. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +19 -2
  25. data/app/views/layouts/rails_error_dashboard.html.erb +255 -92
  26. data/app/views/rails_error_dashboard/digest_mailer/digest_summary.html.erb +23 -22
  27. data/app/views/rails_error_dashboard/digest_mailer/digest_summary.text.erb +30 -19
  28. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +18 -16
  29. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +21 -14
  30. data/app/views/rails_error_dashboard/errors/_ai_help_panel.html.erb +11 -7
  31. data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +45 -38
  32. data/app/views/rails_error_dashboard/errors/_co_occurring_errors.html.erb +17 -14
  33. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +27 -9
  34. data/app/views/rails_error_dashboard/errors/_error_cascades.html.erb +25 -21
  35. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +25 -22
  36. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +24 -12
  37. data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +12 -11
  38. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +19 -13
  39. data/app/views/rails_error_dashboard/errors/_llm_summary.html.erb +14 -14
  40. data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +8 -8
  41. data/app/views/rails_error_dashboard/errors/_modals.html.erb +60 -52
  42. data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +59 -49
  43. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +23 -20
  44. data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +14 -14
  45. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +118 -93
  46. data/app/views/rails_error_dashboard/errors/_similar_errors.html.erb +18 -12
  47. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +20 -5
  48. data/app/views/rails_error_dashboard/errors/_stats.html.erb +5 -5
  49. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +7 -7
  50. data/app/views/rails_error_dashboard/errors/_user_errors_table.html.erb +15 -12
  51. data/app/views/rails_error_dashboard/errors/actioncable_health_summary.html.erb +25 -25
  52. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -28
  53. data/app/views/rails_error_dashboard/errors/analytics.html.erb +82 -82
  54. data/app/views/rails_error_dashboard/errors/cache_health_summary.html.erb +27 -27
  55. data/app/views/rails_error_dashboard/errors/correlation.html.erb +61 -57
  56. data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +64 -65
  57. data/app/views/rails_error_dashboard/errors/deprecations.html.erb +23 -23
  58. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +48 -36
  59. data/app/views/rails_error_dashboard/errors/index.html.erb +121 -70
  60. data/app/views/rails_error_dashboard/errors/job_health_summary.html.erb +30 -30
  61. data/app/views/rails_error_dashboard/errors/llm_health_summary.html.erb +38 -39
  62. data/app/views/rails_error_dashboard/errors/n_plus_one_summary.html.erb +28 -25
  63. data/app/views/rails_error_dashboard/errors/overview.html.erb +45 -39
  64. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +45 -44
  65. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +39 -28
  66. data/app/views/rails_error_dashboard/errors/releases.html.erb +46 -47
  67. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +48 -44
  68. data/app/views/rails_error_dashboard/errors/settings.html.erb +122 -112
  69. data/app/views/rails_error_dashboard/errors/show.html.erb +44 -37
  70. data/app/views/rails_error_dashboard/errors/storms.html.erb +30 -24
  71. data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +26 -26
  72. data/app/views/rails_error_dashboard/errors/user_impact.html.erb +31 -30
  73. data/config/locales/de.yml +2007 -0
  74. data/config/locales/en.yml +2719 -0
  75. data/config/locales/es.yml +2079 -0
  76. data/config/locales/fr.yml +2082 -0
  77. data/config/locales/it.yml +2060 -0
  78. data/config/locales/ja.yml +1802 -0
  79. data/config/locales/pl.yml +2108 -0
  80. data/config/locales/pt-BR.yml +2064 -0
  81. data/config/locales/ru.yml +2109 -0
  82. data/config/locales/uk.yml +2106 -0
  83. data/config/locales/zh-CN.yml +1791 -0
  84. data/config/routes.rb +5 -0
  85. data/db/migrate/20260325000001_fix_swallowed_exceptions_index_for_mysql.rb +9 -3
  86. data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +3 -1
  87. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +4 -2
  88. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +4 -2
  89. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +4 -2
  90. data/lib/rails_error_dashboard/commands/create_issue.rb +5 -3
  91. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +4 -2
  92. data/lib/rails_error_dashboard/commands/log_error.rb +1 -1
  93. data/lib/rails_error_dashboard/configuration.rb +23 -0
  94. data/lib/rails_error_dashboard/current.rb +56 -0
  95. data/lib/rails_error_dashboard/i18n_store.rb +293 -0
  96. data/lib/rails_error_dashboard/private_backend.rb +213 -0
  97. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +89 -22
  98. data/lib/rails_error_dashboard/services/digest_builder.rb +21 -7
  99. data/lib/rails_error_dashboard/services/discord_payload_builder.rb +27 -15
  100. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +10 -5
  101. data/lib/rails_error_dashboard/services/localized_time_formatter.rb +117 -0
  102. data/lib/rails_error_dashboard/services/notification_helpers.rb +87 -4
  103. data/lib/rails_error_dashboard/services/pagerduty_payload_builder.rb +22 -5
  104. data/lib/rails_error_dashboard/services/slack_payload_builder.rb +48 -28
  105. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +2 -1
  106. data/lib/rails_error_dashboard/services/webhook_payload_builder.rb +15 -1
  107. data/lib/rails_error_dashboard/translation.rb +39 -0
  108. data/lib/rails_error_dashboard/version.rb +1 -1
  109. data/lib/rails_error_dashboard.rb +4 -0
  110. data/lib/tasks/error_dashboard.rake +5 -1
  111. 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
- if index_exists?(:rails_error_dashboard_swallowed_exceptions, name: "index_swallowed_exceptions_upsert_key")
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 index_exists?(:rails_error_dashboard_swallowed_exceptions, name: "index_swallowed_exceptions_upsert_key")
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: [ "No error IDs provided" ] } if @error_ids.empty?
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: [ "No error IDs provided" ] } if @error_ids.empty?
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? ? [] : [ "Failed to mute #{failed_ids.size} error(s)" ]
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: [ "No error IDs provided" ] } if @error_ids.empty?
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? ? [] : [ "Failed to resolve #{failed_ids.size} error(s)" ]
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: [ "No error IDs provided" ] } if @error_ids.empty?
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? ? [] : [ "Failed to unmute #{failed_ids.size} error(s)" ]
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: "Error already has a linked issue: #{error.external_issue_url}" }
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: "Issue tracking is not configured" } unless client
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: "Error not found: #{@error_id}" }
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: "Issue URL is required" } if @issue_url.blank?
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: "Error not found: #{@error_id}" }
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