rails_error_dashboard 0.14.4 → 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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +12 -5
  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 +2 -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 +11 -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 +68 -5
  31. data/config/locales/en.yml +78 -4
  32. data/config/locales/es.yml +68 -5
  33. data/config/locales/fr.yml +68 -5
  34. data/config/locales/it.yml +68 -5
  35. data/config/locales/ja.yml +68 -4
  36. data/config/locales/pl.yml +68 -5
  37. data/config/locales/pt-BR.yml +68 -5
  38. data/config/locales/ru.yml +68 -5
  39. data/config/locales/uk.yml +68 -5
  40. data/config/locales/zh-CN.yml +68 -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/20261010000001_create_missing_translations.rb +50 -0
  44. data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
  45. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +27 -0
  46. data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
  47. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +9 -1
  48. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +103 -0
  49. data/lib/rails_error_dashboard/commands/log_error.rb +1 -1
  50. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
  51. data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
  52. data/lib/rails_error_dashboard/configuration.rb +42 -1
  53. data/lib/rails_error_dashboard/engine.rb +42 -1
  54. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
  55. data/lib/rails_error_dashboard/plugin.rb +19 -3
  56. data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
  57. data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
  58. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
  59. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
  60. data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
  61. data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
  62. data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
  63. data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
  64. data/lib/rails_error_dashboard/services/backtrace_processor.rb +15 -0
  65. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
  66. data/lib/rails_error_dashboard/services/crash_capture.rb +85 -2
  67. data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
  68. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
  69. data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -9
  70. data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
  71. data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
  72. data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
  73. data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
  74. data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
  75. data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
  76. data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
  77. data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
  78. data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
  79. data/lib/rails_error_dashboard/version.rb +1 -1
  80. data/lib/rails_error_dashboard.rb +12 -0
  81. metadata +21 -2
@@ -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">
@@ -1552,7 +1558,11 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1552
1558
  });
1553
1559
 
