rails_error_dashboard 0.9.1 → 0.11.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -3
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +9 -0
  4. data/app/helpers/rails_error_dashboard/i18n_helper.rb +26 -0
  5. data/app/jobs/rails_error_dashboard/rack_attack_flush_job.rb +7 -3
  6. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +8 -1
  7. data/app/models/rails_error_dashboard/error_log.rb +1 -0
  8. data/app/models/rails_error_dashboard/rack_attack_event.rb +6 -2
  9. data/app/views/layouts/rails_error_dashboard.html.erb +85 -0
  10. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +5 -0
  11. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +1 -0
  12. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +8 -0
  13. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +9 -0
  14. data/app/views/rails_error_dashboard/errors/analytics.html.erb +58 -13
  15. data/app/views/rails_error_dashboard/errors/correlation.html.erb +4 -4
  16. data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +1 -1
  17. data/app/views/rails_error_dashboard/errors/index.html.erb +8 -1
  18. data/app/views/rails_error_dashboard/errors/overview.html.erb +2 -2
  19. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +1 -1
  20. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +36 -3
  21. data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
  22. data/config/locales/de.yml +22 -0
  23. data/config/locales/en.yml +24 -0
  24. data/config/locales/es.yml +21 -0
  25. data/config/locales/fr.yml +22 -0
  26. data/config/locales/it.yml +30 -1
  27. data/config/locales/ja.yml +19 -0
  28. data/config/locales/pl.yml +22 -0
  29. data/config/locales/pt-BR.yml +21 -0
  30. data/config/locales/ru.yml +21 -0
  31. data/config/locales/uk.yml +21 -0
  32. data/config/locales/zh-CN.yml +17 -0
  33. data/db/migrate/20260824000001_add_user_agent_to_rack_attack_events.rb +19 -0
  34. data/db/migrate/20260826000001_add_environment_to_error_logs.rb +26 -0
  35. data/lib/generators/rails_error_dashboard/install/install_generator.rb +12 -0
  36. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +11 -0
  37. data/lib/rails_error_dashboard/commands/backfill_environments.rb +58 -0
  38. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +55 -29
  39. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +12 -3
  40. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +40 -9
  41. data/lib/rails_error_dashboard/commands/log_error.rb +17 -0
  42. data/lib/rails_error_dashboard/configuration.rb +52 -0
  43. data/lib/rails_error_dashboard/engine.rb +7 -0
  44. data/lib/rails_error_dashboard/queries/analytics_stats.rb +9 -0
  45. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -0
  46. data/lib/rails_error_dashboard/queries/filter_options.rb +9 -0
  47. data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +31 -5
  48. data/lib/rails_error_dashboard/services/ai_agent_classifier.rb +154 -0
  49. data/lib/rails_error_dashboard/services/discord_payload_builder.rb +8 -0
  50. data/lib/rails_error_dashboard/services/error_broadcaster.rb +6 -2
  51. data/lib/rails_error_dashboard/services/issue_body_formatter.rb +1 -0
  52. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +1 -0
  53. data/lib/rails_error_dashboard/services/notification_throttler.rb +23 -0
  54. data/lib/rails_error_dashboard/services/pagerduty_payload_builder.rb +1 -0
  55. data/lib/rails_error_dashboard/services/rack_attack_tracker.rb +91 -9
  56. data/lib/rails_error_dashboard/services/slack_payload_builder.rb +9 -0
  57. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +2 -0
  58. data/lib/rails_error_dashboard/services/webhook_payload_builder.rb +1 -0
  59. data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +40 -2
  60. data/lib/rails_error_dashboard/version.rb +1 -1
  61. data/lib/rails_error_dashboard.rb +2 -0
  62. data/lib/tasks/rails_error_dashboard_tasks.rake +6 -0
  63. metadata +6 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57296ebd76d5c90e1605cf26e0946418332a109b6a8e06e47764d0679d580180
4
- data.tar.gz: c7c013e699b365aa6fe3ec520d57261eb9e81beabe32ae60e040e4dec60632f2
3
+ metadata.gz: defed3f836bc0e812c76abfb81b95e5e3f229e5338bfa40cde4604643eb95582
4
+ data.tar.gz: 7657043dbf617694e5318a5b0e2829ecca78ade0c67412ede16fbecabc696c36
5
5
  SHA512:
6
- metadata.gz: eacb14ac3148b50729be1a2028df13699feab0c80bdd8d52094d275eabe55f159ed6d68bec6b6b6b8b7642bcb46fa9e0b27c1c23138da1cca277044de5dc1b26
7
- data.tar.gz: 9ba438dd2e6efbe417a26846cc2c8e3784e568ba2f4c471ea92c25258a782ab7265d5cb27e488760d8871764a270e5299de76057c799c3a3220450b410c50f06
6
+ metadata.gz: 79992d38956c257a116c1cfcb153429cbf3a082892bc71664c20ba57e801ba33325ae69f43bd0ef8404a3cd08a6d5df9727a989a0f8977826786a179ad70b448
7
+ data.tar.gz: 3bbcc477eff8db567415c727037a58d7e27bd8e01ad602674beb05cd6891f75bb06e665273698fed1b49c20806e4f07dbea586647583f6b7da3052a744d58fae
data/README.md CHANGED
@@ -23,7 +23,7 @@ gem 'rails_error_dashboard'
23
23
 
