rails_error_dashboard 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -0
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +156 -0
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +95 -73
  5. data/app/controllers/rails_error_dashboard/locales_controller.rb +87 -0
  6. data/app/helpers/rails_error_dashboard/application_helper.rb +20 -14
  7. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +15 -6
  8. data/app/helpers/rails_error_dashboard/i18n_helper.rb +154 -0
  9. data/app/helpers/rails_error_dashboard/mailer_i18n_helper.rb +69 -0
  10. data/app/helpers/rails_error_dashboard/overview_helper.rb +18 -6
  11. data/app/helpers/rails_error_dashboard/user_agent_helper.rb +25 -11
  12. data/app/jobs/rails_error_dashboard/application_job.rb +5 -0
  13. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +18 -16
  14. data/app/jobs/rails_error_dashboard/concerns/localized_job.rb +83 -0
  15. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +4 -2
  16. data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +4 -2
  17. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +6 -2
  18. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +8 -3
  19. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +6 -4
  20. data/app/jobs/rails_error_dashboard/storm_notification_job.rb +26 -8
  21. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +4 -2
  22. data/app/mailers/rails_error_dashboard/application_mailer.rb +14 -0
  23. data/app/mailers/rails_error_dashboard/digest_mailer.rb +18 -2
  24. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +19 -2
  25. data/app/views/layouts/rails_error_dashboard.html.erb +255 -92
  26. data/app/views/rails_error_dashboard/digest_mailer/digest_summary.html.erb +23 -22
  27. data/app/views/rails_error_dashboard/digest_mailer/digest_summary.text.erb +30 -19
  28. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +18 -16
  29. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +21 -14
  30. data/app/views/rails_error_dashboard/errors/_ai_help_panel.html.erb +11 -7
  31. data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +45 -38
  32. data/app/views/rails_error_dashboard/errors/_co_occurring_errors.html.erb +17 -14
  33. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +27 -9
  34. data/app/views/rails_error_dashboard/errors/_error_cascades.html.erb +25 -21
  35. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +25 -22
  36. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +24 -12
  37. data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +12 -11
  38. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +19 -13
  39. data/app/views/rails_error_dashboard/errors/_llm_summary.html.erb +14 -14
  40. data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +8 -8
  41. data/app/views/rails_error_dashboard/errors/_modals.html.erb +60 -52
  42. data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +59 -49
  43. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +23 -20
  44. data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +14 -14
  45. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +118 -93
  46. data/app/views/rails_error_dashboard/errors/_similar_errors.html.erb +18 -12
  47. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +20 -5
  48. data/app/views/rails_error_dashboard/errors/_stats.html.erb +5 -5
  49. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +7 -7
  50. data/app/views/rails_error_dashboard/errors/_user_errors_table.html.erb +15 -12
  51. data/app/views/rails_error_dashboard/errors/actioncable_health_summary.html.erb +25 -25
  52. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -28
  53. data/app/views/rails_error_dashboard/errors/analytics.html.erb +82 -82
  54. data/app/views/rails_error_dashboard/errors/cache_health_summary.html.erb +27 -27
  55. data/app/views/rails_error_dashboard/errors/correlation.html.erb +61 -57
  56. data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +64 -65
  57. data/app/views/rails_error_dashboard/errors/deprecations.html.erb +23 -23
  58. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +48 -36
  59. data/app/views/rails_error_dashboard/errors/index.html.erb +121 -70
  60. data/app/views/rails_error_dashboard/errors/job_health_summary.html.erb +30 -30
  61. data/app/views/rails_error_dashboard/errors/llm_health_summary.html.erb +38 -39
  62. data/app/views/rails_error_dashboard/errors/n_plus_one_summary.html.erb +28 -25
  63. data/app/views/rails_error_dashboard/errors/overview.html.erb +45 -39
  64. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +45 -44
  65. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +39 -28
  66. data/app/views/rails_error_dashboard/errors/releases.html.erb +46 -47
  67. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +48 -44
  68. data/app/views/rails_error_dashboard/errors/settings.html.erb +122 -112
  69. data/app/views/rails_error_dashboard/errors/show.html.erb +44 -37
  70. data/app/views/rails_error_dashboard/errors/storms.html.erb +30 -24
  71. data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +26 -26
  72. data/app/views/rails_error_dashboard/errors/user_impact.html.erb +31 -30
  73. data/config/locales/de.yml +2007 -0
  74. data/config/locales/en.yml +2719 -0
  75. data/config/locales/es.yml +2079 -0
  76. data/config/locales/fr.yml +2082 -0
  77. data/config/locales/it.yml +2060 -0
  78. data/config/locales/ja.yml +1802 -0
  79. data/config/locales/pl.yml +2108 -0
  80. data/config/locales/pt-BR.yml +2064 -0
  81. data/config/locales/ru.yml +2109 -0
  82. data/config/locales/uk.yml +2106 -0
  83. data/config/locales/zh-CN.yml +1791 -0
  84. data/config/routes.rb +5 -0
  85. data/db/migrate/20260325000001_fix_swallowed_exceptions_index_for_mysql.rb +9 -3
  86. data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +3 -1
  87. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +4 -2
  88. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +4 -2
  89. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +4 -2
  90. data/lib/rails_error_dashboard/commands/create_issue.rb +5 -3
  91. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +4 -2
  92. data/lib/rails_error_dashboard/commands/log_error.rb +1 -1
  93. data/lib/rails_error_dashboard/configuration.rb +23 -0
  94. data/lib/rails_error_dashboard/current.rb +56 -0
  95. data/lib/rails_error_dashboard/i18n_store.rb +293 -0
  96. data/lib/rails_error_dashboard/private_backend.rb +213 -0
  97. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +89 -22
  98. data/lib/rails_error_dashboard/services/digest_builder.rb +21 -7
  99. data/lib/rails_error_dashboard/services/discord_payload_builder.rb +27 -15
  100. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +10 -5
  101. data/lib/rails_error_dashboard/services/localized_time_formatter.rb +117 -0
  102. data/lib/rails_error_dashboard/services/notification_helpers.rb +87 -4
  103. data/lib/rails_error_dashboard/services/pagerduty_payload_builder.rb +22 -5
  104. data/lib/rails_error_dashboard/services/slack_payload_builder.rb +48 -28
  105. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +2 -1
  106. data/lib/rails_error_dashboard/services/webhook_payload_builder.rb +15 -1
  107. data/lib/rails_error_dashboard/translation.rb +39 -0
  108. data/lib/rails_error_dashboard/version.rb +1 -1
  109. data/lib/rails_error_dashboard.rb +4 -0
  110. data/lib/tasks/error_dashboard.rake +5 -1
  111. metadata +24 -4