1554
1560
  if (typeof hljs.lineNumbersBlock === 'function') {
1555
- 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
+ });
1556
1566
  } else if (typeof hljs.initLineNumbersOnLoad === 'function') {
1557
1567
  hljs.initLineNumbersOnLoad();
1558
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 %>
@@ -0,0 +1,143 @@
1
+ <% content_for :page_title, red_t("red.errors.missing_translations_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-translate me-2"></i>
7
+ <%= red_t("red.errors.missing_translations_page.title") %>
8
+ </h1>
9
+
10
+ <div class="btn-group" role="group">
11
+ <%= link_to missing_translations_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 missing_translations_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 missing_translations_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-translate display-1 text-success mb-3"></i>
26
+ <div class="red-empty-state-title"><%= red_t("red.errors.missing_translations_page.empty_title") %></div>
27
+ <p class="text-muted">
28
+ <%= red_t("red.errors.missing_translations_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.missing_translations_page.how_title") %></h6>
33
+ <ul class="mb-0">
34
+ <li><%= red_t("red.errors.missing_translations_page.how_enabled_html", option: content_tag(:code, "enable_missing_translation_tracking = true")) %></li>
35
+ <li><%= red_t("red.errors.missing_translations_page.how_hooked") %></li>
36
+ <li><%= red_t("red.errors.missing_translations_page.how_recorded") %></li>
37
+ <li><%= red_t("red.errors.missing_translations_page.how_not_seen") %></li>
38
+ <li><%= red_t("red.errors.missing_translations_page.how_buffered") %></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-warning"><%= @unique_keys %></div>
49
+ <small class="text-muted"><%= red_t("red.errors.missing_translations_page.missing_keys") %></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-danger"><%= @total_misses %></div>
57
+ <small class="text-muted"><%= red_t("red.errors.missing_translations_page.total_misses") %></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"><%= @locale_count %></div>
65
+ <small class="text-muted"><%= red_t("red.errors.missing_translations_page.locales_affected") %></small>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+
71
+ <% if @overflow_count.to_i.positive? %>
72
+ <div class="alert alert-warning d-flex align-items-start" role="alert">
73
+ <i class="bi bi-exclamation-triangle me-2"></i>
74
+ <div>
75
+ <%= red_t("red.errors.missing_translations_page.overflow_notice",
76
+ count: @overflow_count,
77
+ limit: RailsErrorDashboard::Services::MissingTranslationTracker::MAX_BUFFERED_KEYS) %>
78
+ </div>
79
+ </div>
80
+ <% end %>
81
+
82
+ <div class="card mb-4">
83
+ <div class="card-header d-flex justify-content-between align-items-center">
84
+ <h5 class="mb-0">
85
+ <i class="bi bi-translate text-warning me-2"></i>
86
+ <%= red_t("red.errors.missing_translations_page.all_title") %>
87
+ <span class="badge bg-warning text-dark"><%= @entry_count %></span>
88
+ </h5>
89
+ <small class="text-muted"><%== @pagy.info_tag %></small>
90
+ </div>
91
+ <div class="card-body p-0">
92
+ <div class="table-responsive">
93
+ <table class="table table-hover mb-0">
94
+ <thead class="table-light">
95
+ <tr>
96
+ <th><%= red_t("red.errors.missing_translations_page.column_key") %></th>
97
+ <th width="90"><%= red_t("red.errors.missing_translations_page.column_locale") %></th>
98
+ <th width="90"><%= red_t("red.errors.missing_translations_page.column_misses") %></th>
99
+ <th><%= red_t("red.errors.missing_translations_page.column_source") %></th>
100
+ <th width="140"><%= red_t("red.errors.missing_translations_page.column_first_seen") %></th>
101
+ <th width="140"><%= red_t("red.errors.pages.column_last_seen") %></th>
102
+ </tr>
103
+ </thead>
104
+ <tbody>
105
+ <% @entries.each do |entry| %>
106
+ <tr>
107
+ <td><code><%= entry[:key] %></code></td>
108
+ <%# A locale tag is a machine value, shown as-is. %>
109
+ <td><span class="badge bg-light text-dark"><%= entry[:locale] %></span></td>
110
+ <td><strong><%= entry[:count] %></strong></td>
111
+ <td>
112
+ <% if entry[:source].present? %>
113
+ <code><%= entry[:source] %></code>
114
+ <% else %>
115
+ <span class="text-muted">&mdash;</span>
116
+ <% end %>
117
+ </td>
118
+ <td><%= local_time_ago(entry[:first_seen]) %></td>
119
+ <td><%= local_time_ago(entry[:last_seen]) %></td>
120
+ </tr>
121
+ <% end %>
122
+ </tbody>
123
+ </table>
124
+ </div>
125
+ </div>
126
+ <div class="card-footer border-top d-flex justify-content-between align-items-center">
127
+ <div>
128
+ <small class="text-muted">
129
+ <i class="bi bi-lightbulb text-warning"></i> <%= red_t("red.errors.missing_translations_page.footer_tip") %>
130
+ </small>
131
+ <small class="ms-3">
132
+ <a href="https://guides.rubyonrails.org/i18n.html" target="_blank" rel="noopener" class="text-decoration-none">
133
+ <i class="bi bi-book"></i> <%= red_t("red.errors.missing_translations_page.docs") %> <i class="bi bi-box-arrow-up-right" style="font-size: 0.7em;"></i>
134
+ </a>
135
+ </small>
136
+ </div>
137
+ <div>
138
+ <%== @pagy.series_nav(:bootstrap) if @pagy.pages > 1 %>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ <% end %>
143
+ </div>
@@ -265,16 +265,19 @@
265
265
  end
266
266
 
267
267
  when :retention_days
268
- # Show retention configuration with manual cleanup instructions
268
+ # Retention is enforced by RetentionCleanupJob, which the host schedules
269
+ # (docs/PRODUCTION.md). The badge used to claim "manual cleanup only" and
270
+ # cite cleanup_resolved, a different task that deletes only resolved
271
+ # errors and ignores this setting.
269
272
  if value.present?
270
273
  %>
271
274
  <div>
272
275
  <span class="badge bg-warning text-dark fs-6"><%= red_tp("red.settings.values.days", count: value) %></span>
273
- <small class="text-danger d-block">
274
- <i class="bi bi-exclamation-triangle"></i> <%= red_t("red.settings.values.manual_cleanup_required") %>
276
+ <small class="text-muted d-block">
277
+ <i class="bi bi-clock-history"></i> <%= red_t("red.settings.values.retention_enforced_by_job") %>
275
278
  </small>
276
279
  <small class="text-muted d-block mt-1">
277
- <%= red_t("red.settings.values.run_command") %> <code class="small">rails error_dashboard:cleanup_resolved DAYS=<%= value %></code>
280
+ <%= red_t("red.settings.values.run_command") %> <code class="small">RailsErrorDashboard::RetentionCleanupJob.perform_now</code>
278
281
  </small>
279
282
  </div>
280
283
  <%
@@ -56,10 +56,15 @@
56
56
  notification_email_from: { type: :string, show_if: :enable_email_notifications },
57
57
  enable_discord_notifications: { type: :boolean },
58
58
  discord_webhook_url: { type: :url, show_if: :enable_discord_notifications },
59
+ enable_telegram_notifications: { type: :boolean },
60
+ telegram_bot_token: { type: :secret, show_if: :enable_telegram_notifications },
61
+ telegram_chat_id: { type: :string, show_if: :enable_telegram_notifications },
62
+ telegram_api_base_url: { type: :url, show_if: :enable_telegram_notifications },
59
63
  enable_pagerduty_notifications: { type: :boolean },
60
64
  pagerduty_integration_key: { type: :string, show_if: :enable_pagerduty_notifications },
61
65
  enable_webhook_notifications: { type: :boolean },
62
66
  webhook_urls: { type: :array, show_if: :enable_webhook_notifications },
67
+ webhook_signing_secret: { type: :secret, show_if: :enable_webhook_notifications },
63
68
  enable_scheduled_digests: { type: :boolean },
64
69
  digest_frequency: { type: :symbol, show_if: :enable_scheduled_digests },
65
70
  digest_recipients: { type: :array, show_if: :enable_scheduled_digests }
@@ -100,7 +105,7 @@
100
105
  enable_breadcrumbs: { type: :boolean },
101
106
  breadcrumb_buffer_size: { type: :integer, show_if: :enable_breadcrumbs, unit: :events },
102
107
  enable_n_plus_one_detection: { type: :boolean, show_if: :enable_breadcrumbs },
103
- n_plus_one_threshold: { type: :integer, show_if: :enable_n_plus_one_detection, unit: :queries }
108
+ n_plus_one_threshold: { type: :integer, show_if: [ :enable_breadcrumbs, :enable_n_plus_one_detection ], unit: :queries }
104
109
  }
105
110
  },
106
111
  :system_health => {
@@ -149,6 +154,7 @@
149
154
  color: "info",
150
155
  settings: {
151
156
  enable_rack_attack_tracking: { type: :boolean },
157
+ enable_missing_translation_tracking: { type: :boolean },
152
158
  enable_actioncable_tracking: { type: :boolean },
153
159
  enable_activestorage_tracking: { type: :boolean }
154
160
  }
@@ -166,7 +172,9 @@
166
172
  color: "light",
167
173
  settings: {
168
174
  enable_internal_logging: { type: :boolean },
169
- log_level: { type: :symbol, show_if: :enable_internal_logging }
175
+ # Not gated on enable_internal_logging: Logger.error honours log_level
176
+ # whether or not internal logging is on.
177
+ log_level: { type: :symbol }
170
178
  }
171
179
  }
172
180
  }
@@ -198,16 +206,22 @@
198
206
  <div>
199
207
  <% group_config[:settings].each do |setting_key, setting_meta| %>
200
208
  <%
201
- show_if_key = setting_meta[:show_if]
202
- should_show = show_if_key.nil? || @config.send(show_if_key)
209
+ # One flag, or every flag in an array, must be on for the row to show.
210
+ should_show = Array(setting_meta[:show_if]).all? { |flag| @config.send(flag) }
203
211
  next unless should_show
204
212
  value = @config.send(setting_meta[:method] || setting_key)
213
+ # A credential is shown as Set / Not set whatever shape it has. The
214
+ # per-row :type documents the value's shape; this one rule is what
215
+ # keeps a webhook URL or an API key off the page, the same list
216
+ # Configuration#inspect masks, so a new secret cannot leak by a
217
+ # forgotten per-row type.
218
+ badge_type = RailsErrorDashboard::Configuration::SECRET_ATTRIBUTES.include?(setting_key) ? :secret : setting_meta[:type]
205
219
  %>
206
220
  <div style="display: flex; align-items: center; gap: var(--space-4); padding: var(--space-3) var(--space-6); border-bottom: 1px solid var(--border-primary); font-size: 13px;">
207
221
  <span style="font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 500; width: 240px; flex-shrink: 0;"><%= setting_key %></span>
208
222
  <span style="font-family: var(--font-mono); font-size: 12px; color: var(--text-primary); font-weight: 500; width: 180px; flex-shrink: 0;">
209
223
  <%= render partial: 'rails_error_dashboard/errors/settings/value_badge',
210
- locals: { value: value, type: setting_meta[:type], unit: setting_meta[:unit] } %>
224
+ locals: { value: value, type: badge_type, unit: setting_meta[:unit] } %>
211
225
  </span>
212
226
  <%# Keyed by the config option's own name, so a key is greppable
213
227
  from the initializer that sets it. %>