rails_error_dashboard 0.14.3 → 0.15.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -17
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +68 -2
  5. data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
  6. data/app/helpers/rails_error_dashboard/application_helper.rb +10 -2
  7. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +56 -2
  8. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
  9. data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
  10. data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
  11. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
  12. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
  13. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +26 -0
  14. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
  15. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
  16. data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
  17. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
  18. data/app/models/rails_error_dashboard/error_log.rb +43 -12
  19. data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
  20. data/app/views/layouts/rails_error_dashboard.html.erb +24 -1
  21. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +3 -1
  22. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
  23. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
  24. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
  25. data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
  26. data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
  27. data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
  28. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
  29. data/app/views/rails_error_dashboard/errors/settings.html.erb +19 -5
  30. data/config/locales/de.yml +72 -5
  31. data/config/locales/en.yml +83 -4
  32. data/config/locales/es.yml +72 -5
  33. data/config/locales/fr.yml +72 -5
  34. data/config/locales/it.yml +72 -5
  35. data/config/locales/ja.yml +72 -4
  36. data/config/locales/pl.yml +72 -5
  37. data/config/locales/pt-BR.yml +72 -5
  38. data/config/locales/ru.yml +72 -5
  39. data/config/locales/uk.yml +72 -5
  40. data/config/locales/zh-CN.yml +72 -4
  41. data/config/routes.rb +6 -0
  42. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
  43. data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +9 -0
  44. data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
  45. data/lib/generators/rails_error_dashboard/install/install_generator.rb +40 -38
  46. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +31 -3
  47. data/lib/generators/rails_error_dashboard/uninstall/uninstall_generator.rb +11 -2
  48. data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
  49. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +9 -1
  50. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +103 -0
  51. data/lib/rails_error_dashboard/commands/log_error.rb +6 -3
  52. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
  53. data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
  54. data/lib/rails_error_dashboard/configuration.rb +75 -2
  55. data/lib/rails_error_dashboard/engine.rb +52 -5
  56. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +21 -43
  57. data/lib/rails_error_dashboard/plugin.rb +49 -3
  58. data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
  59. data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
  60. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
  61. data/lib/rails_error_dashboard/queries/errors_list.rb +11 -17
  62. data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
  63. data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
  64. data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
  65. data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
  66. data/lib/rails_error_dashboard/services/backtrace_processor.rb +15 -0
  67. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
  68. data/lib/rails_error_dashboard/services/crash_capture.rb +85 -2
  69. data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
  70. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
  71. data/lib/rails_error_dashboard/services/exception_filter.rb +4 -1
  72. data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -9
  73. data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
  74. data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
  75. data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
  76. data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
  77. data/lib/rails_error_dashboard/services/solid_queue_config_check.rb +47 -0
  78. data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
  79. data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
  80. data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
  81. data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
  82. data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
  83. data/lib/rails_error_dashboard/version.rb +1 -1
  84. data/lib/rails_error_dashboard.rb +12 -0
  85. data/lib/tasks/error_dashboard.rake +43 -2
  86. metadata +21 -2
@@ -44,7 +44,7 @@ module RailsErrorDashboard
44
44
  http.use_ssl = uri.scheme == "https"
45
45
  http.open_timeout = 5
46
46
  http.read_timeout = 10
47
- request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
47
+ request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
48
48
  request.body = payload.to_json
49
49
  http.request(request)
50
50
  end
@@ -70,6 +70,7 @@ module RailsErrorDashboard
70
70
  # errors — clean them up BEFORE the early return below, which fires
71
71
  # whenever no error logs happen to be expired.
72
72
  cleanup_rack_attack_events(cutoff)
73
+ cleanup_missing_translations(cutoff)
73
74
  cleanup_storm_flush_batches(cutoff)
74
75
  cleanup_diagnostic_dumps(cutoff)
75
76
  cleanup_swallowed_exceptions(cutoff)
@@ -211,6 +212,31 @@ module RailsErrorDashboard
211
212
  )
212
213
  end
213
214
 
215
+ # Expire missing-translation rows not seen since the cutoff. A key that
216
+ # is still missing is still being seen, so it keeps its row (and its
217
+ # first_seen_at); only keys that stopped missing age out.
218
+ #
219
+ # Not gated on the feature flag: rows collected while tracking was on
220
+ # must still age out after it is turned off.
221
+ def cleanup_missing_translations(cutoff)
222
+ return unless MissingTranslation.table_exists?
223
+
224
+ deleted = 0
225
+ MissingTranslation.where("last_seen_at < ?", cutoff).in_batches(of: 1000) do |batch|
226
+ deleted += batch.delete_all
227
+ end
228
+
229
+ if deleted > 0
230
+ RailsErrorDashboard::Logger.info(
231
+ "[RailsErrorDashboard] Retention cleanup: deleted #{deleted} missing translations"
232
+ )
233
+ end
234
+ rescue => e
235
+ RailsErrorDashboard::Logger.debug(
236
+ "[RailsErrorDashboard] Missing translation retention cleanup failed: #{e.class} - #{e.message}"
237
+ )
238
+ end
239
+
214
240
  # Expire aggregated Rack Attack event rows. Isolated in its own rescue so a
215
241
  # failure here (e.g. table not yet migrated) never blocks error cleanup.
216
242
  def cleanup_rack_attack_events(cutoff)
@@ -12,11 +12,16 @@ module RailsErrorDashboard
12
12
  class ScheduledDigestJob < ApplicationJob
13
13
  queue_as :default
14
14
 
15
+ # @param period [String, Symbol, nil] "daily" or "weekly"; nil takes
16
+ # config.digest_frequency, which was shown on the Settings page but
17
+ # read by nothing until now.
15
18
  # @param locale [String, nil] resolved at enqueue time. nil for jobs
16
19
  # enqueued by a pre-Phase-4 version still draining from the queue.
17
- def perform(period: "daily", application_id: nil, locale: nil)
20
+ def perform(period: nil, application_id: nil, locale: nil)
18
21
  return unless RailsErrorDashboard.configuration.enable_scheduled_digests
19
22
 
23
+ period ||= RailsErrorDashboard.configuration.digest_frequency || :daily
24
+
20
25
  recipients = effective_recipients
21
26
  return if recipients.blank?
22
27
 
@@ -32,7 +32,7 @@ module RailsErrorDashboard
32
32
  http.open_timeout = 5 # 5 seconds to establish connection
33
33
  http.read_timeout = 10 # 10 seconds to read response
34
34
 
35
- request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
35
+ request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
36
36
  request.body = Services::SlackPayloadBuilder.call(error_log, locale: locale).to_json
37
37
 
38
38
  response = http.request(request)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Job to send error notifications to a Telegram chat via the Bot API
5
+ class TelegramErrorNotificationJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ # @param locale [String, nil] resolved at enqueue time.
9
+ def perform(error_log_id, locale = nil)
10
+ return unless Services::TelegramDelivery.credentials?
11
+
12
+ error_log = ErrorLog.find(error_log_id)
13
+ payload = Services::TelegramPayloadBuilder.call(error_log, locale: job_locale(locale))
14
+
15
+ Services::TelegramDelivery.post(payload)
16
+ rescue StandardError => e
17
+ # The bot token is in the request URL; an exception that quotes it must
18
+ # not reach the log as-is.
19
+ Rails.logger.error(
20
+ "[RailsErrorDashboard] Failed to send Telegram notification: #{Services::TelegramDelivery.redact(e.message)}"
21
+ )
22
+ Rails.logger.error(e.backtrace&.first(5)&.join("\n")) if e.backtrace
23
+ end
24
+ end
25
+ end
@@ -31,13 +31,12 @@ module RailsErrorDashboard
31
31
 
32
32
  def send_webhook(url, payload, error_log)