@@ -1,18 +1,49 @@
1
- <% content_for :page_title, "Errors" %>
1
+ <% content_for :page_title, red_t("red.errors.index.title") %>
2
2
 
3
3
  <!-- Subscribe to Turbo Stream updates (only if Turbo Streams + ActionCable pubsub are available) -->
4
4
  <% if defined?(Turbo::StreamsHelper) && defined?(ActionCable) && respond_to?(:turbo_stream_from) && RailsErrorDashboard::Services::ErrorBroadcaster.available? %>
5
5
  <%= turbo_stream_from "error_list" %>
6
6
  <% end %>
7
7
 
8
+ <%
9
+ # Value -> label lookups for the machine-valued filters.
10
+ #
11
+ # These previously went through humanize/titleize/capitalize, which apply
12
+ # English morphology to an identifier ("last_7_days" -> "Last 7 days") and
13
+ # produce nonsense in any other language. Each known value now has a key, and
14
+ # an unrecognised one falls back to its raw value rather than raising — a
15
+ # hand-edited query string must never break the page.
16
+ #
17
+ # They live here as lambdas because P2-T2's scope is this view and the row
18
+ # partial; the helpers are P2-T10's task, which can promote these as-is.
19
+ timeframe_label = ->(value) do
20
+ red_t("red.errors.index.filters.timeframes.#{value}", default: value.to_s)
21
+ end
22
+ frequency_label = ->(value) do
23
+ red_t("red.errors.index.filters.frequencies.#{value}", default: value.to_s)
24
+ end
25
+ severity_label = ->(value) do
26
+ red_t("red.common.severity.#{value}", default: value.to_s)
27
+ end
28
+ status_label = ->(value) do
29
+ red_t("red.errors.index.filters.status.#{value}", default: value.to_s)
30
+ end
31
+ %>
8
32
  <div data-controller="loading">
9
- <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-4);">
10
- <h1 style="font-size: 20px; font-weight: 700; margin: 0;">Errors</h1>
33
+ <%# flex-wrap is load-bearing, not cosmetic: this row pairs the page title
34
+ with a fully-formatted timestamp, and every locale but English spells the
35
+ month out in full ("22 августа 2026, 14:05:03" against "August 22, 2026").
36
+ Without wrapping, the row is forced onto one line and overflows a 375px
37
+ viewport in de/fr/ru while fitting in English — measured at 406-469px in
38
+ spec/system/p7_layout_qa_spec.rb. Wrapping lets the timestamp drop below
39
+ the title instead of pushing the page wider. %>
40
+ <div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); margin-bottom: var(--space-4);">
41
+ <h1 style="font-size: 20px; font-weight: 700; margin: 0;"><%= red_t("red.errors.index.title") %></h1>
11
42
  <span style="font-size: 12px; color: var(--text-tertiary);">
12
- Last updated: <%= local_time(Time.current, format: :full) %>
43
+ <%= red_t("red.errors.index.last_updated_html", time: local_time(Time.current, format: :full)) %>
13
44
  <% if defined?(Turbo::StreamsHelper) && defined?(ActionCable) && respond_to?(:turbo_stream_from) && RailsErrorDashboard::Services::ErrorBroadcaster.available? %>
14
45
  <span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; font-size: 10px; font-weight: 600; border-radius: var(--radius-full); background: var(--status-success-bg); color: var(--status-success); margin-left: 6px;">
15
- <i class="bi bi-broadcast" style="font-size: 10px;"></i> Live
46
+ <i class="bi bi-broadcast" style="font-size: 10px;"></i> <%= red_t("red.errors.index.live") %>
16
47
  </span>
17
48
  <% end %>
18
49
  </span>
@@ -30,14 +61,16 @@
30
61
  <!-- Summary line + batch actions -->
31
62
  <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-4);">
32
63
  <span style="font-size: 13px; color: var(--text-secondary);">
33
- <strong style="color: var(--text-primary);"><%= @pagy.count %></strong> errors
64
+ <%= red_tp("red.errors.index.summary.errors_html", count: @pagy.count,
65
+ value: tag.strong(@pagy.count, style: "color: var(--text-primary);")) %>
34
66
  <span style="margin: 0 6px; color: var(--border-secondary);">&middot;</span>
35
- <strong style="color: var(--status-critical);"><%= @stats[:unresolved] %></strong> unresolved
67
+ <%= red_t("red.errors.index.summary.unresolved_html",
68
+ value: tag.strong(@stats[:unresolved], style: "color: var(--status-critical);")) %>
36
69
  <span style="margin: 0 6px; color: var(--border-secondary);">&middot;</span>
37
70
  <% if params[:timeframe].present? %>
38
- <%= params[:timeframe].humanize %>
71
+ <%= timeframe_label.call(params[:timeframe]) %>
39
72
  <% else %>
40
- All time
73
+ <%= red_t("red.errors.index.summary.all_time") %>
41
74
  <% end %>
42
75
  </span>
43
76
  </div>
@@ -50,17 +83,17 @@
50
83
  <strong>
