rails_error_dashboard 0.12.0 → 0.13.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/rails_error_dashboard/application_controller.rb +92 -11
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +111 -34
  4. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +3 -0
  5. data/app/helpers/rails_error_dashboard/application_helper.rb +46 -1
  6. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +8 -0
  7. data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +71 -0
  8. data/app/jobs/rails_error_dashboard/notification_burst_summary_job.rb +55 -0
  9. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +68 -1
  10. data/app/jobs/rails_error_dashboard/storm_notification_job.rb +8 -44
  11. data/app/models/rails_error_dashboard/error_baseline.rb +12 -9
  12. data/app/models/rails_error_dashboard/error_comment.rb +0 -5
  13. data/app/models/rails_error_dashboard/error_log.rb +9 -3
  14. data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
  15. data/app/models/rails_error_dashboard/error_occurrence.rb +9 -1
  16. data/app/views/layouts/rails_error_dashboard.html.erb +11 -3
  17. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +18 -11
  18. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +22 -8
  19. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +15 -6
  20. data/app/views/rails_error_dashboard/errors/analytics.html.erb +14 -8
  21. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +1 -1
  22. data/app/views/rails_error_dashboard/errors/index.html.erb +6 -1
  23. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +7 -7
  24. data/app/views/rails_error_dashboard/errors/releases.html.erb +2 -2
  25. data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
  26. data/config/locales/de.yml +28 -0
  27. data/config/locales/en.yml +35 -0
  28. data/config/locales/es.yml +28 -0
  29. data/config/locales/fr.yml +28 -0
  30. data/config/locales/it.yml +28 -0
  31. data/config/locales/ja.yml +28 -0
  32. data/config/locales/pl.yml +28 -0
  33. data/config/locales/pt-BR.yml +28 -0
  34. data/config/locales/ru.yml +28 -0
  35. data/config/locales/uk.yml +28 -0
  36. data/config/locales/zh-CN.yml +28 -0
  37. data/db/migrate/20260917000001_add_last_notified_at_to_error_logs.rb +40 -0
  38. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +12 -1
  39. data/lib/rails_error_dashboard/commands/assign_error.rb +12 -2
  40. data/lib/rails_error_dashboard/commands/backfill_environments.rb +2 -0
  41. data/lib/rails_error_dashboard/commands/backfill_resolved_at.rb +42 -0
  42. data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +1 -0
  43. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +2 -0
  44. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +2 -0
  45. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +2 -0
  46. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +39 -7
  47. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +3 -1
  48. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +27 -4
  49. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +5 -2
  50. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +1 -0
  51. data/lib/rails_error_dashboard/commands/log_error.rb +82 -23
  52. data/lib/rails_error_dashboard/commands/mute_error.rb +1 -0
  53. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -0
  54. data/lib/rails_error_dashboard/commands/scrub_invalid_encoding.rb +104 -0
  55. data/lib/rails_error_dashboard/commands/snooze_error.rb +34 -10
  56. data/lib/rails_error_dashboard/commands/unmute_error.rb +1 -0
  57. data/lib/rails_error_dashboard/commands/update_error_priority.rb +23 -2
  58. data/lib/rails_error_dashboard/commands/update_error_status.rb +33 -6
  59. data/lib/rails_error_dashboard/configuration.rb +19 -1
  60. data/lib/rails_error_dashboard/engine.rb +15 -0
  61. data/lib/rails_error_dashboard/queries/analytics_stats.rb +107 -26
  62. data/lib/rails_error_dashboard/queries/baseline_stats.rb +107 -0
  63. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +55 -50
  64. data/lib/rails_error_dashboard/queries/error_correlation.rb +6 -3
  65. data/lib/rails_error_dashboard/queries/errors_list.rb +15 -2
  66. data/lib/rails_error_dashboard/queries/similar_errors.rb +1 -1
  67. data/lib/rails_error_dashboard/services/analytics_cache_manager.rb +43 -18
  68. data/lib/rails_error_dashboard/services/backtrace_processor.rb +3 -1
  69. data/lib/rails_error_dashboard/services/cause_chain_extractor.rb +3 -1
  70. data/lib/rails_error_dashboard/services/codeberg_issue_client.rb +13 -2
  71. data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +5 -3
  72. data/lib/rails_error_dashboard/services/encoding_sanitizer.rb +80 -0
  73. data/lib/rails_error_dashboard/services/error_broadcaster.rb +180 -32
  74. data/lib/rails_error_dashboard/services/error_hash_generator.rb +9 -3
  75. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +13 -0
  76. data/lib/rails_error_dashboard/services/exception_filter.rb +55 -0
  77. data/lib/rails_error_dashboard/services/git_head_reader.rb +102 -0
  78. data/lib/rails_error_dashboard/services/notification_throttler.rb +172 -28
  79. data/lib/rails_error_dashboard/services/sensitive_data_filter.rb +47 -1
  80. data/lib/rails_error_dashboard/services/storm_protection/circuit_breaker.rb +59 -3
  81. data/lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb +25 -2
  82. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +32 -5
  83. data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +99 -23
  84. data/lib/rails_error_dashboard/services/url_safety.rb +40 -0
  85. data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +11 -2
  86. data/lib/rails_error_dashboard/value_objects/error_context.rb +3 -1
  87. data/lib/rails_error_dashboard/version.rb +1 -1
  88. data/lib/rails_error_dashboard.rb +31 -0
  89. data/lib/tasks/error_dashboard.rake +54 -4
  90. metadata +10 -2
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Sends the ONE message that replaces the rest of a burst of new-error
5
+ # notifications.
6
+ #
7
+ # A bad deploy can produce hundreds of DISTINCT new errors. Each is a first
8
+ # occurrence, so the per-error cooldown never applies; without a cap every
9
+ # one of them pages. NotificationThrottler.burst_decision lets the first
10
+ # config.notification_burst_limit through per window and asks for this job
11
+ # exactly once, when the limit is first exceeded.
12
+ #
13
+ # The message states the limit and the window rather than a count of what
14
+ # was suppressed: it is sent when suppression STARTS, and a count at that
15
+ # moment would always be one. Every error is still stored and counted; only
16
+ # the notifications are held back.
17
+ class NotificationBurstSummaryJob < ApplicationJob
18
+ include Concerns::PlainChannelMessage
19
+
20
+ queue_as :default
21
+
22
+ # @param limit [Integer] config.notification_burst_limit when the cap engaged
23
+ # @param window_seconds [Integer] config.notification_burst_window_seconds
24
+ # @param locale [String, nil] resolved at enqueue time
25
+ def perform(limit:, window_seconds:, locale: nil)
26
+ config = RailsErrorDashboard.configuration
27
+
28
+ deliver_plain_message(build_message(limit, window_seconds, config, job_locale(locale)), {
29
+ event: "new_error_notifications_suppressed",
30
+ limit: limit,
31
+ window_seconds: window_seconds,
32
+ application: app_name(config)
33
+ })
34
+ rescue => e
35
+ Rails.logger.error("[RailsErrorDashboard] NotificationBurstSummaryJob failed: #{e.class} - #{e.message}")
36
+ end
37
+
38
+ private
39
+
40
+ # Whole sentences joined, never fragments: see StormNotificationJob.
41
+ # ":warning:" is Slack/Discord emoji shortcode, not text.
42
+ def build_message(limit, window_seconds, config, locale)
43
+ dashboard = (config.dashboard_base_url || "").chomp("/")
44
+ link = if dashboard.present?
45
+ " " + t("red.notifications.burst.dashboard", locale, url: "#{dashboard}/errors")
46
+ else
47
+ ""
48
+ end
49
+
50
+ ":warning: " \
51
+ "#{t("red.notifications.burst.suppressed", locale, application: app_name(config), limit: limit, window: window_seconds)} " \
52
+ "#{t("red.notifications.burst.still_recorded", locale)}#{link}"
53
+ end
54
+ end
55
+ end
@@ -14,6 +14,25 @@ module RailsErrorDashboard
14
14
  class RetentionCleanupJob < ApplicationJob
15
15
  queue_as :default
16
16
 
17
+ # The errors retention applies to: "not seen for retention_days", not
18
+ # "first seen retention_days ago". occurred_at is stamped when a group is
19
+ # created and never moves, so expiring by it deleted errors that were still
20
+ # happening today -- with their comments and triage history -- the day
21
+ # they turned N days old.
22
+ #
23
+ # Equivalent to COALESCE(last_seen_at, occurred_at) < cutoff (the NULL arm
24
+ # covers rows from before last_seen_at existed), but written so that each
25
+ # arm can use its own index; wrapping the columns in COALESCE would force a
26
+ # full scan of the largest table on every run.
27
+ #
28
+ # Public because the rake task previews the same selection before it asks
29
+ # for confirmation.
30
+ def self.expired_scope(cutoff)
31
+ ErrorLog.where(
32
+ "last_seen_at < :cutoff OR (last_seen_at IS NULL AND occurred_at < :cutoff)", cutoff: cutoff
33
+ )
34
+ end
35
+
17
36
  # @return [Integer] number of errors deleted
18
37
  def perform
19
38
  retention_days = RailsErrorDashboard.configuration.retention_days
@@ -26,8 +45,10 @@ module RailsErrorDashboard
26
45
  # whenever no error logs happen to be expired.
27
46
  cleanup_rack_attack_events(cutoff)
28
47
  cleanup_storm_flush_batches(cutoff)
48
+ cleanup_diagnostic_dumps(cutoff)
49
+ cleanup_swallowed_exceptions(cutoff)
29
50
 
30
- expired_scope = ErrorLog.where("occurred_at < ?", cutoff)
51
+ expired_scope = self.class.expired_scope(cutoff)
31
52
  return 0 if expired_scope.none?
32
53
 
33
54
  deleted_count = 0
@@ -48,6 +69,9 @@ module RailsErrorDashboard
48
69
  deleted_count += batch_size
49
70
  end
50
71
 
72
+ # delete_all skips callbacks, and the stat cards are cached.
73
+ Services::AnalyticsCacheManager.clear if deleted_count > 0
74
+
51
75
  if deleted_count > 0
52
76
  RailsErrorDashboard::Logger.info(
53
77
  "[RailsErrorDashboard] Retention cleanup: deleted #{deleted_count} errors older than #{retention_days} days"
@@ -86,6 +110,49 @@ module RailsErrorDashboard
86
110
  )
87
111
  end
88
112
 
113
+ # Diagnostic dumps and swallowed-exception aggregates are written
114
+ # independently of error logs and nothing else ever deletes them, so they
115
+ # grew without bound. Not gated on their feature flags: rows written while
116
+ # a feature was on must still expire after it is switched off. Own rescue
117
+ # each, like the cleanups around them.
118
+ def cleanup_diagnostic_dumps(cutoff)
119
+ return unless DiagnosticDump.table_exists?
120
+
121
+ deleted = 0
122
+ DiagnosticDump.where("captured_at < ?", cutoff).in_batches(of: 1000) do |batch|
123
+ deleted += batch.delete_all
124
+ end
125
+
126
+ if deleted > 0
127
+ RailsErrorDashboard::Logger.info(
128
+ "[RailsErrorDashboard] Retention cleanup: deleted #{deleted} diagnostic dumps"
129
+ )
130
+ end
131
+ rescue => e
132
+ RailsErrorDashboard::Logger.debug(
133
+ "[RailsErrorDashboard] Diagnostic dump retention cleanup failed: #{e.class} - #{e.message}"
134
+ )
135
+ end
136
+
137
+ def cleanup_swallowed_exceptions(cutoff)
138
+ return unless SwallowedException.table_exists?
139
+
140
+ deleted = 0
141
+ SwallowedException.where("period_hour < ?", cutoff).in_batches(of: 1000) do |batch|
142
+ deleted += batch.delete_all
143
+ end
144
+
145
+ if deleted > 0
146
+ RailsErrorDashboard::Logger.info(
147
+ "[RailsErrorDashboard] Retention cleanup: deleted #{deleted} swallowed exception records"
148
+ )
149
+ end
150
+ rescue => e
151
+ RailsErrorDashboard::Logger.debug(
152
+ "[RailsErrorDashboard] Swallowed exception retention cleanup failed: #{e.class} - #{e.message}"
153
+ )
154
+ end
155
+
89
156
  # Expire aggregated Rack Attack event rows. Isolated in its own rescue so a
90
157
  # failure here (e.g. table not yet migrated) never blocks error cleanup.
91
158
  def cleanup_rack_attack_events(cutoff)
@@ -7,6 +7,8 @@ module RailsErrorDashboard
7
7
  # help nobody) — this one message replaces them. The gate guarantees at
8
8
  # most one enqueue per episode; this job just delivers.
9
9
  class StormNotificationJob < ApplicationJob
10
+ include Concerns::PlainChannelMessage
11
+
10
12
  queue_as :default
11
13
 
12
14
  # @param started_at [String] ISO8601 episode start
@@ -17,23 +19,12 @@ module RailsErrorDashboard
17
19
  config = RailsErrorDashboard.configuration
18
20
  message = build_message(started_at, state, config, job_locale(locale))
19
21
 