33
33
  headers = {
34
- "Content-Type" => "application/json",
35
- "User-Agent" => "RailsErrorDashboard/1.0",
36
34
  "X-Error-Dashboard-Event" => "error.created",
37
35
  "X-Error-Dashboard-ID" => error_log.id.to_s
38
36
  }
39
37
 
40
- response = post_json(url, payload, headers)
38
+ # Serialises, signs (when webhook_signing_secret is set) and posts.
39
+ response = Services::WebhookDelivery.post(url, payload, headers: headers)
41
40
 
42
41
  unless response_success?(response)
43
42
  Rails.logger.warn("[RailsErrorDashboard] Webhook failed for #{url}: #{response_code(response)}")
@@ -46,21 +45,6 @@ module RailsErrorDashboard
46
45
  Rails.logger.error("[RailsErrorDashboard] Webhook error for #{url}: #{e.message}")
47
46
  end
48
47
 
49
- def post_json(url, payload, headers)
50
- if defined?(HTTParty)
51
- HTTParty.post(url, body: payload.to_json, headers: headers, timeout: 10)
52
- else
53
- uri = URI(url)
54
- http = Net::HTTP.new(uri.host, uri.port)
55
- http.use_ssl = uri.scheme == "https"
56
- http.open_timeout = 5
57
- http.read_timeout = 10
58
- request = Net::HTTP::Post.new(uri.path, headers)
59
- request.body = payload.to_json
60
- http.request(request)
61
- end
62
- end
63
-
64
48
  def response_success?(response)
65
49
  if response.respond_to?(:success?)
66
50
  response.success?
@@ -204,6 +204,13 @@ module RailsErrorDashboard
204
204
  Commands::LogError.call(exception, context)
205
205
  end
206
206
 
207
+ # nil rather than a wrong user: an integer column casts "3f2a9c1e-..." to 3
208
+ def self.storable_user_id(user_id)
209
+ return user_id unless type_for_attribute("user_id").type == :integer
210
+
211
+ Integer(user_id.to_s, 10, exception: false)
212
+ end
213
+
207
214
  # Mark error as resolved (delegates to Command)
208
215
  def resolve!(resolution_data = {})
209
216
  Commands::ResolveError.call(id, resolution_data)
@@ -415,13 +422,8 @@ module RailsErrorDashboard
415
422
  return {} unless RailsErrorDashboard.configuration.enable_occurrence_patterns
416
423
  return {} unless defined?(Services::PatternDetector)
417
424
 
418
- timestamps = self.class
419
- .where(error_type: error_type, platform: platform)
420
- .where("occurred_at >= ?", days.days.ago)
421
- .pluck(:occurred_at)
422
-
423
425
  Services::PatternDetector.analyze_cyclical_pattern(
424
- timestamps: timestamps,
426
+ timestamps: pattern_timestamps(days: days),
425
427
  days: days
426
428
  )
427
429
  end
@@ -433,13 +435,42 @@ module RailsErrorDashboard
433
435
  return [] unless RailsErrorDashboard.configuration.enable_occurrence_patterns
434
436
  return [] unless defined?(Services::PatternDetector)
435
437
 
436
- timestamps = self.class
437
- .where(error_type: error_type, platform: platform)
438
- .where("occurred_at >= ?", days.days.ago)
439
- .order(:occurred_at)
440
- .pluck(:occurred_at)
438
+ Services::PatternDetector.detect_bursts(timestamps: pattern_timestamps(days: days))
439
+ end
440
+
441
+ # The most recent occurrence timestamps a Pattern Insights sample reads.
442
+ # A grouped row holds one occurred_at for hundreds of events, so the
443
+ # pattern used to be computed from one timestamp per error: the per-event
444
+ # rows are what a daily or weekly rhythm is visible in. Capped so an error
445
+ # with a million occurrences does not load a million Time objects to
446
+ # bucket them by hour; the newest PATTERN_SAMPLE_LIMIT are enough for a
447
+ # distribution and for the bursts of the last week.
448
+ PATTERN_SAMPLE_LIMIT = 10_000
449
+
450
+ def pattern_timestamps(days:)
451
+ since = days.days.ago
452
+ grouped = self.class.where(error_type: error_type, platform: platform)
453
+
454
+ if occurrence_rows_available?
455
+ timestamps = ErrorOccurrence.where(error_log_id: grouped.select(:id))
456
+ .where("occurred_at >= ?", since)
457
+ .order(occurred_at: :desc)
458
+ .limit(PATTERN_SAMPLE_LIMIT)
459
+ .pluck(:occurred_at)
460
+ .reverse
461
+ return timestamps if timestamps.any?
462
+ end
463
+
464
+ # No per-event rows: an install from before the occurrences table, or
465
+ # rows captured while a storm was being shed. The grouped rows are the
466
+ # only timestamps there are.
467
+ grouped.where("occurred_at >= ?", since).order(:occurred_at).pluck(:occurred_at)
468
+ end
441
469
 
442
- Services::PatternDetector.detect_bursts(timestamps: timestamps)
470
+ def occurrence_rows_available?
471
+ defined?(ErrorOccurrence) && ErrorOccurrence.table_exists?
472
+ rescue StandardError
473
+ false
443
474
  end
444
475
 
445
476
  private
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # A translation key the host app looked up and did not have, counted per
5
+ # (locale, key, application).
6
+ #
7
+ # Rows are written by Commands::FlushMissingTranslations from the tracker's
8
+ # in-memory buffer, never one per lookup. Inherits ErrorLogsRecord so
9
+ # separate-database routing applies.
10
+ class MissingTranslation < ErrorLogsRecord
11
+ self.table_name = "rails_error_dashboard_missing_translations"
12
+
13
+ # Not a translation key: a synthetic row holding the misses the tracker
14
+ # dropped because its per-thread buffer was full, kept so the totals the
15
+ # dashboard shows are never silently understated. Stored under a locale
16
+ # no real lookup uses. The tracker owns the values; aliased here so the
17
+ # writer and the scopes below cannot drift apart.
18
+ OVERFLOW_KEY = Services::MissingTranslationTracker::OVERFLOW_KEY
19
+ OVERFLOW_LOCALE = Services::MissingTranslationTracker::OVERFLOW_LOCALE
20
+
21
+ belongs_to :application, optional: true
22
+
23
+ validates :locale, presence: true
24
+ validates :translation_key, presence: true
25
+ validates :miss_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
26
+
27
+ scope :for_application, ->(app_id) { where(application_id: app_id) }
28
+ scope :seen_since, ->(time) { where("last_seen_at >= ?", time) }
29
+ scope :overflow, -> { where(translation_key: OVERFLOW_KEY) }
30
+ scope :real, -> { where.not(translation_key: OVERFLOW_KEY) }
31
+
32
+ # The table may not be migrated yet on a host that upgraded the gem before
33
+ # running the migration. Everything that touches this model on a request
34
+ # path checks first and must never raise.
35
+ def self.table_exists?
36
+ connection.table_exists?(table_name)
37
+ rescue *Commands::LogError::RETRYABLE_STORE_ERRORS
38
+ raise
39
+ rescue StandardError
40
+ false
41
+ end
42
+ end
43
+ end
@@ -1156,6 +1156,12 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1156
1156
  <% if RailsErrorDashboard.configuration.enable_storm_protection %>
1157
1157
  <% diag_items << { path: storms_errors_path(nav_params), icon: 'bi-cloud-lightning-rain', label: red_t("red.nav.storms") } %>
1158
1158
  <% end %>
1159
+ <% if RailsErrorDashboard.configuration.enable_missing_translation_tracking %>
1160
+ <% diag_items << { path: missing_translations_errors_path(nav_params), icon: 'bi-translate', label: red_t("red.nav.missing_translations") } %>
1161
+ <% end %>
1162
+ <% if RailsErrorDashboard.configuration.enable_crash_capture %>
1163
+ <% diag_items << { path: boot_errors_errors_path(nav_params), icon: 'bi-power', label: red_t("red.nav.boot_errors") } %>
1164
+ <% end %>
1159
1165
 
1160
1166
  <% if diag_items.any? %>
1161
1167
  <div style="margin-bottom: var(--space-2);" id="navDiagSection">
@@ -1335,6 +1341,19 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1335
1341
  </div>
1336
1342
  <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>try { if (sessionStorage.getItem('red_dismiss_creds_warning') === '1') { document.getElementById('security-warning').style.display = 'none'; } } catch(e) {}</script>
1337
1343
  <% end %>
1344
+ <% solid_queue_problems = red_solid_queue_problems %>
1345
+ <% if solid_queue_problems.any? %>
1346
+ <div id="solid-queue-warning" class="alert alert-warning" style="display: flex; align-items: flex-start; gap: 10px; margin-top: var(--space-2); margin-bottom: var(--space-4); border-left: 4px solid var(--status-warning);">
1347
+ <i class="bi bi-exclamation-triangle-fill" style="font-size: 18px; flex-shrink: 0;"></i>
1348
+ <div style="flex: 1;">
1349
+ <strong><%= red_t("red.banners.solid_queue.label") %></strong> <%= red_t("red.banners.solid_queue.body_html") %>
1350
+ <ul style="margin: var(--space-2) 0; padding-left: var(--space-5); font-family: var(--font-mono); font-size: 12px;">
1351
+ <% solid_queue_problems.each do |problem| %><li><%= problem %></li><% end %>
1352
+ </ul>
1353
+ <a href="<%= RailsErrorDashboard::Services::SolidQueueConfigCheck::GUIDE_URL %>" target="_blank" rel="noopener"><%= red_t("red.banners.solid_queue.guide") %></a>
1354
+ </div>
1355
+ </div>
1356
+ <% end %>
1338
1357
  <% if defined?(@storm_banner_event) && @storm_banner_event %>
1339
1358
  <% storm_active = @storm_banner_event.active? %>
1340
1359
  <div id="storm-banner" class="alert <%= storm_active ? 'alert-danger' : 'alert-info' %>" style="display: flex; align-items: center; gap: 10px; margin-top: var(--space-2); margin-bottom: var(--space-4); border-left: 4px solid <%= storm_active ? 'var(--status-critical)' : 'var(--status-info)' %>;">
@@ -1539,7 +1558,11 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1539
1558
  });
1540
1559
 
1541
1560
  if (typeof hljs.lineNumbersBlock === 'function') {
1542
- codeBlocks.forEach(function(block) { hljs.lineNumbersBlock(block); });
1561
+ // The gutter shows the file's own line numbers: the excerpt starts at
1562
+ // data-start-line, not at 1.
1563
+ codeBlocks.forEach(function(block) {
1564
+ hljs.lineNumbersBlock(block, { startFrom: parseInt(block.dataset.startLine) || 1 });
1565
+ });
1543
1566
  } else if (typeof hljs.initLineNumbersOnLoad === 'function') {
1544
1567
  hljs.initLineNumbersOnLoad();
1545
1568
  }
@@ -1,7 +1,9 @@
1
1
  <%# The locale is part of the cache key: this fragment now contains
2
2
  translated text, and without it the first locale to render an error
3
3
  would be served to every other one. %>
4
- <% cache [error, 'error_details_v2', red_locale] do %>
4
+ <%# Plus the source-integration configuration and a TTL, and not at all
5
+ while coverage is live: see BacktraceHelper#source_view_cache_key. %>
6
+ <% cache_if source_view_cacheable?, [error, 'error_details_v3', red_locale, source_view_cache_key], **source_view_cache_options do %>
5
7
  <div class="card mb-4" id="section-error-info">
6
8
  <div class="card-header bg-danger text-white">
7
9
  <div class="d-flex justify-content-between align-items-center">
