rails_error_dashboard 0.12.1 → 0.14.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/rails_error_dashboard/application_controller.rb +92 -11
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +111 -34
  4. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +3 -0
  5. data/app/helpers/rails_error_dashboard/application_helper.rb +46 -1
  6. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +8 -0
  7. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +10 -0
  8. data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +71 -0
  9. data/app/jobs/rails_error_dashboard/notification_burst_summary_job.rb +55 -0
  10. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +122 -1
  11. data/app/jobs/rails_error_dashboard/storm_flush_job.rb +7 -4
  12. data/app/jobs/rails_error_dashboard/storm_notification_job.rb +8 -44
  13. data/app/models/rails_error_dashboard/error_baseline.rb +12 -9
  14. data/app/models/rails_error_dashboard/error_comment.rb +0 -5
  15. data/app/models/rails_error_dashboard/error_log.rb +19 -3
  16. data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
  17. data/app/models/rails_error_dashboard/error_occurrence.rb +9 -1
  18. data/app/models/rails_error_dashboard/event_count.rb +132 -0
  19. data/app/models/rails_error_dashboard/event_timing_gap.rb +55 -0
  20. data/app/views/layouts/rails_error_dashboard.html.erb +58 -5
  21. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +18 -11
  22. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +22 -8
  23. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +2 -0
  24. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +15 -6
  25. data/app/views/rails_error_dashboard/errors/analytics.html.erb +8 -8
  26. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +1 -1
  27. data/app/views/rails_error_dashboard/errors/index.html.erb +6 -1
  28. data/app/views/rails_error_dashboard/errors/overview.html.erb +12 -0
  29. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +7 -7
  30. data/app/views/rails_error_dashboard/errors/releases.html.erb +2 -2
  31. data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
  32. data/app/views/rails_error_dashboard/errors/show.html.erb +3 -3
  33. data/config/locales/de.yml +30 -0
  34. data/config/locales/en.yml +37 -0
  35. data/config/locales/es.yml +30 -0
  36. data/config/locales/fr.yml +30 -0
  37. data/config/locales/it.yml +30 -0
  38. data/config/locales/ja.yml +30 -0
  39. data/config/locales/pl.yml +30 -0
  40. data/config/locales/pt-BR.yml +30 -0
  41. data/config/locales/ru.yml +30 -0
  42. data/config/locales/uk.yml +30 -0
  43. data/config/locales/zh-CN.yml +30 -0
  44. data/db/migrate/20260917000001_add_last_notified_at_to_error_logs.rb +40 -0
  45. data/db/migrate/20260919000001_create_event_counts.rb +71 -0
  46. data/db/migrate/20260920000001_add_buckets_incomplete_to_storm_events.rb +25 -0
  47. data/db/migrate/20260920000002_create_event_timing_gaps.rb +55 -0
  48. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +12 -1
  49. data/lib/rails_error_dashboard/commands/assign_error.rb +12 -2
  50. data/lib/rails_error_dashboard/commands/backfill_environments.rb +2 -0
  51. data/lib/rails_error_dashboard/commands/backfill_resolved_at.rb +42 -0
  52. data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +1 -0
  53. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +2 -0
  54. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +2 -0
  55. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +2 -0
  56. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +109 -11
  57. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +3 -1
  58. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +252 -12
  59. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +5 -2
  60. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +1 -0
  61. data/lib/rails_error_dashboard/commands/log_error.rb +291 -40
  62. data/lib/rails_error_dashboard/commands/mute_error.rb +1 -0
  63. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -0
  64. data/lib/rails_error_dashboard/commands/scrub_invalid_encoding.rb +104 -0
  65. data/lib/rails_error_dashboard/commands/snooze_error.rb +34 -10
  66. data/lib/rails_error_dashboard/commands/unmute_error.rb +1 -0
  67. data/lib/rails_error_dashboard/commands/update_error_priority.rb +23 -2
  68. data/lib/rails_error_dashboard/commands/update_error_status.rb +33 -6
  69. data/lib/rails_error_dashboard/configuration.rb +39 -1
  70. data/lib/rails_error_dashboard/engine.rb +28 -0
  71. data/lib/rails_error_dashboard/manual_error_reporter.rb +16 -5
  72. data/lib/rails_error_dashboard/queries/analytics_stats.rb +89 -30
  73. data/lib/rails_error_dashboard/queries/baseline_stats.rb +107 -0
  74. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +216 -74
  75. data/lib/rails_error_dashboard/queries/error_correlation.rb +6 -3
  76. data/lib/rails_error_dashboard/queries/errors_list.rb +15 -2
  77. data/lib/rails_error_dashboard/queries/event_volume.rb +503 -0
  78. data/lib/rails_error_dashboard/queries/similar_errors.rb +1 -1
  79. data/lib/rails_error_dashboard/services/analytics_cache_manager.rb +43 -18
  80. data/lib/rails_error_dashboard/services/backtrace_processor.rb +3 -1
  81. data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +23 -0
  82. data/lib/rails_error_dashboard/services/cause_chain_extractor.rb +3 -1
  83. data/lib/rails_error_dashboard/services/codeberg_issue_client.rb +13 -2
  84. data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +5 -3
  85. data/lib/rails_error_dashboard/services/encoding_sanitizer.rb +80 -0
  86. data/lib/rails_error_dashboard/services/error_broadcaster.rb +180 -32
  87. data/lib/rails_error_dashboard/services/error_hash_generator.rb +9 -3
  88. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +13 -0
  89. data/lib/rails_error_dashboard/services/exception_filter.rb +55 -0
  90. data/lib/rails_error_dashboard/services/git_head_reader.rb +102 -0
  91. data/lib/rails_error_dashboard/services/notification_throttler.rb +172 -28
  92. data/lib/rails_error_dashboard/services/sensitive_data_filter.rb +47 -1
  93. data/lib/rails_error_dashboard/services/storm_protection/circuit_breaker.rb +59 -3
  94. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +64 -6
  95. data/lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb +25 -2
  96. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +32 -5
  97. data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +99 -23
  98. data/lib/rails_error_dashboard/services/url_safety.rb +40 -0
  99. data/lib/rails_error_dashboard/services/variable_serializer.rb +125 -10
  100. data/lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb +111 -0
  101. data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +11 -2
  102. data/lib/rails_error_dashboard/value_objects/error_context.rb +40 -3
  103. data/lib/rails_error_dashboard/version.rb +1 -1
  104. data/lib/rails_error_dashboard.rb +34 -0
  105. data/lib/tasks/error_dashboard.rake +54 -4
  106. metadata +16 -2