24
24
  **[rails-error-dashboard.anjan.dev](https://rails-error-dashboard.anjan.dev)** — Username: `gandalf` · Password: `youshallnotpass`
25
25
 
26
- > **Beta Software** — Functional and tested (2,700+ tests passing), but the API may change before v1.0. Supports Rails 7.0-8.1 and Ruby 3.2-4.0.
26
+ > **Beta Software** — Functional and tested (4,200+ tests passing), but the API may change before v1.0. Supports Rails 7.0-8.1 and Ruby 3.2-4.0.
27
27
 
28
28
  ### Screenshots
29
29
 
@@ -372,7 +372,7 @@ config.llm_model = "gpt-5"
372
372
  <details>
373
373
  <summary><strong>Notifications — Slack, Discord, PagerDuty, Email, Webhooks</strong></summary>
374
374
 
375
- Multi-channel alerting with severity filters, per-error cooldown, and milestone threshold alerts to prevent alert fatigue.
375
+ Multi-channel alerting with severity filters, per-error cooldown, milestone threshold alerts, and a per-environment allowlist (`config.notification_environments = %w[production]`) so a staging deploy never pages anyone.
376
376
 
377
377
  ```ruby
378
378
  config.enable_slack_notifications = true
@@ -382,6 +382,19 @@ config.slack_webhook_url = ENV['SLACK_WEBHOOK_URL']
382
382
  [Notification setup guide →](docs/guides/NOTIFICATIONS.md)
383
383
  </details>
384
384
 
385
+ <details>
386
+ <summary><strong>Environment Awareness — Filter, Badge, Notify per Environment</strong></summary>
387
+
388
+ Every error records the environment it came from — `production`, `staging`, `uat`, `preprod`, any name your deploys use. The errors index filters by it, rows and the detail page carry a badge, the analytics page breaks errors down by environment, and every notification names it. The same error in staging and production is two rows with independent status, so resolving one never hides the other.
389
+
390
+ ```ruby
391
+ config.environment = ENV.fetch("ERROR_DASHBOARD_ENVIRONMENT", Rails.env) # free-form, defaults to Rails.env
392
+ config.notification_environments = %w[production] # nil = notify everywhere
393
+ ```
394
+
395
+ Errors captured before v0.11.0 show no badge until they recur (the next occurrence claims the row) or you run `rails rails_error_dashboard:backfill_environments`.
396
+ </details>
397
+
385
398
  <details>
386
399
  <summary><strong>Advanced Analytics</strong></summary>
387
400
 
@@ -709,7 +722,7 @@ Built with **CQRS (Command/Query Responsibility Segregation)**:
709
722
 
710
723
  ## Testing
711
724
 
712
- 2,600+ tests covering unit, integration, and browser-based system tests.
725
+ 4,200+ tests covering unit, integration, and browser-based system tests.
713
726
 
714
727
  ```bash
715
728
  bundle exec rspec # Full suite
@@ -11,6 +11,7 @@ module RailsErrorDashboard
11
11
  error_type
12
12
  unresolved
13
13
  platform
14
+ environment
14
15
  application_id
15
16
  search
16
17
  severity
@@ -94,6 +95,7 @@ module RailsErrorDashboard
94
95
  filter_options = Queries::FilterOptions.call(application_id: @current_application_id)
95
96
  @error_types = filter_options[:error_types]
96
97
  @platforms = filter_options[:platforms]
98
+ @environments = filter_options[:environments]
97
99
  @assignees = filter_options[:assignees]
98
100
  end
99
101
 
@@ -242,6 +244,7 @@ module RailsErrorDashboard
242
244
  @errors_over_time = analytics[:errors_over_time]
243
245
  @errors_by_type = analytics[:errors_by_type]
244
246
  @errors_by_platform = analytics[:errors_by_platform]
247
+ @errors_by_environment = analytics[:errors_by_environment]
245
248
  @errors_by_hour = analytics[:errors_by_hour]
246
249
  @top_users = analytics[:top_users]
247
250
  @resolution_rate = analytics[:resolution_rate]
@@ -535,6 +538,12 @@ module RailsErrorDashboard
535
538
  @unique_rules = all_events.size
536
539
  @total_events = all_events.sum { |e| e[:count] }
537
540
  @unique_ips = all_events.flat_map { |e| e[:ips] }.uniq.size
541
+ # Events matched by a recognised AI agent. Counts requests, not addresses —
542
+ # one agent rotates through many IPs, so unique_ips overstates it (#170).
543
+ @ai_events = all_events.sum { |e| e[:ai_count].to_i }
544
+ # Counts the tracker's LRU eviction dropped; shown so the totals above are
545
+ # never silently understated.
546
+ @overflow_count = result[:overflow_count].to_i
538
547
 
539
548
  @pagy, @events = pagy(:offset, all_events, limit: params[:per_page] || 25)
540
549
  end
@@ -150,5 +150,31 @@ module RailsErrorDashboard
150
150
  rescue StandardError
151
151
  "%B %d, %Y %I:%M:%S %p"
152
152
  end
153
+
154
+ # A finished, localized date string for somewhere the browser will not
155
+ # re-render it.
156
+ #
157
+ # Almost every timestamp on the dashboard goes out as a <span data-format>
158
+ # that formatDateTime() localizes client-side. Chart labels cannot: they
159
+ # are serialized into a JS array and handed to Chart.js as plain strings,
160
+ # so whatever the server writes is what the axis shows. Calling strftime
161
+ # directly there is what put English month names on every locale's charts
162
+ # (#178) — Ruby's strftime is not locale-aware.
163
+ #
164
+ # Same formatter the mailers and the Slack/Discord payloads use, so a chart
165
+ # axis and an email agree about what a date looks like.
166
+ #
167
+ # @param time [Time, Date, nil]
168
+ # @param pattern [String] a strftime pattern
169
+ # @return [String] "" for nil — an axis label must never read "undefined"
170
+ def red_chart_date(time, pattern)
171
+ return "" if time.nil?
172
+
173
+ time = time.to_time if time.respond_to?(:to_time) && !time.is_a?(Time)
174
+ Services::LocalizedTimeFormatter.call(time, pattern: pattern, locale: red_locale)
175
+ rescue StandardError
176
+ # A chart with an English axis beats a chart that fails to render.
177
+ time.respond_to?(:strftime) ? time.strftime(pattern) : time.to_s
178
+ end
153
179
  end
154
180
  end
@@ -22,9 +22,13 @@ module RailsErrorDashboard
22
22
  # Mode 1: Persist provided snapshot (dispatched from tracker flush)
23
23
  Commands::FlushRackAttackEvents.call(counts: counts)
24
24
  else
25
- # Mode 2: Flush current thread's buffer (scheduled cron safety net).
26
- # sync: true because we are already off the request path.
27
- Services::RackAttackTracker.flush!(sync: true)
25
+ # Mode 2: Flush EVERY live thread's buffer (scheduled cron safety net).
26
+ #
27
+ # This used to call flush!, which only ever sees Thread.current — the
28
+ # job worker's own buffer, which is always empty. The counts live on the
29
+ # Puma threads that served the requests, so the documented safety net
30
+ # swept nothing. flush_all_threads! is what makes the promise true.
31
+ Services::RackAttackTracker.flush_all_threads!
28
32
  end
29
33
  end
30
34
  end
@@ -20,12 +20,19 @@ module RailsErrorDashboard
20
20
  # The subject is assembled from a key rather than interpolated inline, so a
21
21
  # locale can reorder or drop the emoji. error_type and message are verbatim
22
22
  # error content and are never translated.
23
+ #
24
+ # An error that carries an environment uses subject_with_environment; a
25
+ # legacy row keeps the pre-0.11 subject so existing mail filters still match.
23
26
  def subject_for(error_log, locale)
27
+ environment = error_log.respond_to?(:environment) ? error_log.environment : nil
28
+ key = environment.present? ? "red.mailers.error_alert.subject_with_environment" : "red.mailers.error_alert.subject"
29
+
24
30
  I18nStore.translate(
25
- "red.mailers.error_alert.subject",
31
+ key,
26
32
  locale: locale,
27
33
  application: error_log.application&.name ||
28
34
  I18nStore.translate("red.mailers.shared.unknown_application", locale: locale),
35
+ environment: environment,
29
36
  error_type: error_log.error_type,
30
37
  message: truncate_subject(error_log.message)
31
38
  )
@@ -61,6 +61,7 @@ module RailsErrorDashboard
61
61
  scope :by_error_type, ->(type) { where(error_type: type) }
62
62
  scope :by_type, ->(type) { where(error_type: type) }
63
63
  scope :by_platform, ->(platform) { where(platform: platform) }
64
+ scope :by_environment, ->(environment) { where(environment: environment) }
64
65
  scope :last_24_hours, -> { where("occurred_at >= ?", 24.hours.ago) }
65
66
  scope :last_week, -> { where("occurred_at >= ?", 1.week.ago) }
66
67
 
@@ -12,8 +12,12 @@ module RailsErrorDashboard
12
12
  class RackAttackEvent < ErrorLogsRecord
13
13
  self.table_name = "rails_error_dashboard_rack_attack_events"
14
14
 
15
- # Event types emitted by Rack::Attack (v5.0+)
16
- MATCH_TYPES = %w[throttle blocklist track safelist].freeze
15
+ # Event types emitted by Rack::Attack (v5.0+), plus "overflow" — not a
16
+ # Rack::Attack event at all, but a synthetic bucket holding counts dropped
17
+ # by the tracker's LRU eviction so totals stay truthful. Overflow rows are
18
+ # excluded from the per-rule listing and surfaced separately.
19
+ MATCH_TYPES = %w[throttle blocklist track safelist overflow].freeze
20
+ OVERFLOW_MATCH_TYPE = "overflow"
17
21
 
18
22
  belongs_to :application, optional: true
19
23
 
@@ -660,7 +660,16 @@ dd { color: var(--text-primary); }
660
660
  height: 48px; flex-shrink: 0;
661
661
  background: var(--surface-primary);
662
662
  border-bottom: 1px solid var(--border-primary);
663
+ /* A flex item's default min-width is auto, meaning "never shrink below my
664
+ content". Both navbar groups therefore kept their full intrinsic width on a
665
+ narrow screen and pushed the right-hand controls past the viewport — the
666
+ dashboard scrolled sideways at 375px in EVERY locale, English included.
667
+ min-width:0 lets them shrink; overflow:hidden keeps whatever still cannot
668
+ fit from extending the page rather than being clipped inside the bar. */
669
+ min-width: 0;
670
+ overflow: hidden;
663
671
  }
672
+ .red-navbar > * { min-width: 0; }
664
673
  .red-search-input {
665
674
  width: 240px; padding: 5px 10px 5px 30px; font-size: 13px;
666
675
  border: 1px solid var(--border-primary); border-radius: var(--radius-sm);
@@ -720,6 +729,21 @@ dd { color: var(--text-primary); }
720
729
  .red-sidebar.collapsed { display: none; }
721
730
  }
722
731
 
732
+ /* Stat grids
733
+ These were inline `grid-template-columns: repeat(N, 1fr)`, which cannot carry
734
+ a media query, so three or four cards stayed side by side at 375px and pushed
735
+ the page to 488px — it scrolled sideways on a phone in EVERY locale, English
736
+ worst of all. auto-fit + minmax lets the row reflow to two columns and then
737
+ one, and minmax's 0 floor stops a long word forcing a track wider than the
738
+ screen. */
739
+ .red-stat-grid {
740
+ display: grid;
741
+ grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
742
+ gap: var(--space-4);
743
+ margin-bottom: var(--space-6);
744
+ }
745
+ .red-stat-grid > * { min-width: 0; }
746
+
723
747
  /* Error detail responsive grid */
724
748
  .red-detail-grid { display: grid; grid-template-columns: 1fr 260px; gap: var(--space-4); }