@@ -187,8 +187,10 @@
187
187
  </small>
188
188
  <% end %>
189
189
  </div>
190
+ <%# data-red-action, not data-turbo-confirm: the layout runs with
191
+ Turbo off and loads no UJS, so nothing read the old attribute. %>
190
192
  <%= button_to unassign_error_path(error), method: :post, class: "btn btn-sm btn-outline-secondary",
191
- data: { turbo_confirm: red_t("red.errors.sidebar.remove_assignment") } do %>
193
+ form: { data: { red_action: "confirm-submit", red_confirm_message: red_t("red.errors.sidebar.remove_assignment") } } do %>
192
194
  <i class="bi bi-x"></i>
193
195
  <% end %>
194
196
  </div>
@@ -20,17 +20,11 @@
20
20
  </div>
21
21
 
22
22
  <% if repo_link %>
23
- <%
24
- # Repository host names are brands and stay English; only the
25
- # sentence around the name is translated.
26
- repo_host = if repo_link.include?("github") then "GitHub"
27
- elsif repo_link.include?("gitlab") then "GitLab"
28
- else "Bitbucket"
29
- end
30
- %>
23
+ <%# Repository host names are brands and stay English; only the
24
+ sentence around the name is translated. %>
31
25
  <%= link_to repo_link, target: "_blank", rel: "noopener",
32
26
  class: "btn btn-sm btn-primary",
33
- title: red_t("red.errors.source_code.view_on", host: repo_host) do %>
27
+ title: red_t("red.errors.source_code.view_on", host: repository_host_label(repo_link)) do %>
34
28
  <i class="bi bi-box-arrow-up-right"></i> <%= red_t("red.errors.source_code.view_source") %>
35
29
  <% end %>
36
30
  <% end %>
@@ -20,6 +20,34 @@
20
20
  </div>
21
21
  </div>
22
22
 
23
+ <% probe = @storage_probe || {} %>
24
+ <% if probe[:status] && probe[:status] != "skipped" %>
25
+ <% reachable = probe[:status] == "ok" %>
26
+ <div class="card mb-4 border-<%= reachable ? "success" : "danger" %>">
27
+ <div class="card-body d-flex justify-content-between align-items-center flex-wrap gap-2">
28
+ <div>
29
+ <h6 class="mb-1">
30
+ <i class="bi <%= reachable ? "bi-check-circle-fill text-success" : "bi-x-circle-fill text-danger" %> me-1"></i>
31
+ <%= red_t("red.health.activestorage.probe.title") %>
32
+ <span class="badge <%= reachable ? "bg-success" : "bg-danger" %> ms-1">
33
+ <%= reachable ? red_t("red.health.activestorage.probe.reachable") : red_t("red.health.activestorage.probe.unreachable") %>
34
+ </span>
35
+ </h6>
36
+ <small class="text-muted">
37
+ <%# The service name comes from config/storage.yml and the error is an exception class: machine values, shown as-is. %>
38
+ <%= red_t("red.health.activestorage.probe.service", name: probe[:service].presence || "?") %>
39
+ <% if reachable %>
40
+ &middot; <%= red_t("red.health.activestorage.probe.latency", ms: probe[:latency_ms]) %>
41
+ <% else %>
42
+ &middot; <code><%= probe[:error] %></code>
43
+ <% end %>
44
+ </small>
45
+ </div>
46
+ <small class="text-muted"><%= red_t("red.health.activestorage.probe.how") %></small>
47
+ </div>
48
+ </div>
49
+ <% end %>
50
+
23
51
  <% if @unique_services == 0 %>
24
52
  <div class="red-empty-state">
25
53
  <i class="bi bi-cloud-arrow-up display-1 text-success mb-3"></i>