@@ -744,9 +744,54 @@ dd { color: var(--text-primary); }
744
744
  }
745
745
  .red-stat-grid > * { min-width: 0; }
746
746
 
747
- /* Error detail responsive grid */
747
+ /* Error detail hero
748
+ Same failure as the stat grids above: an inline `display: flex` cannot carry
749
+ a media query. The action row held `flex-shrink: 0` while the title column
750
+ was plain `flex: 1`, so at 390px up to five buttons kept their full width
751
+ and the title absorbed every pixel of the squeeze — "NoMethodError" wrapped
752
+ to roughly one character per line, making a very tall header before the
753
+ backtrace on the one screen an on-call engineer actually reads.
754
+
755
+ `flex: 1 1 260px` is the load-bearing part: the 260px basis stops the text
756
+ column collapsing toward zero. Dropping flex-shrink from the actions lets
757
+ them wrap onto their own line instead. */
758
+ .red-error-hero {
759
+ display: flex;
760
+ align-items: flex-start;
761
+ justify-content: space-between;
762
+ gap: var(--space-4);
763
+ flex-wrap: wrap;
764
+ }
765
+ .red-error-hero-text { flex: 1 1 260px; min-width: 0; }
766
+ .red-error-hero-actions { display: flex; gap: 6px; flex-wrap: wrap; }
767
+ @media (max-width: 575.98px) {
768
+ /* Full-width buttons once stacked: a wrapped row of five reads as rubble. */
769
+ .red-error-hero-actions { width: 100%; }
770
+ .red-error-hero-actions .btn { flex: 1 1 auto; }
771
+ }
772
+
773
+ /* Error detail responsive grid
774
+ The single-column rule below stacks the panels, but `1fr` is min-content by
775
+ default, so a track still refused to shrink below its widest child: at 390px
776
+ the grid measured 334px while its content needed 419px, and the page scrolled
777
+ sideways independently of the hero. Same fix as the stat grids: minmax's 0
778
+ floor lets the track shrink, and min-width:0 lets its children do the same. */
748
779
  .red-detail-grid { display: grid; grid-template-columns: 1fr 260px; gap: var(--space-4); }
749
- @media (max-width: 1023.98px) { .red-detail-grid { grid-template-columns: 1fr; } }
780
+ .red-detail-grid > * { min-width: 0; }
781
+ @media (max-width: 1023.98px) { .red-detail-grid { grid-template-columns: minmax(0, 1fr); } }
782
+
783
+ /* Detail tables inside those panels (Request Context, Metadata).
784
+ Their label column is a fixed `width="200"` and their values are URLs and
785
+ JSON, so the table's intrinsic width stayed ~416px however narrow the panel
786
+ became — the last thing still pushing the detail page sideways on a phone
787
+ once the grid and the hero were fixed. Below the stacking breakpoint the
788
+ label column narrows and long values wrap on any character, so the table
789
+ fits its panel instead of widening the page. */
790
+ @media (max-width: 1023.98px) {
791
+ .red-detail-grid .card-body > .table { table-layout: fixed; width: 100%; }
792
+ .red-detail-grid .card-body > .table th { width: 40%; }
793
+ .red-detail-grid .card-body > .table td { overflow-wrap: anywhere; }
794
+ }
750
795
 
751
796
  /* Env badge */