20
- if config.enable_slack_notifications && config.slack_webhook_url.present?
21
- post_json(config.slack_webhook_url, { text: message })
22
- end
23
-
24
- if config.enable_discord_notifications && config.discord_webhook_url.present?
25
- post_json(config.discord_webhook_url, { content: message })
26
- end
27
-
28
- if config.enable_webhook_notifications && config.webhook_urls.any?
29
- payload = {
30
- event: "error_storm_detected",
31
- started_at: started_at,
32
- state: state,
33
- application: app_name(config)
34
- }
35
- config.webhook_urls.each { |url| post_json(url, payload) }
36
- end
22
+ deliver_plain_message(message, {
23
+ event: "error_storm_detected",
24
+ started_at: started_at,
25
+ state: state,
26
+ application: app_name(config)
27
+ })
37
28
  rescue => e
38
29
  Rails.logger.error("[RailsErrorDashboard] StormNotificationJob failed: #{e.class} - #{e.message}")
39
30
  end
@@ -61,32 +52,5 @@ module RailsErrorDashboard
61
52
  "#{t("red.notifications.storm.engaged", locale, mode: mode)} " \
62
53
  "#{t("red.notifications.storm.suppressed", locale)}#{link}"
63
54
  end
64
-
65
- def t(key, locale, **options)
66
- RailsErrorDashboard::I18nStore.translate(key, locale: locale, **options)
67
- end
68
-
69
- def app_name(config)
70
- config.application_name || ENV["APPLICATION_NAME"] ||
71
- (defined?(Rails) && Rails.application.class.module_parent_name) || "Rails Application"
72
- end
73
-
74
- def post_json(url, payload)
75
- if defined?(HTTParty)
76
- HTTParty.post(url, body: payload.to_json,
77
- headers: { "Content-Type" => "application/json" }, timeout: 10)
78
- else
79
- uri = URI(url)
80
- http = Net::HTTP.new(uri.host, uri.port)
81
- http.use_ssl = uri.scheme == "https"
82
- http.open_timeout = 5
83
- http.read_timeout = 10
84
- request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
85
- request.body = payload.to_json
86
- http.request(request)
87
- end
88
- rescue => e
89
- Rails.logger.error("[RailsErrorDashboard] Storm notification post failed: #{e.message}")
90
- end
91
55
  end
92
56
  end
@@ -48,17 +48,20 @@ module RailsErrorDashboard
48
48
  return nil if mean.nil? || std_dev.nil?
49
49
  return nil if current_count <= mean
50
50
 
51
- std_devs_above = (current_count - mean) / std_dev
51
+ # nil when std_dev is zero: a perfectly flat history has no spread to
52
+ # measure against, and dividing by it made every count :critical.
53
+ sigma = std_devs_above_mean(current_count)
54
+ return nil if sigma.nil?
52
55
 
53
- case std_devs_above
54
- when sensitivity..(sensitivity + 1)
55
- :elevated
56
- when (sensitivity + 1)..(sensitivity + 2)
57
- :high
58
- when (sensitivity + 2)..Float::INFINITY
56
+ # Half-open bands, highest first: a boundary value belongs to the band it
57
+ # opens (3.0 is :high, 4.0 is :critical). Inclusive ranges matched
58
+ # top-down gave each boundary to the band below it.
59
+ if sigma >= sensitivity + 2
59
60
  :critical
60
- else
61
- nil
61
+ elsif sigma >= sensitivity + 1
62
+ :high
63
+ elsif sigma >= sensitivity
64
+ :elevated
62
65
  end
63
66
  end
64
67
 
@@ -14,11 +14,6 @@ module RailsErrorDashboard
14
14
  scope :recent_first, -> { order(created_at: :desc) }
15
15
  scope :oldest_first, -> { order(created_at: :asc) }
16
16
 
17
- # Get formatted timestamp for display
18
- def formatted_time
19
- created_at.strftime("%b %d, %Y at %I:%M %p")
20
- end
21
-
22
17
  # Check if comment was created recently (within last hour)
23
18
  def recent?
24
19
  created_at > 1.hour.ago
@@ -86,9 +86,9 @@ module RailsErrorDashboard
86
86
  after_create_commit -> { Services::ErrorBroadcaster.broadcast_new(self) }
87
87
  after_update_commit -> { Services::ErrorBroadcaster.broadcast_update(self) }
88
88
 
89
- # Cache invalidation - clear analytics caches when errors are created/updated/deleted
90
- after_save -> { Services::AnalyticsCacheManager.clear }
91
- after_destroy -> { Services::AnalyticsCacheManager.clear }
89
+ # No cache invalidation here, on purpose. A save is what a CAPTURE does, in
90
+ # the host app's request thread; the stats caches expire by TTL instead, and
91
+ # the commands behind user actions call AnalyticsCacheManager.clear themselves.
92
92
 
93
93
  def set_defaults
94
94
  self.platform ||= "API"
@@ -271,7 +271,13 @@ module RailsErrorDashboard
271
271
  end
272
272
  end
273
273
 
274
+ # The five workflow statuses. One list, so that a command can tell
275
+ # "unknown status" from "known, but not reachable from here".
276
+ STATUSES = %w[new in_progress investigating resolved wont_fix].freeze
277
+
274
278
  def can_transition_to?(new_status)
279
+ return false unless STATUSES.include?(new_status)
280
+
275
281
  # Define valid status transitions
276
282
  valid_transitions = {
277
283
  "new" => [ "in_progress", "investigating", "wont_fix" ],
@@ -35,6 +35,23 @@ module RailsErrorDashboard
35
35
  # after the user's configuration is loaded
36
36
  # See lib/rails_error_dashboard/engine.rb
37
37
 
38
+ # Read-side half of the invalid-bytes defence. Capture scrubs what it
39
+ # writes, but SQLite and MySQL will happily hold a row written before that
40
+ # (or by anything else), and one such string takes down every page that
41
+ # renders it: ERB raises "invalid byte sequence in UTF-8". Scrubbing on load
42
+ # makes the page render whether or not the operator has run
43
+ # error_dashboard:scrub_invalid_encoding yet. In memory only: nothing is
44
+ # written, and the record is not left dirty.
45
+ after_find :scrub_invalid_strings
46
+
47
+ # Names of the attributes the load-time scrub had to repair, so that
48
+ # Commands::ScrubInvalidEncoding can persist the repair: once the values
49
+ # are clean in memory, re-reading them can no longer tell a bad row apart.
50
+ # @return [Array<String>]
51
+ def invalid_encoding_attributes
52
+ @invalid_encoding_attributes || []
53
+ end
54
+
38
55
  # Rails' default for a string column when the adapter has one (MySQL).
39
56
  DEFAULT_STRING_LIMIT = 255
40
57
 
@@ -68,5 +85,22 @@ module RailsErrorDashboard
68
85
  limits[name] = column.limit || DEFAULT_STRING_LIMIT if column.type == :string
69
86
  end
70
87
  end
88
+
89
+ private
90
+
91
+ def scrub_invalid_strings
92
+ @attributes.each_value do |attribute|
93
+ value = attribute.value_before_type_cast
94
+ next unless value.is_a?(String)
95
+ next if value.encoding == Encoding::UTF_8 && value.valid_encoding? && !value.include?("\0")
96
+
97
+ name = attribute.name
98
+ self[name] = Services::EncodingSanitizer.scrub(value)
99
+ clear_attribute_changes([ name ])
100
+ (@invalid_encoding_attributes ||= []) << name
101
+ end
102
+ rescue => e
103
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] scrub on load failed: #{e.class}: #{e.message}")
104
+ end
71
105
  end
72
106
  end
@@ -23,7 +23,15 @@ module RailsErrorDashboard
23
23
  scope :in_time_window, ->(start_time, end_time) { where(occurred_at: start_time..end_time) }
24
24
  scope :for_user, ->(user_id) { where(user_id: user_id) }
25
25
  scope :for_request, ->(request_id) { where(request_id: request_id) }
26
- scope :for_session, ->(session_id) { where(session_id: session_id) }
26
+ # Takes the raw session ID (or a stored digest). Rows are stored as a keyed
27
+ # digest while filter_sensitive_data is on, and raw otherwise or before the
28
+ # upgrade, so both forms are matched. Blank matches nothing.
29
+ scope :for_session, lambda { |session_id|
30
+ raw = session_id.to_s
31
+ next none if raw.empty?
32
+
33
+ where(session_id: [ raw, Services::SensitiveDataFilter.digest_session_id(raw) ].compact.uniq)
34
+ }
27
35
 
28
36
  # Find occurrences within a time window around this occurrence
29
37
  # @param window_minutes [Integer] Time window in minutes (default: 5)
@@ -1182,10 +1182,18 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1182
1182
  <i class="bi bi-layout-sidebar-inset"></i>
1183
1183
  </button>
1184
1184
  <!-- Search -->
1185
- <div style="position: relative;">
1185
+ <%# A plain GET form to the errors index, using the same "search"
1186
+ parameter as the index's own field. Hand-written rather than
1187
+ form_with: a GET form must not put an authenticity token or a
1188
+ utf8 field into the query string. Only String parameters are
1189
+ echoed back; a nested one is ignored. %>
1190
+ <form action="<%= errors_path %>" method="get" role="search" style="position: relative; margin: 0;">
1186
1191
  <i class="bi bi-search" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 13px; color: var(--text-tertiary);"></i>
1187
- <input class="red-search-input" placeholder="<%= red_t("red.navbar.search_placeholder") %>" id="globalSearch" />
1188
- </div>
1192
+ <input class="red-search-input" type="text" name="search" value="<%= params[:search] if params[:search].is_a?(String) %>" placeholder="<%= red_t("red.navbar.search_placeholder") %>" aria-label="<%= red_t("red.navbar.search_placeholder") %>" id="globalSearch" />
1193
+ <% if params[:application_id].is_a?(String) && params[:application_id].present? %>
1194
+ <input type="hidden" name="application_id" value="<%= params[:application_id] %>" />
1195
+ <% end %>
1196
+ </form>
1189
1197
  </div>
1190
1198
  <div style="display: flex; align-items: center; gap: var(--space-3);">
1191
1199
  <!-- Application Switcher (multi-app) -->
@@ -1,4 +1,7 @@
1
1
  <% has_linked_issue = error.respond_to?(:external_issue_url) && error.external_issue_url.present? %>
2
+ <%# nil when the stored value is not plain http(s); the button is then omitted
3
+ (the issue card explains why). See safe_external_url. %>
4
+ <% discussion_issue_url = has_linked_issue ? safe_external_url(error.external_issue_url) : nil %>
2
5
  <% has_audit_comments = error.respond_to?(:comments) && error.comments.any? %>
3
6
  <% platform_comments = @platform_comments || [] %>
4
7
  <% issue_tracking_enabled = RailsErrorDashboard.configuration.enable_issue_tracking %>
@@ -30,9 +33,11 @@
30
33
  <span class="badge bg-secondary ms-2"><%= platform_comments.size %></span>
31
34
  <% end %>
32
35
  </h5>
33
- <a href="<%= error.external_issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
34
- <i class="bi bi-box-arrow-up-right me-1"></i> <%= red_t("red.errors.discussion.reply_on", provider: discussion_provider) %>
35
- </a>
36
+ <% if discussion_issue_url %>
37
+ <a href="<%= discussion_issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
38
+ <i class="bi bi-box-arrow-up-right me-1"></i> <%= red_t("red.errors.discussion.reply_on", provider: discussion_provider) %>
39
+ </a>
40
+ <% end %>
36
41
  </div>
37
42
  <div class="card-body p-0">
38
43
  <% if platform_comments.any? %>
@@ -40,14 +45,14 @@
40
45
  <% platform_comments.each_with_index do |comment, index| %>
41
46
  <div class="px-3 py-3 <%= index < platform_comments.size - 1 ? 'border-bottom' : '' %>">
42
47
  <div class="d-flex align-items-start gap-2 mb-2">
43
- <% if comment[:avatar_url].present? %>
44
- <img src="<%= comment[:avatar_url] %>" alt="<%= comment[:author] %>" width="24" height="24" class="rounded-circle" style="flex-shrink: 0;">
48
+ <% if (avatar = safe_external_url(comment[:avatar_url])) %>
49
+ <img src="<%= avatar %>" alt="<%= comment[:author] %>" width="24" height="24" class="rounded-circle" style="flex-shrink: 0;">
45
50
  <% else %>
46
51
  <i class="bi bi-person-circle" style="font-size: 1.3em; flex-shrink: 0;"></i>
47
52
  <% end %>
48
53
  <div>
49
54
  <strong class="text-primary"><%= comment[:author] || red_t("red.common.unknown") %></strong>
50
- <small class="text-muted ms-2"><%= comment[:created_at] ? Time.parse(comment[:created_at]).strftime(red_time_format(:datetime)) : "" %></small>
55
+ <small class="text-muted ms-2"><%= local_time(safe_parse_time(comment[:created_at]), format: :datetime, fallback: "") %></small>
51
56
  </div>
52
57
  </div>
53
58
  <div class="ms-4 text-break" style="font-size: 0.9em;">
@@ -68,7 +73,7 @@
68
73
  <div class="d-flex justify-content-between align-items-start">
69
74
  <div>
70
75
  <small class="text-primary fw-bold"><i class="bi bi-person-circle"></i> <%= comment.author_name %></small>
71
- <small class="text-muted ms-2"><%= comment.formatted_time %></small>
76
+ <small class="text-muted ms-2"><%= local_time(comment.created_at, format: :datetime) %></small>
72
77
  </div>
73
78
  </div>
74
79
  <small class="text-muted text-break"><%= simple_format(h(comment.body), {}, wrapper_tag: "span") %></small>
@@ -84,9 +89,11 @@
84
89
  <h5 class="mb-0">
85
90
  <i class="bi bi-chat-dots"></i> <%= red_t("red.errors.discussion.title") %>
86
91
  </h5>
87
- <a href="<%= error.external_issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
88
- <i class="bi bi-box-arrow-up-right me-1"></i> <%= red_t("red.errors.discussion.start_on", provider: discussion_provider) %>
89
- </a>
92
+ <% if discussion_issue_url %>
93
+ <a href="<%= discussion_issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
94
+ <i class="bi bi-box-arrow-up-right me-1"></i> <%= red_t("red.errors.discussion.start_on", provider: discussion_provider) %>
95
+ </a>
96
+ <% end %>
90
97
  </div>
91
98
  <div class="card-body text-center py-3">
92
99
  <p class="text-muted small mb-0"><%= red_t("red.errors.discussion.empty", provider: discussion_provider_lc) %></p>
@@ -106,7 +113,7 @@
106
113
  <div class="d-flex justify-content-between align-items-start">
107
114
  <div>
108
115
  <small class="text-primary fw-bold"><i class="bi bi-person-circle"></i> <%= comment.author_name %></small>
109
- <small class="text-muted ms-2"><%= comment.formatted_time %></small>
116
+ <small class="text-muted ms-2"><%= local_time(comment.created_at, format: :datetime) %></small>
110
117
  </div>
111
118
  </div>
112
119
  <small class="text-muted text-break"><%= simple_format(h(comment.body), {}, wrapper_tag: "span") %></small>
@@ -5,8 +5,10 @@
5
5
  <i class="bi bi-link-45deg me-2"></i>
6
6
  <%= red_t("red.errors.issue_section.title") %>
7
7
  </h5>
8
- <% if @error.external_issue_url.present? %>
9
- <a href="<%= @error.external_issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
8
+ <%# nil unless the stored value is plain http(s) — see safe_external_url. %>
9
+ <% issue_url = safe_external_url(@error.external_issue_url) %>
10
+ <% if issue_url %>
11
+ <a href="<%= issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
10
12
  <i class="bi bi-box-arrow-up-right me-1"></i> <%= red_t("red.errors.issue_section.view_issue") %>
11
13
  </a>
12
14
  <% end %>
@@ -23,6 +25,15 @@
23
25
  end %>
24
26
  <% issue = @platform_issue %>
25
27
 
28
+ <% unless issue_url %>
29
+ <%# Stored, but not plain http(s): shown as text, never as a link. %>
30
+ <div class="alert alert-warning py-2 px-3 mb-3" style="font-size: 0.85em;">
31
+ <i class="bi bi-exclamation-triangle me-1"></i>
32
+ <%= red_t("red.errors.issue_section.unsafe_link") %>
33
+ <code class="d-block text-break mt-1"><%= @error.external_issue_url.to_s.truncate(200) %></code>
34
+ </div>
35
+ <% end %>
36
+
26
37
  <!-- Issue badge + status -->
27
38
  <div class="d-flex align-items-center gap-2 mb-3">
28
39
  <span class="badge bg-success">
@@ -50,8 +61,8 @@
50
61
  <div class="d-flex align-items-center gap-2 flex-wrap">
51
62
  <% issue[:assignees].each do |assignee| %>
52
63
  <span class="d-inline-flex align-items-center gap-1 border rounded-pill px-2 py-1" style="font-size: 0.85em;">
53
- <% if assignee[:avatar_url].present? %>
54
- <img src="<%= assignee[:avatar_url] %>" width="18" height="18" class="rounded-circle">
64
+ <% if (avatar = safe_external_url(assignee[:avatar_url])) %>
65
+ <img src="<%= avatar %>" width="18" height="18" class="rounded-circle">
55
66
  <% end %>
56
67
  <%= assignee[:login] %>
57
68
  </span>
@@ -68,8 +79,9 @@
68
79
  <% if issue[:labels].present? && issue[:labels].any? %>
69
80
  <div class="d-flex align-items-center gap-1 flex-wrap">
70
81
  <% issue[:labels].each do |label| %>
71
- <% bg_color = label[:color].present? ? "##{label[:color]}" : "#6c757d" %>
72
- <% text_color = label[:color].present? && label[:color].scan(/../).map { |c| c.to_i(16) }.sum > 382 ? "#000" : "#fff" %>
82
+ <%# Third-party value going into style=: validated, never interpolated raw. %>
83
+ <% bg_color = safe_label_color(label[:color]) %>
84
+ <% text_color = label_text_color(bg_color) %>
73
85
  <span class="badge rounded-pill" style="background-color: <%= bg_color %>; color: <%= text_color %>; font-size: 0.78em;">
74
86
  <%= label[:name] %>
75
87
  </span>
@@ -118,10 +130,12 @@
118
130
  </div>
119
131
  </div>
120
132
 
121
- <% if flash[:new_issue_url].present? %>
133
+ <%# The URL comes from the forge's API response; window.open would run a
134
+ javascript: target, so it gets the same http(s) check as the links. %>
135
+ <% if (new_issue_url = safe_external_url(flash[:new_issue_url])) %>
122
136
  <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
123
137
  (function() {
124
- window.open('<%= j flash[:new_issue_url] %>', '_blank');
138
+ window.open(<%= js_safe_json(new_issue_url) %>, '_blank');
125
139
  })();
126
140
  </script>
127
141
  <% end %>
@@ -72,7 +72,7 @@
72
72
 
73
73
  <% if error.respond_to?(:environment) && error.environment.present? %>
74
74
  <div class="mb-3">
75
- <small class="metadata-label d-block mb-1"><%= red_t("red.errors.sidebar.environment") %></small>
75
+ <small class="metadata-label d-block mb-1"><%= red_t("red.errors.sidebar.environment.title") %></small>
76
76
  <%= link_to errors_path(environment: error.environment, unresolved: '0'), class: "text-decoration-none", title: red_t("red.errors.sidebar.view_all_environment", environment: error.environment) do %>
77
77
  <span class="badge badge-api" data-environment-badge="<%= error.environment %>"><i class="bi bi-hdd-stack"></i> <%= error.environment %></span>
78
78
  <% end %>
@@ -106,12 +106,21 @@
106
106
  The <a> is built here rather than living in the key. The key
107
107
  is _html and therefore html_safe, so a raw URL interpolated
108
108
  into an href inside it would not be escaped and a crafted
109
- external_issue_url could inject an event handler. link_to
110
- escapes the URL. %>
111
- <% if error.respond_to?(:external_issue_url) && error.external_issue_url.present? %>
109
+ external_issue_url could inject an event handler.
110
+
111
+ link_to escapes the URL, which stops attribute BREAKOUT. It
112
+ does not reject a scheme: link_to("x", "javascript:...")
113
+ is a working link. That protection comes from
114
+ safe_external_url, which returns nil for anything that is
115
+ not plain http(s). %>
116
+ <% stored_issue_url = error.respond_to?(:external_issue_url) ? error.external_issue_url : nil %>
117
+ <% sidebar_issue_url = safe_external_url(stored_issue_url) %>
118
+ <% if sidebar_issue_url %>
112
119
  <%= red_t("red.errors.sidebar.issue_tracking_linked_html",
113
120
  link: link_to(red_t("red.errors.sidebar.issue_tracking_link_text"),
114
- error.external_issue_url, target: "_blank", rel: "noopener")) %>
121
+ sidebar_issue_url, target: "_blank", rel: "noopener")) %>
122
+ <% elsif stored_issue_url.present? %>
123
+ <%= red_t("red.errors.issue_section.unsafe_link") %>
115
124
  <% else %>
116
125
  <%= red_t("red.errors.sidebar.issue_tracking_unlinked") %>
117
126
  <% end %>
@@ -504,7 +513,7 @@
504
513
  <% if health[:job_queue] %>
505
514
  <% jq = health[:job_queue] %>
506
515
  <div class="mb-1">
507
- <small class="text-muted">Job Queue (<%= jq[:adapter] %>):</small>
516
+ <small class="text-muted"><%= red_t("red.errors.sidebar.health.job_queue", adapter: jq[:adapter]) %></small>
508
517
  <code class="ms-1">
509
518
  <% case jq[:adapter] %>
510
519
  <% when "sidekiq" %>