@@ -0,0 +1,130 @@
1
+ <% content_for :page_title, red_t("red.errors.boot_errors_page.title") %>
2
+
3
+ <div>
4
+ <div class="d-flex justify-content-between align-items-center mb-4">
5
+ <h1 style="font-size: 20px; font-weight: 700; margin: 0;">
6
+ <i class="bi bi-power me-2"></i>
7
+ <%= red_t("red.errors.boot_errors_page.title") %>
8
+ </h1>
9
+
10
+ <div class="btn-group" role="group">
11
+ <%= link_to boot_errors_errors_path(days: 7), class: "btn btn-sm #{@days == 7 ? 'btn-primary' : 'btn-outline-primary'}" do %>
12
+ <%= red_t("red.common.days_filter.7") %>
13
+ <% end %>
14
+ <%= link_to boot_errors_errors_path(days: 30), class: "btn btn-sm #{@days == 30 ? 'btn-primary' : 'btn-outline-primary'}" do %>
15
+ <%= red_t("red.common.days_filter.30") %>
16
+ <% end %>
17
+ <%= link_to boot_errors_errors_path(days: 90), class: "btn btn-sm #{@days == 90 ? 'btn-primary' : 'btn-outline-primary'}" do %>
18
+ <%= red_t("red.common.days_filter.90") %>
19
+ <% end %>
20
+ </div>
21
+ </div>
22
+
23
+ <% if @entry_count == 0 %>
24
+ <div class="red-empty-state">
25
+ <i class="bi bi-power display-1 text-success mb-3"></i>
26
+ <div class="red-empty-state-title"><%= red_t("red.errors.boot_errors_page.empty_title") %></div>
27
+ <p class="text-muted">
28
+ <%= red_t("red.errors.boot_errors_page.empty_body", days: @days) %>
29
+ </p>
30
+ <div class="card mx-auto" style="max-width: 560px;">
31
+ <div class="card-body text-start">
32
+ <h6><%= red_t("red.errors.boot_errors_page.how_title") %></h6>
33
+ <ul class="mb-0">
34
+ <li><%= red_t("red.errors.boot_errors_page.how_enabled_html", option: content_tag(:code, "enable_crash_capture = true")) %></li>
35
+ <li><%= red_t("red.errors.boot_errors_page.how_hooked") %></li>
36
+ <li><%= red_t("red.errors.boot_errors_page.how_captured_html", zeitwerk: content_tag(:code, "Zeitwerk::NameError"), syntax: content_tag(:code, "SyntaxError")) %></li>
37
+ <li><%= red_t("red.errors.boot_errors_page.how_imported") %></li>
38
+ <li><%= red_t("red.errors.boot_errors_page.how_never") %></li>
39
+ </ul>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ <% else %>
44
+ <div class="row mb-4">
45
+ <div class="col-md-4">
46
+ <div class="card text-center">
47
+ <div class="card-body">
48
+ <div class="display-6 text-danger"><%= @unresolved_count %></div>
49
+ <small class="text-muted"><%= red_t("red.errors.boot_errors_page.unresolved") %></small>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ <div class="col-md-4">
54
+ <div class="card text-center">
55
+ <div class="card-body">
56
+ <div class="display-6 text-warning"><%= @entry_count %></div>
57
+ <small class="text-muted"><%= red_t("red.errors.boot_errors_page.boot_crashes") %></small>
58
+ </div>
59
+ </div>
60
+ </div>
61
+ <div class="col-md-4">
62
+ <div class="card text-center">
63
+ <div class="card-body">
64
+ <div class="display-6 text-info"><%= @total_occurrences %></div>
65
+ <small class="text-muted"><%= red_t("red.errors.boot_errors_page.failed_boots") %></small>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+
71
+ <div class="card mb-4">
72
+ <div class="card-header d-flex justify-content-between align-items-center">
73
+ <h5 class="mb-0">
74
+ <i class="bi bi-power text-danger me-2"></i>
75
+ <%= red_t("red.errors.boot_errors_page.all_title") %>
76
+ <span class="badge bg-danger"><%= @entry_count %></span>
77
+ </h5>
78
+ <small class="text-muted"><%== @pagy.info_tag %></small>
79
+ </div>
80
+ <div class="card-body p-0">
81
+ <div class="table-responsive">
82
+ <table class="table table-hover mb-0">
83
+ <thead class="table-light">
84
+ <tr>
85
+ <th><%= red_t("red.errors.boot_errors_page.column_error") %></th>
86
+ <th><%= red_t("red.errors.boot_errors_page.column_file") %></th>
87
+ <th width="140"><%= red_t("red.errors.boot_errors_page.column_constant") %></th>
88
+ <th width="90"><%= red_t("red.errors.boot_errors_page.column_boots") %></th>
89
+ <th width="140"><%= red_t("red.errors.boot_errors_page.column_first_seen") %></th>
90
+ <th width="140"><%= red_t("red.errors.pages.column_last_seen") %></th>
91
+ <th width="100"><%= red_t("red.errors.boot_errors_page.column_status") %></th>
92
+ </tr>
93
+ </thead>
94
+ <tbody>
95
+ <% @entries.each do |entry| %>
96
+ <tr>
97
+ <td>
98
+ <%# Exception class and message are machine values from the crash file, shown as-is. %>
99
+ <%= link_to entry[:error_type], error_path(entry[:id]), class: "fw-semibold text-decoration-none" %>
100
+ <div class="text-muted small text-truncate" style="max-width: 420px;" title="<%= entry[:message] %>"><%= entry[:message] %></div>
101
+ </td>
102
+ <td><% if entry[:file].present? %><code><%= entry[:file] %></code><% else %><span class="text-muted">&mdash;</span><% end %></td>
103
+ <td><% if entry[:constant].present? %><code><%= entry[:constant] %></code><% else %><span class="text-muted">&mdash;</span><% end %></td>
104
+ <td><strong><%= entry[:count] %></strong></td>
105
+ <td><%= local_time_ago(entry[:first_seen]) %></td>
106
+ <td><%= local_time_ago(entry[:last_seen]) %></td>
107
+ <td>
108
+ <% if entry[:resolved] %>
109
+ <span class="badge bg-success"><%= red_t("red.errors.boot_errors_page.status_resolved") %></span>
110
+ <% else %>
111
+ <span class="badge bg-danger"><%= red_t("red.errors.boot_errors_page.status_open") %></span>
112
+ <% end %>
113
+ </td>
114
+ </tr>
115
+ <% end %>
116
+ </tbody>
117
+ </table>
118
+ </div>
119
+ </div>
120
+ <div class="card-footer border-top d-flex justify-content-between align-items-center">
121
+ <small class="text-muted">
122
+ <i class="bi bi-lightbulb text-warning"></i> <%= red_t("red.errors.boot_errors_page.footer_tip") %>
123
+ </small>
124
+ <div>
125
+ <%== @pagy.series_nav(:bootstrap) if @pagy.pages > 1 %>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ <% end %>
130
+ </div>
@@ -272,7 +272,7 @@
272
272
  <%= button_tag type: "submit", name: "action_type", value: "resolve", class: "btn btn-sm", style: "padding: 4px 12px; font-size: 12px;" do %>
273
273
  <i class="bi bi-check-circle"></i> <%= red_t("red.errors.index.batch.resolve") %>
274
274
  <% end %>
275
- <%= button_tag type: "submit", name: "action_type", value: "delete", class: "btn btn-sm", style: "padding: 4px 12px; font-size: 12px; color: var(--status-critical);", data: { confirm: red_t("red.errors.index.batch.confirm_delete") } do %>
275
+ <%= button_tag type: "submit", name: "action_type", value: "delete", class: "btn btn-sm", style: "padding: 4px 12px; font-size: 12px; color: var(--status-critical);", data: { red_action: "confirm-submit", red_confirm_message: red_t("red.errors.index.batch.confirm_delete") } do %>
276
276
  <i class="bi bi-trash"></i> <%= red_t("red.errors.index.batch.delete") %>
277
277
  <% end %>
278
278
  <% end %>