752
797
  .red-env-badge {
@@ -1182,10 +1227,18 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1182
1227
  <i class="bi bi-layout-sidebar-inset"></i>
1183
1228
  </button>
1184
1229
  <!-- Search -->
1185
- <div style="position: relative;">
1230
+ <%# A plain GET form to the errors index, using the same "search"
1231
+ parameter as the index's own field. Hand-written rather than
1232
+ form_with: a GET form must not put an authenticity token or a
1233
+ utf8 field into the query string. Only String parameters are
1234
+ echoed back; a nested one is ignored. %>
1235
+ <form action="<%= errors_path %>" method="get" role="search" style="position: relative; margin: 0;">
1186
1236
  <i class="bi bi-search" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 13px; color: var(--text-tertiary);"></i>
1187
- <input class="red-search-input" placeholder="<%= red_t("red.navbar.search_placeholder") %>" id="globalSearch" />
1188
- </div>
1237
+ <input class="red-search-input" type="text" name="search" value="<%= params[:search] if params[:search].is_a?(String) %>" placeholder="<%= red_t("red.navbar.search_placeholder") %>" aria-label="<%= red_t("red.navbar.search_placeholder") %>" id="globalSearch" />
1238
+ <% if params[:application_id].is_a?(String) && params[:application_id].present? %>
1239
+ <input type="hidden" name="application_id" value="<%= params[:application_id] %>" />
1240
+ <% end %>
1241
+ </form>
1189
1242
  </div>
1190
1243
  <div style="display: flex; align-items: center; gap: var(--space-3);">
1191
1244
  <!-- Application Switcher (multi-app) -->
@@ -1,4 +1,7 @@
1
1
  <% has_linked_issue = error.respond_to?(:external_issue_url) && error.external_issue_url.present? %>
2
+ <%# nil when the stored value is not plain http(s); the button is then omitted
3
+ (the issue card explains why). See safe_external_url. %>
4
+ <% discussion_issue_url = has_linked_issue ? safe_external_url(error.external_issue_url) : nil %>
2
5
  <% has_audit_comments = error.respond_to?(:comments) && error.comments.any? %>
3
6
  <% platform_comments = @platform_comments || [] %>
4
7
  <% issue_tracking_enabled = RailsErrorDashboard.configuration.enable_issue_tracking %>
@@ -30,9 +33,11 @@
30
33
  <span class="badge bg-secondary ms-2"><%= platform_comments.size %></span>
31
34
  <% end %>
32
35
  </h5>
33
- <a href="<%= error.external_issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
34
- <i class="bi bi-box-arrow-up-right me-1"></i> <%= red_t("red.errors.discussion.reply_on", provider: discussion_provider) %>
35
- </a>
36
+ <% if discussion_issue_url %>
37
+ <a href="<%= discussion_issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
38
+ <i class="bi bi-box-arrow-up-right me-1"></i> <%= red_t("red.errors.discussion.reply_on", provider: discussion_provider) %>
39
+ </a>
40
+ <% end %>
36
41
  </div>
37
42
  <div class="card-body p-0">
38
43
  <% if platform_comments.any? %>
@@ -40,14 +45,14 @@
40
45
  <% platform_comments.each_with_index do |comment, index| %>
41
46
  <div class="px-3 py-3 <%= index < platform_comments.size - 1 ? 'border-bottom' : '' %>">
42
47
  <div class="d-flex align-items-start gap-2 mb-2">
43
- <% if comment[:avatar_url].present? %>
44
- <img src="<%= comment[:avatar_url] %>" alt="<%= comment[:author] %>" width="24" height="24" class="rounded-circle" style="flex-shrink: 0;">
48
+ <% if (avatar = safe_external_url(comment[:avatar_url])) %>
49
+ <img src="<%= avatar %>" alt="<%= comment[:author] %>" width="24" height="24" class="rounded-circle" style="flex-shrink: 0;">
45
50
  <% else %>
46
51
  <i class="bi bi-person-circle" style="font-size: 1.3em; flex-shrink: 0;"></i>
47
52
  <% end %>
48
53
  <div>
49
54
  <strong class="text-primary"><%= comment[:author] || red_t("red.common.unknown") %></strong>
50
- <small class="text-muted ms-2"><%= comment[:created_at] ? Time.parse(comment[:created_at]).strftime(red_time_format(:datetime)) : "" %></small>
55
+ <small class="text-muted ms-2"><%= local_time(safe_parse_time(comment[:created_at]), format: :datetime, fallback: "") %></small>
51
56
  </div>
52
57
  </div>
53
58
  <div class="ms-4 text-break" style="font-size: 0.9em;">
@@ -68,7 +73,7 @@
68
73
  <div class="d-flex justify-content-between align-items-start">
69
74
  <div>
70
75
  <small class="text-primary fw-bold"><i class="bi bi-person-circle"></i> <%= comment.author_name %></small>
71
- <small class="text-muted ms-2"><%= comment.formatted_time %></small>
76
+ <small class="text-muted ms-2"><%= local_time(comment.created_at, format: :datetime) %></small>
72
77
  </div>
73
78
  </div>
74
79
  <small class="text-muted text-break"><%= simple_format(h(comment.body), {}, wrapper_tag: "span") %></small>
@@ -84,9 +89,11 @@
84
89
  <h5 class="mb-0">
85
90
  <i class="bi bi-chat-dots"></i> <%= red_t("red.errors.discussion.title") %>
86
91
  </h5>
87
- <a href="<%= error.external_issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
88
- <i class="bi bi-box-arrow-up-right me-1"></i> <%= red_t("red.errors.discussion.start_on", provider: discussion_provider) %>
89
- </a>
92
+ <% if discussion_issue_url %>
93
+ <a href="<%= discussion_issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
94
+ <i class="bi bi-box-arrow-up-right me-1"></i> <%= red_t("red.errors.discussion.start_on", provider: discussion_provider) %>
95
+ </a>
96
+ <% end %>
90
97
  </div>
91
98
  <div class="card-body text-center py-3">
92
99
  <p class="text-muted small mb-0"><%= red_t("red.errors.discussion.empty", provider: discussion_provider_lc) %></p>
@@ -106,7 +113,7 @@
106
113
  <div class="d-flex justify-content-between align-items-start">
107
114
  <div>
108
115
  <small class="text-primary fw-bold"><i class="bi bi-person-circle"></i> <%= comment.author_name %></small>
109
- <small class="text-muted ms-2"><%= comment.formatted_time %></small>
116
+ <small class="text-muted ms-2"><%= local_time(comment.created_at, format: :datetime) %></small>
110
117
  </div>
111
118
  </div>
112
119
  <small class="text-muted text-break"><%= simple_format(h(comment.body), {}, wrapper_tag: "span") %></small>
@@ -5,8 +5,10 @@
5
5
  <i class="bi bi-link-45deg me-2"></i>
6
6
  <%= red_t("red.errors.issue_section.title") %>
7
7
  </h5>
8
- <% if @error.external_issue_url.present? %>
9
- <a href="<%= @error.external_issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
8
+ <%# nil unless the stored value is plain http(s) — see safe_external_url. %>
9
+ <% issue_url = safe_external_url(@error.external_issue_url) %>
10
+ <% if issue_url %>
11
+ <a href="<%= issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
10
12
  <i class="bi bi-box-arrow-up-right me-1"></i> <%= red_t("red.errors.issue_section.view_issue") %>
11
13
  </a>
12
14
  <% end %>
@@ -23,6 +25,15 @@
23
25
  end %>
24
26
  <% issue = @platform_issue %>
25
27
 
28
+ <% unless issue_url %>
29
+ <%# Stored, but not plain http(s): shown as text, never as a link. %>
30
+ <div class="alert alert-warning py-2 px-3 mb-3" style="font-size: 0.85em;">
31
+ <i class="bi bi-exclamation-triangle me-1"></i>
32
+ <%= red_t("red.errors.issue_section.unsafe_link") %>
33
+ <code class="d-block text-break mt-1"><%= @error.external_issue_url.to_s.truncate(200) %></code>
34
+ </div>
35
+ <% end %>
36
+
26
37
  <!-- Issue badge + status -->
27
38
  <div class="d-flex align-items-center gap-2 mb-3">
28
39
  <span class="badge bg-success">
@@ -50,8 +61,8 @@
50
61
  <div class="d-flex align-items-center gap-2 flex-wrap">
51
62
  <% issue[:assignees].each do |assignee| %>
52
63
  <span class="d-inline-flex align-items-center gap-1 border rounded-pill px-2 py-1" style="font-size: 0.85em;">
53
- <% if assignee[:avatar_url].present? %>
54
- <img src="<%= assignee[:avatar_url] %>" width="18" height="18" class="rounded-circle">
64
+ <% if (avatar = safe_external_url(assignee[:avatar_url])) %>
65
+ <img src="<%= avatar %>" width="18" height="18" class="rounded-circle">
55
66
  <% end %>
56
67
  <%= assignee[:login] %>
57
68
  </span>
@@ -68,8 +79,9 @@
68
79
  <% if issue[:labels].present? && issue[:labels].any? %>
69
80
  <div class="d-flex align-items-center gap-1 flex-wrap">
70
81
  <% issue[:labels].each do |label| %>
71
- <% bg_color = label[:color].present? ? "##{label[:color]}" : "#6c757d" %>
72
- <% text_color = label[:color].present? && label[:color].scan(/../).map { |c| c.to_i(16) }.sum > 382 ? "#000" : "#fff" %>
82
+ <%# Third-party value going into style=: validated, never interpolated raw. %>
83
+ <% bg_color = safe_label_color(label[:color]) %>
84
+ <% text_color = label_text_color(bg_color) %>
73
85
  <span class="badge rounded-pill" style="background-color: <%= bg_color %>; color: <%= text_color %>; font-size: 0.78em;">
74
86
  <%= label[:name] %>
75
87
  </span>
@@ -118,10 +130,12 @@
118
130
  </div>
119
131
  </div>
120
132
 
121
- <% if flash[:new_issue_url].present? %>
133
+ <%# The URL comes from the forge's API response; window.open would run a
134
+ javascript: target, so it gets the same http(s) check as the links. %>
135
+ <% if (new_issue_url = safe_external_url(flash[:new_issue_url])) %>
122
136
  <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
123
137
  (function() {
124
- window.open('<%= j flash[:new_issue_url] %>', '_blank');
138
+ window.open(<%= js_safe_json(new_issue_url) %>, '_blank');
125
139
  })();
126
140
  </script>
127
141
  <% end %>
@@ -30,6 +30,8 @@
30
30
  <div><small class="text-muted"><%= red_t("red.errors.request_context.snapshot_fidelity_lite") %></small></div>
31
31
  <% when "minimal" %>
32
32
  <div><small class="text-muted"><%= red_t("red.errors.request_context.snapshot_fidelity_minimal") %></small></div>
33
+ <% when "partial" %>
34
+ <div><small class="text-muted"><%= red_t("red.errors.request_context.snapshot_fidelity_partial") %></small></div>
33
35
  <% end %>
34
36
  <% end %>
35
37
  </td>
@@ -72,7 +72,7 @@
72
72
 
73
73
  <% if error.respond_to?(:environment) && error.environment.present? %>
74
74
  <div class="mb-3">
75
- <small class="metadata-label d-block mb-1"><%= red_t("red.errors.sidebar.environment") %></small>
75
+ <small class="metadata-label d-block mb-1"><%= red_t("red.errors.sidebar.environment.title") %></small>
76
76
  <%= link_to errors_path(environment: error.environment, unresolved: '0'), class: "text-decoration-none", title: red_t("red.errors.sidebar.view_all_environment", environment: error.environment) do %>
77
77
  <span class="badge badge-api" data-environment-badge="<%= error.environment %>"><i class="bi bi-hdd-stack"></i> <%= error.environment %></span>
78
78
  <% end %>
@@ -106,12 +106,21 @@
106
106
  The <a> is built here rather than living in the key. The key
107
107
  is _html and therefore html_safe, so a raw URL interpolated
108
108
  into an href inside it would not be escaped and a crafted
109
- external_issue_url could inject an event handler. link_to
110
- escapes the URL. %>
111
- <% if error.respond_to?(:external_issue_url) && error.external_issue_url.present? %>
109
+ external_issue_url could inject an event handler.
110
+
111
+ link_to escapes the URL, which stops attribute BREAKOUT. It
112
+ does not reject a scheme: link_to("x", "javascript:...")
113
+ is a working link. That protection comes from
114
+ safe_external_url, which returns nil for anything that is
115
+ not plain http(s). %>
116
+ <% stored_issue_url = error.respond_to?(:external_issue_url) ? error.external_issue_url : nil %>
117
+ <% sidebar_issue_url = safe_external_url(stored_issue_url) %>
118
+ <% if sidebar_issue_url %>
112
119
  <%= red_t("red.errors.sidebar.issue_tracking_linked_html",
113
120
  link: link_to(red_t("red.errors.sidebar.issue_tracking_link_text"),
114
- error.external_issue_url, target: "_blank", rel: "noopener")) %>
121
+ sidebar_issue_url, target: "_blank", rel: "noopener")) %>
122
+ <% elsif stored_issue_url.present? %>
123
+ <%= red_t("red.errors.issue_section.unsafe_link") %>
115
124
  <% else %>