725
749
  @media (max-width: 1023.98px) { .red-detail-grid { grid-template-columns: 1fr; } }
@@ -729,7 +753,13 @@ dd { color: var(--text-primary); }
729
753
  padding: 3px 10px; font-size: 11px; font-weight: 600;
730
754
  border-radius: var(--radius-full);
731
755
  text-transform: uppercase; letter-spacing: 0.05em;
756
+ white-space: nowrap;
732
757
  }
758
+ /* Below the sidebar breakpoint the navbar has to fit a menu button, search,
759
+ language picker, shortcuts, theme toggle and the env badge. The badge is
760
+ decoration — the environment is also on the overview page — so it is the
761
+ first thing dropped rather than letting it push the page sideways. */
762
+ @media (max-width: 575.98px) { .red-env-badge { display: none; } }
733
763
 
734
764
  /* Kbd shortcut */
735
765
  kbd {
@@ -1751,6 +1781,51 @@ document.addEventListener('DOMContentLoaded', function() {
1751
1781
  .replace('%H',pad(h)).replace('%I',pad(h12)).replace('%M',pad(mi)).replace('%S',pad(s)).replace('%p',ampm).replace('%P',ampm.toLowerCase());
1752
1782
  }
1753
1783
 
1784
+ // Chart.js renders date axes through chartjs-adapter-date-fns, whose bundle
1785
+ // ships English locale data only. Loading eleven date-fns locales from a CDN
1786
+ // to fix that would be absurd when RED already carries localized month and
1787
+ // day names in red.js.* for formatDateTime(). So the adapter's format() is
1788
+ // redirected through the same vocabulary the rest of the dashboard uses, and
1789
+ // a chart axis, a page timestamp and an email now agree.
1790
+ //
1791
+ // Only the tokens the adapter's own format table can emit are mapped:
1792
+ // datetime "MMM d, yyyy, h:mm:ss aaaa" millisecond "h:mm:ss.SSS aaaa"
1793
+ // second "h:mm:ss aaaa" minute "h:mm aaaa" hour "ha" day "MMM d"
1794
+ // week "PP" month "MMM yyyy" quarter "qqq - yyyy" year "yyyy"
1795
+ // Anything else — a caller-supplied pattern, a future adapter version — is
1796
+ // handed back to the original implementation rather than half-translated.
1797
+ function installRedChartDateAdapter() {
1798
+ if (typeof Chart === 'undefined' || !Chart._adapters || !Chart._adapters._date) return;
1799
+ var proto = Chart._adapters._date.prototype;
1800
+ if (!proto || typeof proto.format !== 'function' || proto._redPatched) return;
1801
+
1802
+ // date-fns token -> strftime, for the patterns the adapter actually uses.
1803
+ var TOKENS = {
1804
+ 'MMM d, yyyy, h:mm:ss aaaa': '%b %d, %Y, %I:%M:%S %p',
1805
+ 'h:mm:ss.SSS aaaa': '%I:%M:%S %p',
1806
+ 'h:mm:ss aaaa': '%I:%M:%S %p',
1807
+ 'h:mm aaaa': '%I:%M %p',
1808
+ 'ha': '%I %p',
1809
+ 'MMM d': '%b %d',
1810
+ 'PP': '%b %d, %Y',
1811
+ 'MMM yyyy': '%b %Y',
1812
+ 'yyyy': '%Y'
1813
+ };
1814
+
1815
+ var original = proto.format;
1816
+ proto.format = function(time, fmt) {
1817
+ try {
1818
+ var strf = TOKENS[fmt];
1819
+ if (strf) return formatDateTime(new Date(time), strf);
1820
+ } catch (e) {
1821
+ // Fall through — a chart with English axes beats a chart that throws.
1822
+ }
1823
+ return original.call(this, time, fmt);
1824
+ };
1825
+ proto._redPatched = true;
1826
+ }
1827
+
1828
+
1754
1829
  function getTimezoneAbbreviation(date) {
1755
1830
  // RED's locale set and the browser's Intl data need not agree, so an
1756
1831
  // unsupported tag throws RangeError here rather than degrading. Catch it
@@ -1801,6 +1876,16 @@ document.addEventListener('DOMContentLoaded', function() {
1801
1876
  }
1802
1877
 
1803
1878
  convertToLocalTime();
1879
+
1880
+ // Localize Chart.js date axes. Kept out of the block above on purpose: that
1881
+ // region is extracted and run in bare node by js_date_parity_spec, where
1882
+ // `document` is a stub, so it must contain definitions only — no top-level
1883
+ // browser calls. The adapter is patched on the prototype, so charts drawn
1884
+ // later pick it up; chartkick:load re-asserts for page scripts whose own
1885
+ // DOMContentLoaded handler beat this one, and _redPatched makes that a no-op.
1886
+ installRedChartDateAdapter();
1887
+ document.addEventListener('chartkick:load', installRedChartDateAdapter);
1888
+
1804
1889
  if (typeof Turbo !== 'undefined') {
1805
1890
  document.addEventListener('turbo:load', convertToLocalTime);
1806
1891
  document.addEventListener('turbo:frame-load', convertToLocalTime);
@@ -148,6 +148,11 @@
148
148
  </div>
149
149
  <% end %>
150
150
 
151
+ <% if @error_log.respond_to?(:environment) && @error_log.environment.present? %>
152
+ <div class="label"><%= red_t("red.mailers.error_alert.labels.environment") %>:</div>
153
+ <div class="value"><strong><%= @error_log.environment %></strong></div>
154
+ <% end %>
155
+
151
156
  <div class="label"><%= red_t("red.mailers.error_alert.labels.occurred_at") %>:</div>
152
157
  <div class="value"><%= red_mail_time(@error_log.occurred_at) %></div>
153
158
 
@@ -10,6 +10,7 @@
10
10
  <%= red_mail_t("red.mailers.error_alert.labels.application") %>: <%= @error_log.application&.name || red_mail_t("red.mailers.shared.unknown_application") %>
11
11
  <%= red_mail_t("red.mailers.error_alert.labels.error_type") %>: <%= @error_log.error_type %>
12
12
  <% if @error_log.platform.present? %><%= red_mail_t("red.mailers.error_alert.labels.platform") %>: <%= @error_log.platform %><% end %>
13
+ <% if @error_log.respond_to?(:environment) && @error_log.environment.present? %><%= red_mail_t("red.mailers.error_alert.labels.environment") %>: <%= @error_log.environment %><% end %>
13
14
  <%= red_mail_t("red.mailers.error_alert.labels.occurred_at") %>: <%= red_mail_time(@error_log.occurred_at) %>
14
15
  <% if @error_log.user_id.present? %><%= red_mail_t("red.mailers.error_alert.labels.user_id") %>: <%= @error_log.user_id %><% end %>
15
16
  <% if @error_log.ip_address.present? %><%= red_mail_t("red.mailers.error_alert.labels.ip_address") %>: <%= @error_log.ip_address %><% end %>
@@ -99,4 +99,12 @@
99
99
  <% end %>
100
100
  </td>
101
101
  <% end %>
102
+ <% if local_assigns[:show_environment] %>
103
+ <%# Environment names are free-form data (production, staging, uat...) and are never translated. %>
104
+ <td style="padding: var(--space-3) var(--space-4); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="<%= error.environment %>">
105
+ <% if error.environment.present? %>
106
+ <span class="badge badge-api" data-environment-badge="<%= error.environment %>"><i class="bi bi-hdd-stack"></i> <%= error.environment %></span>
107
+ <% end %>
108
+ </td>
109
+ <% end %>
102
110
  </tr>
@@ -70,6 +70,15 @@
70
70
  <% end %>
71
71
  </div>
72
72
 
73
+ <% if error.respond_to?(:environment) && error.environment.present? %>
74
+ <div class="mb-3">
75
+ <small class="metadata-label d-block mb-1"><%= red_t("red.errors.sidebar.environment") %></small>
76
+ <%= link_to errors_path(environment: error.environment, unresolved: '0'), class: "text-decoration-none", title: red_t("red.errors.sidebar.view_all_environment", environment: error.environment) do %>
77
+ <span class="badge badge-api" data-environment-badge="<%= error.environment %>"><i class="bi bi-hdd-stack"></i> <%= error.environment %></span>
78
+ <% end %>
79
+ </div>
80
+ <% end %>
81
+
73
82
  <div class="mb-3">
74
83
  <small class="metadata-label d-block mb-1"><%= red_t("red.errors.sidebar.user") %></small>
75
84
  <% if error.respond_to?(:user) && error.user %>
@@ -45,7 +45,7 @@
45
45
  </div>
46
46
 
47
47
  <!-- Summary Stats -->
48
- <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-bottom: var(--space-6);">
48
+ <div class="red-stat-grid">
49
49
  <div class="card stat-card" style="border-left: 3px solid var(--accent); padding: var(--space-5) var(--space-6);">
50
50
  <div class="stat-label" style="margin-bottom: 4px;"><%= red_t("red.analytics.overview.stats.total_errors") %></div>
51
51
  <span class="stat-value"><%= @error_stats[:total] %></span>
@@ -75,8 +75,6 @@
75
75
  curve: false,
76
76
  points: true,
77
77
  height: "300px",
78
- xtitle: "Date",
79
- ytitle: "Number of Errors",
80
78
  library: {
81
79
  scales: {
82
80
  x: {
@@ -157,6 +155,56 @@
157
155
  </div>
158
156
  <% end %>
159
157
 
158
+ <%# Errors by environment: same shape as the platform chart, shown only when
159
+ there is something to compare. Environment names are data, not text to
160
+ translate; NULL rows (captured before the column existed) are :unknown. %>
161
+ <% environment_counts = (@errors_by_environment || {}).transform_keys { |env| env == :unknown ? red_t("red.analytics.overview.environment_chart.unknown") : env } %>
162
+ <% if environment_counts.size > 1 %>
163
+ <div class="row g-4 mb-4">
164
+ <div class="col-md-12">
165
+ <div class="card">
166
+ <div class="card-header">
167
+ <h5 class="mb-0"><i class="bi bi-hdd-stack"></i> <%= red_t("red.analytics.overview.environment_chart.title") %></h5>
168
+ </div>
169
+ <div class="card-body">
170
+ <div id="errors-by-environment-chart"></div>
171
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
172
+ document.addEventListener('DOMContentLoaded', function() {
173
+ const colors = window.getChartColors();
174
+ const environmentColors = ["#EF4444", "#F59E0B", "#2563EB", "#10B981", "#8B5CF6", "#6B7280"];
175
+
176
+ new Chartkick.PieChart("errors-by-environment-chart", <%= raw environment_counts.to_json %>, {
177
+ colors: environmentColors,
178
+ height: "300px",
179
+ legend: "bottom",
180
+ donut: true,
181
+ library: {
182
+ plugins: {
183
+ legend: {
184
+ labels: {
185
+ color: colors.textColor,
186
+ font: { size: 12, weight: 'bold' }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ });
192
+ });
193
+ </script>
194
+ </div>
195
+ <div class="card-footer border-top-0">
196
+ <div class="d-flex gap-2 flex-wrap">
197
+ <small class="text-muted me-2"><%= red_t("red.analytics.overview.environment_chart.quick_links") %></small>
198
+ <% (@errors_by_environment || {}).keys.reject { |env| env == :unknown }.each do |environment| %>
199
+ <%= link_to environment, errors_path(environment: environment, unresolved: '0'), class: "btn btn-sm btn-outline-secondary" %>
200
+ <% end %>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ <% end %>
207
+
160
208
  <!-- Charts Row 2 -->
161
209
  <div class="row g-4 mb-4">
162
210
  <div class="col-md-12">
@@ -172,18 +220,19 @@
172
220
  new Chartkick.BarChart("errors-by-type-chart", <%= raw @errors_by_type.to_json %>, {
173
221
  color: "#EF4444",
174
222
  height: "400px",
175
- xtitle: "Error Type",
176
- ytitle: "Count",
177
223
  library: {
178
224
  scales: {
225
+ // Chartkick's BarChart is horizontal (it sets indexAxis:"y"),
226
+ // so the categories sit on y and the counts on x — the
227
+ // opposite of the ColumnCharts on this page. #178.
179
228
  x: {
180
229
  ticks: { color: colors.textColor },
181
- title: { color: colors.textColor, display: true, text: '<%= red_js_t("red.ui_js.charts.axis_error_type") %>' },
230
+ title: { color: colors.textColor, display: true, text: '<%= red_js_t("red.ui_js.charts.axis_count") %>' },
182
231
  grid: { color: colors.gridColor }
183
232
  },
184
233
  y: {
185
234
  ticks: { color: colors.textColor },
186
- title: { color: colors.textColor, display: true, text: '<%= red_js_t("red.ui_js.charts.axis_count") %>' },
235
+ title: { color: colors.textColor, display: true, text: '<%= red_js_t("red.ui_js.charts.axis_error_type") %>' },
187
236
  grid: { color: colors.gridColor }
188
237
  }
189
238
  },
@@ -214,8 +263,6 @@
214
263
  new Chartkick.ColumnChart("errors-by-hour-chart", <%= raw @errors_by_hour.to_json %>, {
215
264
  color: "#DC2626",
216
265
  height: "300px",
217
- xtitle: "Hour",
218
- ytitle: "Number of Errors",
219
266
  library: {
220
267
  scales: {
221
268
  x: {
@@ -422,8 +469,6 @@
422
469
  new Chartkick.ColumnChart("errors-by-version-chart", versionData, {
423
470
  colors: ["#8B5CF6", "#EF4444", "#F59E0B", "#10B981"],
424
471
  height: "300px",
425
- xtitle: "Version",
426
- ytitle: "Error Count",
427
472
  library: window.getChartLibraryOptions()
428
473
  });
429
474
  });
@@ -558,7 +603,7 @@
558
603
  const colors = window.getChartColors();
559
604
  new Chartkick.BarChart("mttr-by-platform-chart",
560
605
  <%= raw @mttr_by_platform.to_json %>, {
561
- suffix: " hours",
606
+ suffix: '<%= red_js_t("red.ui_js.charts.suffix_hours") %>',
562
607
  height: "200px",
563
608
  colors: ["#8B5CF6"],
564
609
  library: window.getChartLibraryOptions()
@@ -611,7 +656,7 @@
611
656
  const colors = window.getChartColors();
612
657
  new Chartkick.LineChart("mttr-trend-chart",
613
658
  <%= raw @mttr_stats[:mttr_trend].to_json %>, {
614
- suffix: " hours",
659
+ suffix: '<%= red_js_t("red.ui_js.charts.suffix_hours") %>',
615
660
  height: "250px",
616
661
  colors: ["#10B981"],
617
662
  curve: false,
@@ -36,8 +36,8 @@
36
36
  <div class="display-6"><%= @period_comparison[:current_period][:count] %></div>
37
37
  <small class="text-muted">
38
38
  <%= red_t("red.analytics.correlation.trend_analysis.period_range",
39
- start: @period_comparison[:current_period][:start].strftime("%b %d"),
40
- end: @period_comparison[:current_period][:end].strftime("%b %d")) %>
39
+ start: red_chart_date(@period_comparison[:current_period][:start], "%b %d"),
40
+ end: red_chart_date(@period_comparison[:current_period][:end], "%b %d")) %>
41
41
  </small>
42
42
  </div>
43
43
  <div class="col-md-4">
@@ -45,8 +45,8 @@
45
45
  <div class="display-6"><%= @period_comparison[:previous_period][:count] %></div>
46
46
  <small class="text-muted">
47
47
  <%= red_t("red.analytics.correlation.trend_analysis.period_range",
48
- start: @period_comparison[:previous_period][:start].strftime("%b %d"),
49
- end: @period_comparison[:previous_period][:end].strftime("%b %d")) %>
48
+ start: red_chart_date(@period_comparison[:previous_period][:start], "%b %d"),
49
+ end: red_chart_date(@period_comparison[:previous_period][:end], "%b %d")) %>
50
50
  </small>
51
51
  </div>
52
52
  <div class="col-md-4">
@@ -91,7 +91,7 @@
91
91
  <% if @live_health[:postgresql] %>
92
92
  <%# Summary cards for PostgreSQL %>
93
93
  <% total_db_size = (@host_tables + @gem_tables).sum { |t| t[:total_bytes] } %>
94
- <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-4); margin-bottom: var(--space-4);">
94
+ <div class="red-stat-grid" style="margin-bottom: var(--space-4);">
95
95
  <div class="card stat-card" style="padding: var(--space-4) var(--space-5); text-align: center;">
96
96
  <div class="stat-label" style="margin-bottom: 4px;"><%= red_t("red.health.database.summary.total_db_size") %></div>
97
97
  <div class="stat-value"><%= number_to_human_size(total_db_size) %></div>
@@ -157,6 +157,9 @@
157
157
  <% if @platforms.size > 1 %>
158
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;" %>
159
159
  <% end %>
160
+ <% if @environments.size > 1 %>
161
+ <%= select_tag :environment, options_for_select([[red_t("red.errors.index.filters.all_environments"), '']] + @environments.map { |e| [e, e] }, params[:environment]), class: "form-select", style: "width: auto; min-width: 120px;" %>
162
+ <% end %>
160
163
  <%= 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
164
  <%
162
165
  # Built from the same keys the active chips read, so a label can never
@@ -217,6 +220,7 @@
217
220
  active_filters << { label: red_t("red.errors.index.filters.chips.application", value: app_name), param: :application_id } if app_name
218
221
  end
219
222
  active_filters << { label: red_t("red.errors.index.filters.chips.platform", value: params[:platform]), param: :platform } if params[:platform].present?
223
+ active_filters << { label: red_t("red.errors.index.filters.chips.environment", value: params[:environment]), param: :environment } if params[:environment].present?
220
224
  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
225
  active_filters << { label: red_t("red.errors.index.filters.chips.severity", value: severity_label.call(params[:severity])), param: :severity } if params[:severity].present?
222
226
  active_filters << { label: red_t("red.errors.index.filters.chips.timeframe", value: timeframe_label.call(params[:timeframe])), param: :timeframe } if params[:timeframe].present?
@@ -287,11 +291,14 @@
287
291
  <% if @platforms.size > 1 %>
288
292
  <th style="padding: var(--space-3) var(--space-4); width: 100px;"><%= red_t("red.errors.index.columns.platform") %></th>
289
293
  <% end %>
294
+ <% if @environments.size > 1 %>
295
+ <th style="padding: var(--space-3) var(--space-4); width: 110px;"><%= red_t("red.errors.index.columns.environment") %></th>
296
+ <% end %>
290
297
  </tr>
291
298
  </thead>
292
299
  <tbody id="error_list">
293
300
  <% @errors.each do |error| %>
294
- <%= render "error_row", error: error, show_platform: @platforms.size > 1, show_application: (@applications.size > 1 && params[:application_id].blank?) %>
301
+ <%= render "error_row", error: error, show_platform: @platforms.size > 1, show_environment: @environments.size > 1, show_application: (@applications.size > 1 && params[:application_id].blank?) %>
295
302
  <% end %>
296
303
  </tbody>
297
304
  </table>
@@ -32,7 +32,7 @@
32
32
  trend_values = @stats[:errors_trend_7d].values rescue []
33
33
  trend_max = [ trend_values.max || 1, 1 ].max
34
34
  %>
35
- <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-bottom: var(--space-6);">
35
+ <div class="red-stat-grid">
36
36
  <!-- Error Rate -->
37
37
  <div class="card stat-card" style="border-left: 3px solid var(--status-critical); padding: var(--space-5) var(--space-6);">
38
38
  <div class="stat-label" style="margin-bottom: 4px;"><%= red_t("red.errors.overview_page.error_rate") %></div>
@@ -89,7 +89,7 @@
89
89
  </div>
90
90
 
91
91
  <!-- Secondary Stats (3 cards) -->
92
- <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-bottom: var(--space-6);">
92
+ <div class="red-stat-grid">
93
93
  <!-- Affected Users -->
94
94
  <div class="card stat-card" style="padding: var(--space-5) var(--space-6);">
95
95
  <div class="stat-label" style="margin-bottom: 4px;"><%= red_t("red.errors.overview_page.affected_users") %></div>
@@ -358,7 +358,7 @@
358
358
  new Chart(dailyTrendCtx, {
359
359
  type: 'line',
360
360
  data: {
361
- labels: <%= raw @daily_trends.values.first&.keys&.map { |d| d.strftime('%b %d') }&.to_json || [].to_json %>,
361
+ labels: <%= raw @daily_trends.values.first&.keys&.map { |d| red_chart_date(d, '%b %d') }&.to_json || [].to_json %>,
362
362
  datasets: datasets
363
363
  },
364
364
  options: {