51
84
  <% case @stats[:spike_info][:severity] %>
52
85
  <% when :critical %>
53
- Critical Error Spike Detected!
86
+ <%= red_t("red.errors.index.spike.critical") %>
54
87
  <% when :high %>
55
- High Error Spike Detected
88
+ <%= red_t("red.errors.index.spike.high") %>
56
89
  <% when :elevated %>
57
- Elevated Error Activity
90
+ <%= red_t("red.errors.index.spike.elevated") %>
58
91
  <% end %>
59
92
  </strong>
60
93
  <span style="color: var(--text-secondary);">
61
- Today: <strong><%= @stats[:spike_info][:today_count] %> errors</strong>
62
- (7-day avg: <%= @stats[:spike_info][:avg_count] %>) &mdash;
63
- <strong><%= @stats[:spike_info][:multiplier] %>x normal levels</strong>
94
+ <strong><%= red_tp("red.errors.index.spike.today", count: @stats[:spike_info][:today_count]) %></strong>
95
+ <%= red_t("red.errors.index.spike.average", avg: @stats[:spike_info][:avg_count]) %> &mdash;
96
+ <strong><%= red_t("red.errors.index.spike.multiplier", multiplier: @stats[:spike_info][:multiplier]) %></strong>
64
97
  </span>
65
98
  </div>
66
99
  </div>
@@ -75,7 +108,7 @@
75
108
  <!-- Search -->
76
109
  <div style="position: relative; flex: 1 1 200px; min-width: 160px;">
77
110
  <i class="bi bi-search" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 13px; color: var(--text-tertiary);"></i>
78
- <%= text_field_tag :search, params[:search], placeholder: "Search errors...", style: "width: 100%; padding: 6px 10px 6px 30px; font-size: 13px; border: 1px solid var(--border-primary); border-radius: var(--radius-sm); background: var(--surface-base); color: var(--text-primary); outline: none; font-family: var(--font-sans);" %>
111
+ <%= text_field_tag :search, params[:search], placeholder: red_t("red.errors.index.filters.search_placeholder"), style: "width: 100%; padding: 6px 10px 6px 30px; font-size: 13px; border: 1px solid var(--border-primary); border-radius: var(--radius-sm); background: var(--surface-base); color: var(--text-primary); outline: none; font-family: var(--font-sans);" %>
79
112
  </div>
80
113
 
81
114
  <div style="width: 1px; height: 24px; background: var(--border-primary);"></div>
@@ -86,47 +119,60 @@
86
119
  current_status = params[:status]
87
120
  is_unresolved_only = params[:unresolved] != "false" && params[:unresolved] != "0"
88
121
  %>
89
- <%= link_to errors_path(app_context.merge(unresolved: '0')), class: "btn filter-pill #{current_status.blank? && !is_unresolved_only ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>All<% end %>
90
- <%= link_to errors_path(app_context.merge(unresolved: '1')), class: "btn filter-pill #{is_unresolved_only && current_status.blank? ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Unresolved <span style="font-size: 11px; opacity: 0.7;"><%= @stats[:unresolved] %></span><% end %>
91
- <%= link_to errors_path(app_context.merge(status: 'resolved', unresolved: '0')), class: "btn filter-pill #{current_status == 'resolved' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Resolved<% end %>
92
- <%= link_to errors_path(app_context.merge(assigned_to: '__assigned__', unresolved: '0')), class: "btn filter-pill #{params[:assigned_to] == '__assigned__' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Assigned<% end %>
93
- <%= link_to errors_path(app_context.merge(reopened: 'true', unresolved: '0')), class: "btn filter-pill #{params[:reopened] == 'true' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Reopened<% end %>
122
+ <%= link_to errors_path(app_context.merge(unresolved: '0')), class: "btn filter-pill #{current_status.blank? && !is_unresolved_only ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %><%= red_t("red.errors.index.filters.status.all") %><% end %>
123
+ <%= link_to errors_path(app_context.merge(unresolved: '1')), class: "btn filter-pill #{is_unresolved_only && current_status.blank? ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %><%= red_t("red.errors.index.filters.status.unresolved") %> <span style="font-size: 11px; opacity: 0.7;"><%= @stats[:unresolved] %></span><% end %>
124
+ <%= link_to errors_path(app_context.merge(status: 'resolved', unresolved: '0')), class: "btn filter-pill #{current_status == 'resolved' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %><%= red_t("red.errors.index.filters.status.resolved") %><% end %>
125
+ <%= link_to errors_path(app_context.merge(assigned_to: '__assigned__', unresolved: '0')), class: "btn filter-pill #{params[:assigned_to] == '__assigned__' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %><%= red_t("red.errors.index.filters.status.assigned") %><% end %>
126
+ <%= link_to errors_path(app_context.merge(reopened: 'true', unresolved: '0')), class: "btn filter-pill #{params[:reopened] == 'true' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %><%= red_t("red.errors.index.filters.status.reopened") %><% end %>
94
127
  </div>
95
128
 
96
129
  <div style="width: 1px; height: 24px; background: var(--border-primary);"></div>
97
130
 
98
131
  <!-- Severity pills -->
99
- <div style="display: flex; gap: 4px;">
132
+ <%# flex-wrap matches the status-chip row above it. Five severity chips
133
+ fit one line in English ("Any Critical High Medium Low") but not in a
134
+ locale that expands: Russian renders "Любая важность Критическая
135
+ Высокая Средняя Низкая" and pushed this row to 426px inside a 375px
136
+ viewport. Pinned by spec/system/p7_layout_qa_spec.rb. %>
137
+ <div style="display: flex; gap: 4px; flex-wrap: wrap;">
100
138
  <% current_severity = params[:severity] %>
101
- <%= link_to errors_path(permitted_filter_params.except(:severity)), class: "filter-pill #{current_severity.blank? ? 'active' : ''}", style: "text-decoration: none;" do %>Any severity<% end %>
139
+ <%= link_to errors_path(permitted_filter_params.except(:severity)), class: "filter-pill #{current_severity.blank? ? 'active' : ''}", style: "text-decoration: none;" do %><%= red_t("red.errors.index.filters.severity.any") %><% end %>
102
140
  <% %w[critical high medium low].each do |sev| %>
103
- <%= link_to errors_path(permitted_filter_params.merge(severity: sev)), class: "filter-pill #{current_severity == sev ? 'active' : ''}", style: "text-decoration: none;" do %><%= sev.capitalize %><% end %>
141
+ <%= link_to errors_path(permitted_filter_params.merge(severity: sev)), class: "filter-pill #{current_severity == sev ? 'active' : ''}", style: "text-decoration: none;" do %><%= severity_label.call(sev) %><% end %>
104
142
  <% end %>
105
143
  </div>
106
144
 
107
145
  <!-- Advanced filters toggle -->
108
146
  <div style="width: 1px; height: 24px; background: var(--border-primary);"></div>
109
147
  <button type="button" data-red-action="toggle-display" data-red-target="#advanced-filters" data-red-display="flex" class="filter-pill" style="border: none;">
110
- <i class="bi bi-sliders"></i> More filters
148
+ <i class="bi bi-sliders"></i> <%= red_t("red.errors.index.filters.more") %>
111
149
  </button>
112
150
  </div>
113
151
 
114
152
  <!-- Advanced filters -->
115
153
  <div id="advanced-filters" style="display: none; gap: var(--space-3); flex-wrap: wrap; margin-bottom: var(--space-4); padding: var(--space-3) var(--space-4); background: var(--surface-primary); border-radius: var(--radius-md); border: 1px solid var(--border-primary);">
116
154
  <% if @applications.size > 1 %>
117
- <%= select_tag :application_id, options_for_select([['All Apps', '']] + @applications, params[:application_id]), class: "form-select", style: "width: auto; min-width: 120px;" %>
155
+ <%= select_tag :application_id, options_for_select([[red_t("red.errors.index.filters.all_apps"), '']] + @applications, params[:application_id]), class: "form-select", style: "width: auto; min-width: 120px;" %>
118
156
  <% end %>
119
157
  <% if @platforms.size > 1 %>
120
- <%= select_tag :platform, options_for_select([['All Platforms', '']] + @platforms.map { |p| [p, p] }, params[:platform]), class: "form-select", style: "width: auto; min-width: 120px;" %>
158
+ <%= select_tag :platform, options_for_select([[red_t("red.errors.index.filters.all_platforms"), '']] + @platforms.map { |p| [p, p] }, params[:platform]), class: "form-select", style: "width: auto; min-width: 120px;" %>
121
159
  <% end %>
122
- <%= select_tag :error_type, options_for_select([['All Types', '']] + @error_types.map { |t| [t, t] }, params[:error_type]), class: "form-select", style: "width: auto; min-width: 120px;" %>
123
- <%= select_tag :timeframe, options_for_select([['All Time', ''], ['Last Hour', 'last_hour'], ['Today', 'today'], ['Yesterday', 'yesterday'], ['Last 7 Days', 'last_7_days'], ['Last 30 Days', 'last_30_days'], ['Last 90 Days', 'last_90_days']], params[:timeframe]), class: "form-select", style: "width: auto; min-width: 120px;" %>
124
- <%= select_tag :frequency, options_for_select([['All Frequencies', ''], ['Once', 'once'], ['2-9 Times', 'few'], ['10-99 Times', 'frequent'], ['100+ Times', 'very_frequent'], ['Recurring', 'recurring']], params[:frequency]), class: "form-select", style: "width: auto; min-width: 120px;" %>
125
- <%= select_tag :priority_level, options_for_select([['All Priorities', '']] + RailsErrorDashboard::ErrorLog.priority_options(include_emoji: true), params[:priority_level]), class: "form-select", style: "width: auto; min-width: 120px;" %>
126
- <%= select_tag :assigned_to, options_for_select([['All Assignments', ''], ['Unassigned', '__unassigned__'], ['Assigned', '__assigned__']], params[:assigned_to]), class: "form-select", id: "assigned_to_filter", style: "width: auto; min-width: 120px;" %>
160
+ <%= select_tag :error_type, options_for_select([[red_t("red.errors.index.filters.all_types"), '']] + @error_types.map { |t| [t, t] }, params[:error_type]), class: "form-select", style: "width: auto; min-width: 120px;" %>
161
+ <%
162
+ # Built from the same keys the active chips read, so a label can never
163
+ # differ between the select and the chip it produces.
164
+ timeframe_options = %w[last_hour today yesterday last_7_days last_30_days last_90_days]
165
+ .map { |value| [ timeframe_label.call(value), value ] }
166
+ frequency_options = %w[once few frequent very_frequent recurring]
167
+ .map { |value| [ frequency_label.call(value), value ] }
168
+ %>
169
+ <%= select_tag :timeframe, options_for_select([[red_t("red.errors.index.filters.all_timeframes"), '']] + timeframe_options, params[:timeframe]), class: "form-select", style: "width: auto; min-width: 120px;" %>
170
+ <%= select_tag :frequency, options_for_select([[red_t("red.errors.index.filters.all_frequencies"), '']] + frequency_options, params[:frequency]), class: "form-select", style: "width: auto; min-width: 120px;" %>
171
+ <%= select_tag :priority_level, options_for_select([[red_t("red.errors.index.filters.all_priorities"), '']] + RailsErrorDashboard::ErrorLog.priority_options(include_emoji: true), params[:priority_level]), class: "form-select", style: "width: auto; min-width: 120px;" %>
172
+ <%= select_tag :assigned_to, options_for_select([[red_t("red.errors.index.filters.all_assignments"), ''], [red_t("red.errors.index.filters.unassigned"), '__unassigned__'], [red_t("red.errors.index.filters.assigned"), '__assigned__']], params[:assigned_to]), class: "form-select", id: "assigned_to_filter", style: "width: auto; min-width: 120px;" %>
127
173
  <div id="assignee_name_filter" style="<%= params[:assigned_to] == '__assigned__' ? '' : 'display: none;' %>">
128
174
  <% assignees = defined?(@assignees) ? @assignees : [] %>
129
- <%= select_tag :assignee_name, options_for_select([['All Assignees', '']] + assignees.map { |name| [name, name] }, params[:assignee_name]), class: "form-select", style: "width: auto; min-width: 120px;" %>
175
+ <%= select_tag :assignee_name, options_for_select([[red_t("red.errors.index.filters.all_assignees"), '']] + assignees.map { |name| [name, name] }, params[:assignee_name]), class: "form-select", style: "width: auto; min-width: 120px;" %>
130
176
  </div>
131
177
 
132
178
  <div style="display: flex; align-items: center; gap: var(--space-4); font-size: 12px; color: var(--text-secondary);">
@@ -139,61 +185,66 @@
139
185
  end
140
186
  %>
141
187
  <%= check_box_tag :unresolved, "1", is_checked, class: "form-check-input", id: "unresolved_checkbox", style: "width: 14px; height: 14px;" %>
142
- Unresolved only
188
+ <%= red_t("red.errors.index.filters.unresolved_only") %>
143
189
  </label>
144
190
  <label style="display: flex; align-items: center; gap: 4px; font-weight: 400;">
145
191
  <%= check_box_tag :hide_snoozed, "1", params[:hide_snoozed] == "1", class: "form-check-input", style: "width: 14px; height: 14px;" %>
146
- Hide snoozed
192
+ <%= red_t("red.errors.index.filters.hide_snoozed") %>
147
193
  </label>
148
194
  <label style="display: flex; align-items: center; gap: 4px; font-weight: 400;">
149
195
  <%= check_box_tag :hide_muted, "1", params[:hide_muted] == "1", class: "form-check-input", style: "width: 14px; height: 14px;" %>
150
- Hide muted
196
+ <%= red_t("red.errors.index.filters.hide_muted") %>
151
197
  </label>
152
198
  </div>
153
199
 
154
200
  <div style="display: flex; gap: 6px; margin-left: auto;">
155
- <%= submit_tag "Apply Filters", class: "btn btn-primary btn-sm", data: { loading_target: "submitButton" } %>
156
- <%= link_to "Clear", errors_path, class: "btn btn-sm", style: "font-size: 12px;" %>
201
+ <%= submit_tag red_t("red.errors.index.filters.apply"), class: "btn btn-primary btn-sm", data: { loading_target: "submitButton" } %>
202
+ <%= link_to red_t("red.errors.index.filters.clear"), errors_path, class: "btn btn-sm", style: "font-size: 12px;" %>
157
203
  </div>
158
204
  </div>
159
205
  <% end %>
160
206
 
161
207
  <!-- Active Filter Pills -->
162
208
  <%
209
+ # Each chip is an interpolated key, never a concatenation — the label and
210
+ # its value are not always in that order across languages. Search terms and
211
+ # application, platform and type names are free-form user data and are
212
+ # interpolated verbatim, never translated.
163
213
  active_filters = []
164
- active_filters << { label: "Search: #{params[:search]}", param: :search } if params[:search].present?
214
+ active_filters << { label: red_t("red.errors.index.filters.chips.search", value: params[:search]), param: :search } if params[:search].present?
165
215
  if params[:application_id].present? && defined?(@applications)
166
216
  app_name = @applications.find { |name, id| id.to_s == params[:application_id].to_s }&.first
167
- active_filters << { label: "App: #{app_name}", param: :application_id } if app_name
217
+ active_filters << { label: red_t("red.errors.index.filters.chips.application", value: app_name), param: :application_id } if app_name
168
218
  end
169
- active_filters << { label: "Platform: #{params[:platform]}", param: :platform } if params[:platform].present?
170
- active_filters << { label: "Type: #{params[:error_type]}", param: :error_type } if params[:error_type].present?
171
- active_filters << { label: "Severity: #{params[:severity].titleize}", param: :severity } if params[:severity].present?
172
- active_filters << { label: "Timeframe: #{params[:timeframe].humanize}", param: :timeframe } if params[:timeframe].present?
173
- active_filters << { label: "Frequency: #{params[:frequency].humanize}", param: :frequency } if params[:frequency].present?
174
- active_filters << { label: "Status: #{params[:status].humanize}", param: :status } if params[:status].present?
219
+ active_filters << { label: red_t("red.errors.index.filters.chips.platform", value: params[:platform]), param: :platform } if params[:platform].present?
220
+ active_filters << { label: red_t("red.errors.index.filters.chips.error_type", value: params[:error_type]), param: :error_type } if params[:error_type].present?
221
+ active_filters << { label: red_t("red.errors.index.filters.chips.severity", value: severity_label.call(params[:severity])), param: :severity } if params[:severity].present?
222
+ active_filters << { label: red_t("red.errors.index.filters.chips.timeframe", value: timeframe_label.call(params[:timeframe])), param: :timeframe } if params[:timeframe].present?
223
+ active_filters << { label: red_t("red.errors.index.filters.chips.frequency", value: frequency_label.call(params[:frequency])), param: :frequency } if params[:frequency].present?
224
+ active_filters << { label: red_t("red.errors.index.filters.chips.status", value: status_label.call(params[:status])), param: :status } if params[:status].present?
175
225
  if params[:priority_level].present?
176
226
  # priority_level is the integer key (3=Critical/P0, 0=Low/P3); look up the short_label
177
227
  # rather than interpolating the raw integer, which inverts the P-number.
228
+ # P0-P3 are identifiers, not words, and stay untranslated.
178
229
  priority_data = RailsErrorDashboard::ErrorLog::PRIORITY_LEVELS[params[:priority_level].to_i]
179
230
  priority_short = priority_data ? priority_data[:short_label] : "P?"
180
- active_filters << { label: "Priority: #{priority_short}", param: :priority_level }
231
+ active_filters << { label: red_t("red.errors.index.filters.chips.priority", value: priority_short), param: :priority_level }
181
232
  end
182
233
  if params[:assigned_to] == '__unassigned__'
183
- active_filters << { label: "Unassigned", param: :assigned_to }
234
+ active_filters << { label: red_t("red.errors.index.filters.unassigned"), param: :assigned_to }
184
235
  elsif params[:assigned_to] == '__assigned__'
185
- active_filters << { label: "Assigned", param: :assigned_to }
236
+ active_filters << { label: red_t("red.errors.index.filters.assigned"), param: :assigned_to }
186
237
  end
187
238
  %>
188
239
  <% if active_filters.any? %>
189
240
  <div style="display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: var(--space-4); font-size: 12px;">
190
- <span style="color: var(--text-tertiary); font-weight: 500;">Filters:</span>
241
+ <span style="color: var(--text-tertiary); font-weight: 500;"><%= red_t("red.errors.index.filters.active_label") %></span>
191
242
  <% active_filters.each do |filter| %>
192
- <%= link_to errors_path(permitted_filter_params.except(filter[:param])), class: "filter-pill active", style: "text-decoration: none; font-size: 11px; padding: 3px 8px;" do %>
243
+ <%= link_to errors_path(permitted_filter_params.except(filter[:param])), class: "filter-pill active", style: "text-decoration: none; font-size: 11px; padding: 3px 8px;", "aria-label": red_t("red.errors.index.filters.chips.remove") do %>
193
244
  <%= filter[:label] %> <i class="bi bi-x" style="font-size: 12px;"></i>
194
245
  <% end %>
195
246
  <% end %>
196
- <%= link_to errors_path, style: "color: var(--text-tertiary); font-size: 11px;" do %>Clear all<% end %>
247
+ <%= link_to errors_path, style: "color: var(--text-tertiary); font-size: 11px;" do %><%= red_t("red.errors.index.filters.clear_all") %><% end %>
197
248
  </div>
198
249
  <% end %>
199
250
 
@@ -210,10 +261,10 @@
210
261
  <input type="checkbox" id="select-all-batch" class="form-check-input" style="accent-color: var(--accent); margin: 0;" checked>
211
262
  <span id="selected-count" style="font-size: 13px; font-weight: 600; color: var(--text-primary); margin-right: var(--space-2);"></span>
212
263
  <%= button_tag type: "submit", name: "action_type", value: "resolve", class: "btn btn-sm", style: "padding: 4px 12px; font-size: 12px;" do %>
213
- <i class="bi bi-check-circle"></i> Resolve
264
+ <i class="bi bi-check-circle"></i> <%= red_t("red.errors.index.batch.resolve") %>
214
265
  <% end %>
215
- <%= 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: "Are you sure you want to delete the selected errors?" } do %>
216
- <i class="bi bi-trash"></i> Delete
266
+ <%= 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 %>
267
+ <i class="bi bi-trash"></i> <%= red_t("red.errors.index.batch.delete") %>
217
268
  <% end %>
218
269
  <% end %>
219
270
  </div>
@@ -223,18 +274,18 @@
223
274
  <thead>
224
275
  <tr id="thead-columns" style="border-bottom: 1px solid var(--border-primary);">
225
276
  <th style="width: 36px; padding: var(--space-3) var(--space-4); text-align: center;">
226
- <input type="checkbox" id="select-all" class="form-check-input" style="accent-color: var(--accent);">
277
+ <input type="checkbox" id="select-all" class="form-check-input" style="accent-color: var(--accent);" aria-label="<%= red_t("red.errors.index.columns.select_all") %>">
227
278
  </th>
228
- <th style="padding: var(--space-3) var(--space-4); text-align: left;">Error</th>
229
- <th style="padding: var(--space-3) var(--space-4); text-align: left; width: 100px;">Status</th>
230
- <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 70px;">Events</th>
231
- <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 55px;">Users</th>
232
- <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 85px;">Last seen</th>
279
+ <th style="padding: var(--space-3) var(--space-4); text-align: left;"><%= red_t("red.errors.index.columns.error") %></th>
280
+ <th style="padding: var(--space-3) var(--space-4); text-align: left; width: 100px;"><%= red_t("red.errors.index.columns.status") %></th>
281
+ <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 70px;"><%= red_t("red.errors.index.columns.events") %></th>
282
+ <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 55px;"><%= red_t("red.errors.index.columns.users") %></th>
283
+ <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 85px;"><%= red_t("red.errors.index.columns.last_seen") %></th>
233
284
  <% if @applications.size > 1 && params[:application_id].blank? %>
234
- <th style="padding: var(--space-3) var(--space-4); width: 100px;">App</th>
285
+ <th style="padding: var(--space-3) var(--space-4); width: 100px;"><%= red_t("red.errors.index.columns.application") %></th>
235
286
  <% end %>
236
287
  <% if @platforms.size > 1 %>
237
- <th style="padding: var(--space-3) var(--space-4); width: 100px;">Platform</th>
288
+ <th style="padding: var(--space-3) var(--space-4); width: 100px;"><%= red_t("red.errors.index.columns.platform") %></th>
238
289
  <% end %>
239
290
  </tr>
240
291
  </thead>
@@ -249,7 +300,7 @@
249
300
  <!-- Pagination -->
250
301
  <% if @pagy.pages > 1 %>
251
302
  <div style="padding: var(--space-4); border-top: 1px solid var(--border-primary); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3);">
252
- <span style="font-size: 12px; color: var(--text-tertiary);">Showing <%= @pagy.from %>–<%= @pagy.to %> of <%= @pagy.count %> errors</span>
303
+ <span style="font-size: 12px; color: var(--text-tertiary);"><%= red_t("red.errors.index.pagination", from: @pagy.from, to: @pagy.to, count: @pagy.count) %></span>
253
304
  <div><%== @pagy.series_nav(:bootstrap) %></div>
254
305
  </div>
255
306
  <% end %>
@@ -267,13 +318,13 @@
267
318
  <div class="red-empty-state">
268
319
  <% if params[:search].present? || params[:error_type].present? || params[:severity].present? %>
269
320
  <div class="red-empty-state-icon"><i class="bi bi-funnel"></i></div>
270
- <div class="red-empty-state-title">No errors match your filters</div>
271
- <div class="red-empty-state-message">Try adjusting your search or filter criteria to find what you're looking for.</div>
272
- <%= link_to errors_path, class: "red-empty-state-cta" do %><i class="bi bi-x-circle"></i> Clear all filters<% end %>
321
+ <div class="red-empty-state-title"><%= red_t("red.errors.index.empty.filtered_title") %></div>
322
+ <div class="red-empty-state-message"><%= red_t("red.errors.index.empty.filtered_message") %></div>
323
+ <%= link_to errors_path, class: "red-empty-state-cta" do %><i class="bi bi-x-circle"></i> <%= red_t("red.errors.index.empty.filtered_cta") %><% end %>
273
324
  <% else %>
274
325
  <div class="red-empty-state-icon" style="background: var(--status-success-bg); color: var(--status-success);"><i class="bi bi-check-lg"></i></div>
275
- <div class="red-empty-state-title">All clear!</div>
276
- <div class="red-empty-state-message">No errors have been logged yet. Your application is running smoothly. Errors will appear here automatically when they occur.</div>
326
+ <div class="red-empty-state-title"><%= red_t("red.errors.index.empty.clear_title") %></div>
327
+ <div class="red-empty-state-message"><%= red_t("red.errors.index.empty.clear_message") %></div>
277
328
  <% end %>
278
329
  </div>
279
330
  <% end %>
@@ -301,7 +352,7 @@ document.addEventListener('DOMContentLoaded', function() {
301
352
 
302
353
  if (count > 0) {
303
354
  if (batchToolbar) batchToolbar.style.display = '';
304
- if (selectedCountSpan) selectedCountSpan.textContent = count + ' selected';
355
+ if (selectedCountSpan) selectedCountSpan.textContent = window.redT('js.selected', { count: count });
305
356
  if (selectAllBatchCheckbox) {
306
357
  selectAllBatchCheckbox.checked = allChecked;
307
358
  selectAllBatchCheckbox.indeterminate = someChecked && !allChecked;
@@ -1,21 +1,21 @@
1
- <% content_for :page_title, "Job Health" %>
1
+ <% content_for :page_title, red_t("red.health.job.title") %>
2
2
 
3
3
  <div>
4
4
  <div class="d-flex justify-content-between align-items-center mb-4">
5
5
  <h1 style="font-size: 20px; font-weight: 700; margin: 0;">
6
6
  <i class="bi bi-cpu me-2"></i>
7
- Job Health
7
+ <%= red_t("red.health.job.title") %>
8
8
  </h1>
9
9
 
10
10
  <div class="btn-group" role="group">
11
11
  <%= link_to job_health_summary_errors_path(days: 7), class: "btn btn-sm #{@days == 7 ? 'btn-primary' : 'btn-outline-primary'}" do %>
12
- 7 Days
12
+ <%= red_t("red.common.days_filter.7") %>
13
13
  <% end %>
14
14
  <%= link_to job_health_summary_errors_path(days: 30), class: "btn btn-sm #{@days == 30 ? 'btn-primary' : 'btn-outline-primary'}" do %>
15
- 30 Days
15
+ <%= red_t("red.common.days_filter.30") %>
16
16
  <% end %>
17
17
  <%= link_to job_health_summary_errors_path(days: 90), class: "btn btn-sm #{@days == 90 ? 'btn-primary' : 'btn-outline-primary'}" do %>
18
- 90 Days
18
+ <%= red_t("red.common.days_filter.90") %>
19
19
  <% end %>
20
20
  </div>
21
21
  </div>
@@ -23,18 +23,18 @@
23
23
  <% if @errors_with_jobs == 0 %>
24
24
  <div class="red-empty-state">
25
25
  <div class="red-empty-state-icon" style="background: var(--status-success-bg); color: var(--status-success);"><i class="bi bi-check-lg"></i></div>
26
- <div class="red-empty-state-title">No Job Queue Data Found</div>
26
+ <div class="red-empty-state-title"><%= red_t("red.health.job.empty.title") %></div>
27
27
  <p class="text-muted">
28
- No job queue stats were detected in system health snapshots over the last <%= @days %> days.
28
+ <%= red_t("red.health.job.empty.message", days: @days) %>
29
29
  </p>
30
30
  <div class="card mx-auto" style="max-width: 500px;">
31
31
  <div class="card-body text-start">
32
- <h6>How job health tracking works:</h6>
32
+ <h6><%= red_t("red.health.job.empty.how_it_works") %></h6>
33
33
  <ul class="mb-0">
34
- <li>System health must be enabled (<code>enable_system_health = true</code>)</li>
35
- <li>Job queue stats are captured automatically when Sidekiq, SolidQueue, or GoodJob is detected</li>
36
- <li>Stats are collected per-error at the time of capture</li>
37
- <li>This page shows job queue health per-error, sorted by failed count</li>
34
+ <li><%= red_t("red.health.job.empty.step_enable_html") %></li>
35
+ <li><%= red_t("red.health.job.empty.step_capture") %></li>
36
+ <li><%= red_t("red.health.job.empty.step_collected") %></li>
37
+ <li><%= red_t("red.health.job.empty.step_sort") %></li>
38
38
  </ul>
39
39
  </div>
40
40
  </div>
@@ -45,7 +45,7 @@
45
45
  <div class="card text-center">
46
46
  <div class="card-body">
47
47
  <div class="display-6 text-info"><%= @errors_with_jobs %></div>
48
- <small class="text-muted">Errors with Job Data</small>
48
+ <small class="text-muted"><%= red_t("red.health.job.stats.errors_with_jobs") %></small>
49
49
  </div>
50
50
  </div>
51
51
  </div>
@@ -54,7 +54,7 @@
54
54
  <div class="card-body">
55
55
  <% failed_color = @total_failed > 0 ? "danger" : "success" %>
56
56
  <div class="display-6 text-<%= failed_color %>"><%= @total_failed %></div>
57
- <small class="text-muted">Total Failed Jobs</small>
57
+ <small class="text-muted"><%= red_t("red.health.job.stats.total_failed") %></small>
58
58
  </div>
59
59
  </div>
60
60
  </div>
@@ -62,7 +62,7 @@
62
62
  <div class="card text-center">
63
63
  <div class="card-body">
64
64
  <div class="display-6 text-secondary"><%= @adapters_detected.size %></div>
65
- <small class="text-muted">Adapters Detected</small>
65
+ <small class="text-muted"><%= red_t("red.health.job.stats.adapters_detected") %></small>
66
66
  </div>
67
67
  </div>
68
68
  </div>
@@ -72,7 +72,7 @@
72
72
  <div class="card-header d-flex justify-content-between align-items-center">
73
73
  <h5 class="mb-0">
74
74
  <i class="bi bi-cpu text-info me-2"></i>
75
- Job Queue Health by Error
75
+ <%= red_t("red.health.job.table.title") %>
76
76
  <span class="badge bg-info"><%= @errors_with_jobs %></span>
77
77
  </h5>
78
78
  <small class="text-muted"><%== @pagy.info_tag %></small>
@@ -82,12 +82,12 @@
82
82
  <table class="table table-hover mb-0">
83
83
  <thead class="table-light">
84
84
  <tr>
85
- <th width="100">Error</th>
86
- <th width="120">Adapter</th>
87
- <th width="80">Failed</th>
88
- <th width="120">Queued</th>
89
- <th>Other Stats</th>
90
- <th width="140">Last Seen</th>
85
+ <th width="100"><%= red_t("red.health.job.table.column_error") %></th>
86
+ <th width="120"><%= red_t("red.health.job.table.column_adapter") %></th>
87
+ <th width="80"><%= red_t("red.health.job.table.column_failed") %></th>
88
+ <th width="120"><%= red_t("red.health.job.table.column_queued") %></th>
89
+ <th><%= red_t("red.health.job.table.column_other_stats") %></th>
90
+ <th width="140"><%= red_t("red.health.job.table.column_last_seen") %></th>
91
91
  </tr>
92
92
  </thead>
93
93
  <tbody>
@@ -106,22 +106,22 @@
106
106
  <td>
107
107
  <% case entry[:adapter] %>
108
108
  <% when "sidekiq" %>
109
- <%= entry[:enqueued] %> enqueued
109
+ <%= entry[:enqueued] %> <%= red_t("red.health.job.table.enqueued") %>
110
110
  <% when "solid_queue" %>
111
- <%= entry[:ready] %> ready
111
+ <%= entry[:ready] %> <%= red_t("red.health.job.table.ready") %>
112
112
  <% when "good_job" %>
113
- <%= entry[:queued] %> queued
113
+ <%= entry[:queued] %> <%= red_t("red.health.job.table.queued") %>
114
114
  <% end %>
115
115
  </td>
116
116
  <td>
117
117
  <small class="text-muted">
118
118
  <% case entry[:adapter] %>
119
119
  <% when "sidekiq" %>
120
- dead: <%= entry[:dead] %>, retry: <%= entry[:retry] %>, workers: <%= entry[:workers] %>
120
+ <%= red_t("red.health.job.table.other_stats_sidekiq", dead: entry[:dead], retry: entry[:retry], workers: entry[:workers]) %>
121
121
  <% when "solid_queue" %>
122
- claimed: <%= entry[:claimed] %>, blocked: <%= entry[:blocked] %>, scheduled: <%= entry[:scheduled] %>
122
+ <%= red_t("red.health.job.table.other_stats_solid_queue", claimed: entry[:claimed], blocked: entry[:blocked], scheduled: entry[:scheduled]) %>
123
123
  <% when "good_job" %>
124
- finished: <%= entry[:finished] %>
124
+ <%= red_t("red.health.job.table.other_stats_good_job", finished: entry[:finished]) %>
125
125
  <% end %>
126
126
  </small>
127
127
  </td>
@@ -135,11 +135,11 @@
135
135
  <div class="card-footer border-top d-flex justify-content-between align-items-center">
136
136
  <div>
137
137
  <small class="text-muted">
138
- <i class="bi bi-lightbulb text-warning"></i> High failed job counts during errors may indicate job queue saturation or upstream service issues.
138
+ <i class="bi bi-lightbulb text-warning"></i> <%= red_t("red.health.job.table.tip") %>
139
139
  </small>
140
140
  <small class="ms-3">
141
141
  <a href="https://guides.rubyonrails.org/active_job_basics.html" target="_blank" rel="noopener" class="text-decoration-none">
142
- <i class="bi bi-book"></i> Active Job Guide <i class="bi bi-box-arrow-up-right" style="font-size: 0.7em;"></i>
142
+ <i class="bi bi-book"></i> <%= red_t("red.health.job.table.guide_link") %> <i class="bi bi-box-arrow-up-right" style="font-size: 0.7em;"></i>
143
143
  </a>
144
144
  </small>
145
145
  </div>