116
125
  <%= red_t("red.errors.sidebar.issue_tracking_unlinked") %>
117
126
  <% end %>
@@ -504,7 +513,7 @@
504
513
  <% if health[:job_queue] %>
505
514
  <% jq = health[:job_queue] %>
506
515
  <div class="mb-1">
507
- <small class="text-muted">Job Queue (<%= jq[:adapter] %>):</small>
516
+ <small class="text-muted"><%= red_t("red.errors.sidebar.health.job_queue", adapter: jq[:adapter]) %></small>
508
517
  <code class="ms-1">
509
518
  <% case jq[:adapter] %>
510
519
  <% when "sidekiq" %>
@@ -70,7 +70,7 @@
70
70
  <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
71
71
  document.addEventListener('DOMContentLoaded', function() {
72
72
  const colors = window.getChartColors();
73
- new Chartkick.LineChart("errors-over-time-chart", <%= raw @errors_over_time.to_json %>, {
73
+ new Chartkick.LineChart("errors-over-time-chart", <%= js_safe_json(@errors_over_time) %>, {
74
74
  color: "#DC2626",
75
75
  curve: false,
76
76
  points: true,
@@ -122,7 +122,7 @@
122
122
  "#EF4444" // Red - iOS
123
123
  ];
124
124
 
125
- new Chartkick.PieChart("errors-by-platform-chart", <%= raw @errors_by_platform.to_json %>, {
125
+ new Chartkick.PieChart("errors-by-platform-chart", <%= js_safe_json(@errors_by_platform) %>, {
126
126
  colors: platformColors,
127
127
  height: "300px",
128
128
  legend: "bottom",
@@ -173,7 +173,7 @@
173
173
  const colors = window.getChartColors();
174
174
  const environmentColors = ["#EF4444", "#F59E0B", "#2563EB", "#10B981", "#8B5CF6", "#6B7280"];
175
175
 
176
- new Chartkick.PieChart("errors-by-environment-chart", <%= raw environment_counts.to_json %>, {
176
+ new Chartkick.PieChart("errors-by-environment-chart", <%= js_safe_json(environment_counts) %>, {
177
177
  colors: environmentColors,
178
178
  height: "300px",
179
179
  legend: "bottom",
@@ -217,7 +217,7 @@
217
217
  <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
218
218
  document.addEventListener('DOMContentLoaded', function() {
219
219
  const colors = window.getChartColors();
220
- new Chartkick.BarChart("errors-by-type-chart", <%= raw @errors_by_type.to_json %>, {
220
+ new Chartkick.BarChart("errors-by-type-chart", <%= js_safe_json(@errors_by_type) %>, {
221
221
  color: "#EF4444",
222
222
  height: "400px",
223
223
  library: {
@@ -260,7 +260,7 @@
260
260
  <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
261
261
  document.addEventListener('DOMContentLoaded', function() {
262
262
  const colors = window.getChartColors();
263
- new Chartkick.ColumnChart("errors-by-hour-chart", <%= raw @errors_by_hour.to_json %>, {
263
+ new Chartkick.ColumnChart("errors-by-hour-chart", <%= js_safe_json(@errors_by_hour) %>, {
264
264
  color: "#DC2626",
265
265
  height: "300px",
266
266
  library: {
@@ -469,7 +469,7 @@
469
469
  <div id="errors-by-version-chart"></div>
470
470
  <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
471
471
  document.addEventListener('DOMContentLoaded', function() {
472
- const versionData = <%= raw @errors_by_version.transform_values { |v| v[:count] }.to_json %>;
472
+ const versionData = <%= js_safe_json(@errors_by_version.transform_values { |v| v[:count] }) %>;
473
473
  const colors = window.getChartColors();
474
474
 
475
475
  new Chartkick.ColumnChart("errors-by-version-chart", versionData, {
@@ -608,7 +608,7 @@
608
608
  document.addEventListener('DOMContentLoaded', function() {
609
609
  const colors = window.getChartColors();
610
610
  new Chartkick.BarChart("mttr-by-platform-chart",
611
- <%= raw @mttr_by_platform.to_json %>, {
611
+ <%= js_safe_json(@mttr_by_platform) %>, {
612
612
  suffix: '<%= red_js_t("red.ui_js.charts.suffix_hours") %>',
613
613
  height: "200px",
614
614
  colors: ["#8B5CF6"],
@@ -661,7 +661,7 @@
661
661
  document.addEventListener('DOMContentLoaded', function() {
662
662
  const colors = window.getChartColors();
663
663
  new Chartkick.LineChart("mttr-trend-chart",
664
- <%= raw @mttr_stats[:mttr_trend].to_json %>, {
664
+ <%= js_safe_json(@mttr_stats[:mttr_trend]) %>, {
665
665
  suffix: '<%= red_js_t("red.ui_js.charts.suffix_hours") %>',
666
666
  height: "250px",
667
667
  colors: ["#10B981"],
@@ -49,7 +49,7 @@
49
49
  <div class="card text-center">
50
50
  <div class="card-body">
51
51
  <%
52
- latest = @dumps.first
52
+ latest = @latest_dump
53
53
  latest_data = begin; JSON.parse(latest.dump_data); rescue; {}; end if latest
54
54
  thread_count = latest_data&.dig("system_health", "thread_count")
55
55
  %>
@@ -2,7 +2,12 @@
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
- <%= turbo_stream_from "error_list" %>
5
+ <%# Names come from ErrorBroadcaster (one source for both ends): this page's
6
+ application or the global streams, without the new-row stream when a
7
+ filter, a sort or a later page means a new row does not belong on top. %>
8
+ <% (@live_streams || []).each do |stream| %>
9
+ <%= turbo_stream_from stream %>
10
+ <% end %>
6
11
  <% end %>
7
12
 
8
13
  <%
@@ -16,6 +16,18 @@
16
16
  </div>
17
17
  <% end %>
18
18
 
19
+ <%# A storm episode degraded without writing its time buckets, so the
20
+ time-window figures below rest on each group's first-seen timestamp
21
+ rather than on per-event records. The COUNTS are still exact -- only
22
+ their placement in time is approximate, and saying so beats presenting
23
+ a figure of unknown completeness as fact. %>
24
+ <% if @stats[:event_timing_incomplete] %>
25
+ <div class="alert-warning" style="display: flex; align-items: center; gap: 10px; padding: 10px var(--space-5); background: var(--status-warning-bg); border-radius: var(--radius-md); border: 1px solid var(--status-warning); margin-bottom: var(--space-6); font-size: 13px; color: var(--status-warning);">
26
+ <i class="bi bi-clock-history"></i>
27
+ <strong><%= red_t("red.errors.overview_page.event_timing_incomplete") %></strong>
28
+ </div>
29
+ <% end %>
30
+
19
31
  <!-- Spike / Critical Alerts Banner -->
20
32
  <% if @critical_alerts.any? %>
21
33
  <%
@@ -313,10 +313,10 @@
313
313
  new Chart(errorRateCtx, {
314
314
  type: 'bar',
315
315
  data: {
316
- labels: <%= raw @error_rate_by_platform.keys.map { |k| k.to_s.capitalize }.to_json %>,
316
+ labels: <%= js_safe_json(@error_rate_by_platform.keys.map { |k| k.to_s.capitalize }) %>,
317
317
  datasets: [{
318
318
  label: '<%= red_js_t("red.ui_js.charts.series_total_errors") %>',
319
- data: <%= raw @error_rate_by_platform.values.to_json %>,
319
+ data: <%= js_safe_json(@error_rate_by_platform.values) %>,
320
320
  backgroundColor: 'rgba(37, 99, 235, 0.5)', // Blue with transparency
321
321
  borderColor: '#2563EB', // Blue
322
322
  borderWidth: 1
@@ -338,14 +338,14 @@
338
338
  // Daily Trend Chart
339
339
  const dailyTrendCtx = document.getElementById('dailyTrendChart');
340
340
  if (dailyTrendCtx) {
341
- const datasets = <%= raw @daily_trends.map { |platform, data|
341
+ const datasets = <%= js_safe_json(@daily_trends.map { |platform, data|
342
342
  {
343
343
  label: platform.to_s.capitalize,
344
344
  data: data.values,
345
345
  platform: platform.to_s.downcase,
346
346
  tension: 0.4
347
347
  }
348
- }.to_json %>;
348
+ }) %>;
349
349
 
350
350
  // Apply platform-specific colors
351
351
  datasets.forEach(ds => {
@@ -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| red_chart_date(d) }&.to_json || [].to_json %>,
361
+ labels: <%= js_safe_json(@daily_trends.values.first&.keys&.map { |d| red_chart_date(d) } || []) %>,
362
362
  datasets: datasets
363
363
  },
364
364
  options: {
@@ -377,8 +377,8 @@
377
377
  // Resolution Time Chart
378
378
  const resolutionTimeCtx = document.getElementById('resolutionTimeChart');
379
379
  if (resolutionTimeCtx) {
380
- const platforms = <%= raw @resolution_times.keys.map { |k| k.to_s.capitalize }.to_json %>;
381
- const times = <%= raw @resolution_times.values.map { |v| v || 0 }.to_json %>;
380
+ const platforms = <%= js_safe_json(@resolution_times.keys.map { |k| k.to_s.capitalize }) %>;
381
+ const times = <%= js_safe_json(@resolution_times.values.map { |v| v || 0 }) %>;
382
382
 
383
383
  new Chart(resolutionTimeCtx, {
384
384
  type: 'bar',
@@ -43,7 +43,7 @@
43
43
  </div>
44
44
  <% else %>
45
45
  <%
46
- current_release = @releases.find { |r| r[:current] }
46
+ current_release = @current_release
47
47
  %>
48
48
 
49
49
  <!-- Summary Cards -->
@@ -76,7 +76,7 @@
76
76
  <div class="card text-center">
77
77
  <div class="card-body">
78
78
  <%
79
- problematic_count = @releases.count { |r| r[:problematic] }
79
+ problematic_count = @problematic_release_count
80
80
  %>
81
81
  <div class="display-6 <%= problematic_count > 0 ? 'text-danger' : 'text-success' %>"><%= problematic_count %></div>
82
82
  <small class="text-muted"><%= red_t("red.analytics.releases.summary.problematic") %></small>
@@ -47,6 +47,8 @@
47
47
  color: "info",
48
48
  settings: {
49
49
  notification_environments: { type: :array },
50
+ notification_burst_limit: { type: :integer },
51
+ notification_burst_window_seconds: { type: :integer, unit: :seconds },
50
52
  enable_slack_notifications: { type: :boolean },
51
53
  slack_webhook_url: { type: :url, show_if: :enable_slack_notifications },
52
54
  enable_email_notifications: { type: :boolean },
@@ -13,8 +13,8 @@
13
13
  </div>
14
14
 
15
15
  <!-- Hero card -->
16
- <div style="display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-6); background: var(--surface-primary); border-radius: var(--radius-md); border: 1px solid var(--border-primary); margin-bottom: var(--space-4);">
17
- <div style="flex: 1; min-width: 0;">
16
+ <div class="red-error-hero" style="padding: var(--space-6); background: var(--surface-primary); border-radius: var(--radius-md); border: 1px solid var(--border-primary); margin-bottom: var(--space-4);">
17
+ <div class="red-error-hero-text">
18
18
  <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
19
19
  <span class="badge bg-<%= severity_color(@error.severity) %>" style="display: inline-flex; align-items: center; gap: 4px;">
20
20
  <span style="width: 6px; height: 6px; border-radius: 50%; background: currentColor;"></span>
@@ -75,7 +75,7 @@
75
75
  <span><%= red_t("red.errors.show.hero.last_seen_html", time: local_time_ago(@error.last_seen_at)) %></span>
76
76
  </div>
77
77
  </div>
78
- <div style="display: flex; gap: 6px; flex-shrink: 0;">
78
+ <div class="red-error-hero-actions">
79
79
  <% unless @error.resolved? %>
80
80
  <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#resolveModal" style="font-weight: 600;">
81
81
  <%= red_t("red.errors.show.actions.resolve") %>
@@ -420,6 +420,7 @@ de:
420
420
  issue_section:
421
421
  title: Issue-Tracker
422
422
  view_issue: Issue ansehen
423
+ unsafe_link: "Dieser Issue-Link ist keine http(s)-URL, daher wird er als Text angezeigt und kann nicht geöffnet werden."
423
424
  linked: Verknüpft
424
425
  issue_reference: "%{provider} #%{number}"
425
426
  states:
@@ -582,6 +583,7 @@ de:
582
583
  snapshot_stale: aus einem früheren Auftreten — spätere lieferten keinen Kontext
583
584
  snapshot_fidelity_lite: reduzierte Erfassung (der Storm-Schutz hat die Kontext-Payloads verworfen)
584
585
  snapshot_fidelity_minimal: während eines Storms gezählt — es wurde kein Kontext erfasst
586
+ snapshot_fidelity_partial: gemischter Snapshot — einige Felder stammen aus einem früheren Vorkommen
585
587
  snapshot_unknown: erfasst, bevor RED die Herkunft von Momentaufnahmen aufzeichnete
586
588
  similar_errors:
587
589
  title: Ähnliche Fehler
@@ -939,6 +941,7 @@ de:
939
941
  rate_per_hour: "%{value}/Std."
940
942
  affected_users_incomplete: mindestens — der Storm-Schutz hat Detaildaten pro Ereignis verworfen
941
943
  data_unavailable: Statistiken sind derzeit nicht verfügbar — die Zahlen unten sind keine Messung.
944
+ event_timing_incomplete: "Zeitangaben sind unvollständig — der Sturmschutz hat Zeitstempel verworfen; die Gesamtzahlen stimmen."
942
945
  unresolved: Offen
943
946
  unresolved_hint: Behebung ausstehend
944
947
  resolution_rate: Behebungsquote
@@ -972,6 +975,16 @@ de:
972
975
  types:
973
976
  one: "%{count} Typ"
974
977
  other: "%{count} Typen"
978
+ errors_page:
979
+ csrf:
980
+ title: "Ihr Sitzungstoken ist abgelaufen"
981
+ message: "Gehen Sie zurück, laden Sie die Seite neu und versuchen Sie es erneut. Es wurde nichts geändert."
982
+ bad_request:
983
+ title: "Diese Anfrage konnte nicht verarbeitet werden"
984
+ message: "Ein erforderlicher Wert fehlte oder war fehlerhaft. Gehen Sie zurück und versuchen Sie es erneut."
985
+ not_acceptable:
986
+ title: "Dieses Format ist nicht verfügbar"
987
+ message: "Das Dashboard kann das angeforderte Format nicht erzeugen."
975
988
  settings:
976
989
  title: Einstellungen
977
990
  version: v%{version}
@@ -1086,6 +1099,8 @@ de:
1086
1099
  gesetzt)
1087
1100
  environment: Umgebung, der Fehler zugeordnet werden (Rails.env, falls nicht
1088
1101
  gesetzt)
1102
+ notification_burst_limit: "Benachrichtigungen über neue Fehler pro Zeitfenster und Prozess (0 = keine Obergrenze)"
1103
+ notification_burst_window_seconds: "Länge des Zeitfensters für Benachrichtigungen über neue Fehler"
1089
1104
  notification_environments: Nur für diese Umgebungen benachrichtigen (alle,
1090
1105
  falls nicht gesetzt)
1091
1106
  database: Aktive Datenbank für Fehlerprotokolle
@@ -1193,6 +1208,16 @@ de:
1193
1208
  linked: Issue erfolgreich verknüpft
1194
1209
  create_failed: 'Issue konnte nicht erstellt werden: %{reason}'
1195
1210
  link_failed: 'Issue konnte nicht verknüpft werden: %{reason}'
1211
+ status:
1212
+ updated: "Status geändert zu %{status}"
1213
+ invalid_transition: "Der Status kann nicht von %{from} zu %{to} geändert werden"
1214
+ unknown: "Diesen Status gibt es nicht"
1215
+ snooze:
1216
+ invalid_hours: "Die Zurückstelldauer muss eine ganze Zahl von Stunden zwischen 1 und %{max} sein"
1217
+ priority:
1218
+ invalid: "Diese Prioritätsstufe gibt es nicht"
1219
+ assign:
1220
+ blank: "Geben Sie einen Namen ein, dem dieser Fehler zugewiesen werden soll"
1196
1221
  not_enabled: "%{feature} ist nicht aktiviert. Aktivieren Sie es in %{file}"
1197
1222
  not_enabled_plural: "%{feature} sind nicht aktiviert. Aktivieren Sie sie in
1198
1223
  %{file}"
@@ -1238,6 +1263,7 @@ de:
1238
1263
  not_configured: Issue-Tracking ist nicht konfiguriert
1239
1264
  already_linked: 'Der Fehler hat bereits ein verknüpftes Issue: %{url}'
1240
1265
  url_required: Die Issue-URL ist erforderlich
1266
+ url_invalid: Die Issue-URL muss mit http:// oder https:// beginnen
1241
1267
  error_not_found: 'Fehler nicht gefunden: %{id}'
1242
1268
  batch_failed:
1243
1269
  mute:
@@ -1362,6 +1388,10 @@ de:
1362
1388
  one: "%{value} Fehler"
1363
1389
  other: "%{value} Fehler"
1364
1390
  view_in_dashboard: Im Dashboard ansehen
1391
+ burst:
1392
+ suppressed: "In %{application} wurden innerhalb von %{window} Sekunden mehr als %{limit} neue Fehler gemeldet."
1393
+ still_recorded: "Benachrichtigungen über weitere neue Fehler sind bis zum Ende dieses Zeitfensters pausiert; jeder Fehler wird weiterhin erfasst."
1394
+ dashboard: "Dashboard: %{url}"
1365
1395
  storm:
1366
1396
  detected: Fehlersturm in %{application} um %{started_at} erkannt.
1367
1397
  mode: