rails_error_dashboard 0.6.0 → 0.6.2

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 (25) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/rails_error_dashboard/application_controller.rb +37 -12
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +22 -2
  4. data/app/helpers/rails_error_dashboard/application_helper.rb +27 -0
  5. data/app/views/layouts/rails_error_dashboard.html.erb +152 -33
  6. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +3 -3
  7. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +13 -15
  8. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +1 -1
  9. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +6 -6
  10. data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +3 -3
  11. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +1 -1
  12. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +3 -3
  13. data/app/views/rails_error_dashboard/errors/analytics.html.erb +8 -8
  14. data/app/views/rails_error_dashboard/errors/index.html.erb +66 -43
  15. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +1 -1
  16. data/app/views/rails_error_dashboard/errors/settings.html.erb +24 -2
  17. data/app/views/rails_error_dashboard/errors/show.html.erb +12 -12
  18. data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +12 -12
  19. data/config/routes.rb +1 -0
  20. data/lib/rails_error_dashboard/services/error_broadcaster.rb +1 -1
  21. data/lib/rails_error_dashboard/services/system_health_snapshot.rb +1 -1
  22. data/lib/rails_error_dashboard/test_error.rb +7 -0
  23. data/lib/rails_error_dashboard/version.rb +1 -1
  24. data/lib/rails_error_dashboard.rb +1 -0
  25. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa8243a13e9eccf5cedcffea0600d19a904e7dbba1b372dd3c633fece3ab6674
4
- data.tar.gz: 0aa65c2bfd6038ebd7d35901d8889a4e2ffc07bf653746a43650d7a9b6685257
3
+ metadata.gz: d065c1edba3f66205914ba82dbfc5aed936e0df62fd96df8f1dcd1667bc119af
4
+ data.tar.gz: dff67e2453ec7d33429b5a4c07754b1e7d9c3103946cd5fdcb6ed282a3a14bce
5
5
  SHA512:
6
- metadata.gz: c85d90b5c8e36466306e9bda5466b8cce46e8dae335c218d946cfde374d1243835d5ec3eae9457fb75f80dfe48a339e71cef7f1b0439d2ccc1419a123e8fab94
7
- data.tar.gz: 016bdd5a313427c5b07c72a1808262514e74159fb60761ff6903c6d64dc7c1127032df1f8c6ea5da64f858322be6ed734e249a3026a9d4b7dd5a0b8e50239628
6
+ metadata.gz: 14b55055cb0a3c765824778c82cb7d8f33cd2d4ad5c445ea2e568fc10c51aec611271e0610fa295c8318a4701bba6a051db5f313ee6f12b4d6ac04be331df85f
7
+ data.tar.gz: 0c154f205b6622ad747e3e037864b0e2bc2078f72098f71c2b26fb42d717a1b09a275e87ec29f2e34864684505b66b169f96efe8cc13910dc6a9f4c58fca5a4c
@@ -23,23 +23,27 @@ module RailsErrorDashboard
23
23
  Rails.logger.error("Params: #{params.inspect}")
24
24
  Rails.logger.error(exception.backtrace&.first(10)&.join("\n")) if exception.backtrace
25
25
 
26
- # Render user-friendly error page
27
- render plain: "The Error Dashboard encountered an issue displaying this page.\n\n" \
28
- "Your application is unaffected - this is only a dashboard display error.\n\n" \
29
- "Error: #{exception.message}\n\n" \
30
- "Check Rails logs for details: [RailsErrorDashboard]",
31
- status: :internal_server_error,
32
- layout: false
26
+ render_dashboard_error(
27
+ icon: "bi-exclamation-triangle",
28
+ icon_style: "background: var(--status-warning-bg); color: var(--status-warning);",
29
+ title: "Something went wrong",
30
+ message: "The Error Dashboard encountered an issue displaying this page. Your application is unaffected.",
31
+ detail: exception.message,
32
+ status: :internal_server_error
33
+ )
33
34
  end
34
35
 
35
36
  # Handle record not found — return 404 instead of 500
36
37
  rescue_from ActiveRecord::RecordNotFound do |exception|
37
38
  Rails.logger.warn("[RailsErrorDashboard] Record not found: #{exception.message}")
38
- render plain: "The requested error was not found.\n\n" \
39
- "It may have been deleted or the ID is invalid.\n\n" \
40
- "Error: #{exception.message}",
41
- status: :not_found,
42
- layout: false
39
+
40
+ render_dashboard_error(
41
+ icon: "bi-search",
42
+ title: "The requested error was not found",
43
+ message: "It may have been deleted or the ID is invalid.",
44
+ detail: exception.message,
45
+ status: :not_found
46
+ )
43
47
  end
44
48
 
45
49
  # Handle Pagy pagination errors — redirect to page 1
@@ -47,5 +51,26 @@ module RailsErrorDashboard
47
51
  Rails.logger.warn("[RailsErrorDashboard] Pagination error: #{exception.message}")
48
52
  redirect_to request.path, status: :moved_permanently
49
53
  end
54
+
55
+ private
56
+
57
+ def render_dashboard_error(icon:, title:, message:, detail: nil, icon_style: nil, status: :internal_server_error)
58
+ set_common_view_variables
59
+ error_html = <<~ERB
60
+ <div class="red-empty-state" style="margin-top: var(--space-6);">
61
+ <div class="red-empty-state-icon"#{icon_style ? " style=\"#{icon_style}\"" : ""}><i class="bi #{icon}"></i></div>
62
+ <div class="red-empty-state-title">#{ERB::Util.html_escape(title)}</div>
63
+ <div class="red-empty-state-message">#{ERB::Util.html_escape(message)}</div>
64
+ #{"<div style=\"font-size: 12px; color: var(--text-tertiary); margin-top: var(--space-2); font-family: var(--font-mono);\">" + ERB::Util.html_escape(detail) + "</div>" if detail}
65
+ <a href="#{errors_path}" class="red-empty-state-cta" style="margin-top: var(--space-4);"><i class="bi bi-arrow-left"></i> Back to errors</a>
66
+ </div>
67
+ ERB
68
+ render html: error_html.html_safe, status: status, layout: "rails_error_dashboard"
69
+ end
70
+
71
+ def set_common_view_variables
72
+ @applications = Application.ordered_by_name.pluck(:name, :id) rescue []
73
+ @default_credentials_warning = RailsErrorDashboard.configuration.default_credentials? rescue false
74
+ end
50
75
  end
51
76
  end
@@ -60,8 +60,11 @@ module RailsErrorDashboard
60
60
  # Use Query to get filtered errors
61
61
  errors_query = Queries::ErrorsList.call(filter_params)
62
62
 
63
- # Paginate with Pagy
64
- @pagy, @errors = pagy(:offset, errors_query, limit: params[:per_page] || 25)
63
+ # Paginate with Pagy. raise_range_error makes pagy 43.x raise Pagy::RangeError
64
+ # on out-of-range pages (e.g. ?page=999999) so application_controller.rb's
65
+ # rescue_from can redirect to a valid page. Without this, pagy 43.x silently
66
+ # returns an empty result and users see "All clear!" instead of their data.
67
+ @pagy, @errors = pagy(:offset, errors_query, limit: params[:per_page] || 25, raise_range_error: true)
65
68
 
66
69
  # Get dashboard stats using Query (pass application filter)
67
70
  @stats = Queries::DashboardStats.call(application_id: @current_application_id)
@@ -560,6 +563,23 @@ module RailsErrorDashboard
560
563
  redirect_back fallback_location: errors_path
561
564
  end
562
565
 
566
+ def test_error
567
+ exception = RailsErrorDashboard::TestError.new(
568
+ "[RED Test] This is a test error sent from the dashboard to verify " \
569
+ "that error capture and notification delivery are working correctly. " \
570
+ "It is safe to resolve or delete this error."
571
+ )
572
+ exception.set_backtrace(caller)
573
+
574
+ Commands::LogError.call(exception, { request: request, source: "dashboard.test_error" })
575
+
576
+ flash[:notice] = "Test error logged successfully. Check your notification channels (Slack, Discord, email, etc.) to confirm delivery."
577
+ redirect_to errors_path(**app_context_params)
578
+ rescue => e
579
+ flash[:alert] = "Failed to log test error: #{e.message}"
580
+ redirect_to settings_path(**app_context_params)
581
+ end
582
+
563
583
  def settings
564
584
  @config = RailsErrorDashboard.configuration
565
585
  end
@@ -1,5 +1,32 @@
1
1
  module RailsErrorDashboard
2
2
  module ApplicationHelper
3
+ # Returns the host app's CSP nonce (if any) so inline <script> tags pass strict CSP.
4
+ # Falls back to nil when the host has no CSP configured — in that case the script tag
5
+ # works without a nonce attribute. Strict CSPs (script-src 'self' 'nonce-...') require
6
+ # this; without it the script is blocked.
7
+ def red_csp_nonce
8
+ return nil unless respond_to?(:content_security_policy_nonce)
9
+ content_security_policy_nonce
10
+ rescue StandardError
11
+ nil
12
+ end
13
+
14
+ # Wraps an inline <script> block with the host app's CSP nonce when available.
15
+ # Use everywhere we have <script>...</script> in our views so they pass strict CSP.
16
+ #
17
+ # <%= red_javascript_tag do %>
18
+ # console.log('hi');
19
+ # <% end %>
20
+ def red_javascript_tag(&block)
21
+ nonce = red_csp_nonce
22
+ content = capture(&block)
23
+ if nonce
24
+ content_tag(:script, content.html_safe, nonce: nonce)
25
+ else
26
+ content_tag(:script, content.html_safe)
27
+ end
28
+ end
29
+
3
30
  # Returns Bootstrap color class for error severity
4
31
  # Uses Catppuccin Mocha colors in dark theme via CSS variables
5
32
  # @param severity [Symbol] The severity level (:critical, :high, :medium, :low, :info)
@@ -39,7 +39,7 @@
39
39
  <% end %>
40
40
 
41
41
  <!-- Early theme detection (prevents flash of wrong theme) -->
42
- <script>
42
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
43
43
  (function(){
44
44
  var t = localStorage.getItem('red-theme');
45
45
  if (t === 'dark' || (!t && window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches)) {
@@ -135,9 +135,9 @@
135
135
  --surface-secondary: #313244;
136
136
  --surface-tertiary: #45475a;
137
137
  --surface-hover: #313244;
138
- --text-primary: #cdd6f4;
139
- --text-secondary: #a6adc8;
140
- --text-tertiary: #6c7086;
138
+ --text-primary: #ffffff;
139
+ --text-secondary: #cdd6f4;
140
+ --text-tertiary: #a6adc8;
141
141
  --border-primary: #313244;
142
142
  --border-secondary: #45475a;
143
143
  --accent: <%= ac[:dark] %>;
@@ -279,17 +279,21 @@ dd { color: var(--text-primary); }
279
279
  .ps-3 { padding-left: var(--space-4); }
280
280
  .pe-2 { padding-right: var(--space-2); }
281
281
 
282
- /* Display */
283
- .d-none { display: none; }
284
- .d-block { display: block; }
285
- .d-inline { display: inline; }
286
- .d-inline-block { display: inline-block; }
287
- @media (min-width: 576px) { .d-sm-inline { display: inline; } .d-sm-block { display: block; } }
288
- @media (min-width: 768px) { .d-md-block { display: block; } .d-md-none { display: none; } .d-md-flex { display: flex; } .d-md-inline { display: inline; } .ms-sm-auto { margin-left: auto; } .px-md-4 { padding-left: var(--space-6); padding-right: var(--space-6); } }
282
+ /* Display (use !important to match Bootstrap utility behavior) */
283
+ .d-none { display: none !important; }
284
+ .d-block { display: block !important; }
285
+ .d-inline { display: inline !important; }
286
+ .d-inline-block { display: inline-block !important; }
287
+ @media (min-width: 576px) { .d-sm-inline { display: inline !important; } .d-sm-block { display: block !important; } }
288
+ @media (min-width: 768px) { .d-md-block { display: block !important; } .d-md-none { display: none !important; } .d-md-flex { display: flex !important; } .d-md-inline { display: inline !important; } .ms-sm-auto { margin-left: auto; } .px-md-4 { padding-left: var(--space-6); padding-right: var(--space-6); } }
289
289
 
290
290
  /* Text */
291
- .text-muted { color: var(--text-tertiary); }
291
+ .text-muted { color: var(--text-secondary); }
292
292
  .text-secondary { color: var(--text-secondary); }
293
+
294
+ /* Bootstrap compat: table-light thead */
295
+ .table-light, thead.table-light { background: var(--surface-secondary); }
296
+ .table-light th { color: var(--text-secondary); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
293
297
  .text-center { text-align: center; }
294
298
  .text-end { text-align: right; }
295
299
  .text-start { text-align: left; }
@@ -632,6 +636,10 @@ dd { color: var(--text-primary); }
632
636
  .red-sidebar.collapsed { display: none; }
633
637
  }
634
638
 
639
+ /* Error detail responsive grid */
640
+ .red-detail-grid { display: grid; grid-template-columns: 1fr 260px; gap: var(--space-4); }
641
+ @media (max-width: 1023.98px) { .red-detail-grid { grid-template-columns: 1fr; } }
642
+
635
643
  /* Env badge */
636
644
  .red-env-badge {
637
645
  padding: 3px 10px; font-size: 11px; font-weight: 600;
@@ -872,6 +880,12 @@ main { animation: contentFadeIn 0.15s ease; }
872
880
  details { background: var(--surface-primary); border: 1px solid var(--border-primary); border-radius: var(--radius-md); }
873
881
  summary { padding: var(--space-3) var(--space-4); cursor: pointer; font-weight: 500; color: var(--text-primary); background: var(--surface-hover); }
874
882
  details[open] summary { border-bottom: 1px solid var(--border-primary); }
883
+
884
+ /* Error row hover (replaces inline onmouseenter/onmouseleave) */
885
+ tr[data-red-row-href] { transition: background 0.1s ease; }
886
+ tr[data-red-row-href]:hover { background: var(--surface-hover); }
887
+ tr[data-red-row-href] .sev-bar { transition: opacity 0.1s ease; }
888
+ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
875
889
  </style>
876
890
  </head>
877
891
 
@@ -884,7 +898,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
884
898
  <!-- Sidebar -->
885
899
  <nav class="red-sidebar d-none d-md-flex" id="sidebar">
886
900
  <!-- Logo -->
887
- <div class="red-sidebar-logo">
901
+ <a href="/" class="red-sidebar-logo" title="Back to <%= Rails.application.class.module_parent_name %>" style="text-decoration: none; color: inherit;">
888
902
  <div class="red-sidebar-logo-icon">R</div>
889
903
  <div>
890
904
  <div style="font-size: 14px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em;">RED</div>
@@ -893,7 +907,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
893
907
  end %>
894
908
  <div style="font-size: 10px; color: var(--text-tertiary); margin-top: -2px;"><%= sidebar_app_name || Rails.application.class.module_parent_name %> &middot; <%= Rails.env %></div>
895
909
  </div>
896
- </div>
910
+ </a>
897
911
 
898
912
  <!-- Nav groups -->
899
913
  <div style="flex: 1; overflow: auto; padding: var(--space-3) 0;">
@@ -949,7 +963,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
949
963
 
950
964
  <% if health_items.any? %>
951
965
  <div style="margin-bottom: var(--space-2);" id="navHealthSection">
952
- <button class="red-sidebar-section-label" onclick="toggleNavSection('navHealthItems')">
966
+ <button type="button" class="red-sidebar-section-label" data-red-action="toggle-nav-section" data-red-target="navHealthItems">
953
967
  Health
954
968
  <i class="bi bi-chevron-down" style="font-size: 10px;" id="navHealthChevron"></i>
955
969
  </button>
@@ -980,7 +994,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
980
994
 
981
995
  <% if diag_items.any? %>
982
996
  <div style="margin-bottom: var(--space-2);" id="navDiagSection">
983
- <button class="red-sidebar-section-label" onclick="toggleNavSection('navDiagItems')">
997
+ <button type="button" class="red-sidebar-section-label" data-red-action="toggle-nav-section" data-red-target="navDiagItems">
984
998
  Diagnostics
985
999
  <i class="bi bi-chevron-down" style="font-size: 10px;" id="navDiagChevron"></i>
986
1000
  </button>
@@ -998,7 +1012,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
998
1012
 
999
1013
  <!-- INSIGHTS section -->
1000
1014
  <div style="margin-bottom: var(--space-2);" id="navInsightsSection">
1001
- <button class="red-sidebar-section-label" onclick="toggleNavSection('navInsightsItems')">
1015
+ <button type="button" class="red-sidebar-section-label" data-red-action="toggle-nav-section" data-red-target="navInsightsItems">
1002
1016
  Insights
1003
1017
  <i class="bi bi-chevron-down" style="font-size: 10px;" id="navInsightsChevron"></i>
1004
1018
  </button>
@@ -1062,7 +1076,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
1062
1076
  <% if params[:application_id].present? %>
1063
1077
  <%= @applications.find { |name, id| id.to_s == params[:application_id].to_s }&.first || 'Unknown' %>
1064
1078
  <% else %>
1065
- All Apps
1079
+ All Apps (<%= @applications.size %>)
1066
1080
  <% end %>
1067
1081
  </button>
1068
1082
  <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="appSwitcher">
@@ -1094,7 +1108,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
1094
1108
  </ul>
1095
1109
  </div>
1096
1110
  <% end %>
1097
- <kbd>?</kbd>
1111
+ <button type="button" title="Keyboard shortcuts (?)" data-bs-toggle="modal" data-bs-target="#keyboardShortcutsModal" style="background: none; border: none; padding: 0; cursor: pointer; line-height: 1;"><kbd>?</kbd></button>
1098
1112
  <button class="red-theme-toggle" id="themeToggle" title="Toggle theme">
1099
1113
  <i class="bi bi-moon" id="themeIcon"></i>
1100
1114
  </button>
@@ -1105,14 +1119,16 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
1105
1119
  <!-- Main content -->
1106
1120
  <main style="flex: 1; padding: var(--space-6) var(--space-8); max-width: 1200px; width: 100%; margin: 0 auto;">
1107
1121
  <% if @default_credentials_warning %>
1108
- <div class="alert alert-warning" style="display: flex; align-items: center; gap: 10px; margin-top: var(--space-2); margin-bottom: var(--space-4); border-left: 4px solid var(--status-warning);">
1109
- <i class="bi bi-exclamation-triangle-fill" style="font-size: 18px;"></i>
1110
- <div>
1122
+ <div id="security-warning" class="alert alert-warning" style="display: flex; align-items: center; gap: 10px; margin-top: var(--space-2); margin-bottom: var(--space-4); border-left: 4px solid var(--status-warning);">
1123
+ <i class="bi bi-exclamation-triangle-fill" style="font-size: 18px; flex-shrink: 0;"></i>
1124
+ <div style="flex: 1;">
1111
1125
  <strong>Security Warning:</strong> You are using default credentials (gandalf/youshallnotpass).
1112
1126
  Set <code>ERROR_DASHBOARD_USER</code> and <code>ERROR_DASHBOARD_PASSWORD</code> environment variables,
1113
1127
  or configure <code>authenticate_with</code> in your initializer.
1114
1128
  </div>
1129
+ <button type="button" data-red-action="dismiss-creds-warning" style="background: none; border: none; cursor: pointer; color: var(--text-secondary); font-size: 18px; padding: 0 4px; flex-shrink: 0; line-height: 1;" title="Dismiss for this session">&times;</button>
1115
1130
  </div>
1131
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>try { if (sessionStorage.getItem('red_dismiss_creds_warning') === '1') { document.getElementById('security-warning').style.display = 'none'; } } catch(e) {}</script>
1116
1132
  <% end %>
1117
1133
  <%= yield %>
1118
1134
  </main>
@@ -1124,7 +1140,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
1124
1140
  Built with <a href="https://github.com/AnjanJ/rails_error_dashboard" target="_blank"><strong>RED</strong> &mdash; Rails Error Dashboard</a>
1125
1141
  </p>
1126
1142
  <p style="font-size: 12px; color: var(--text-tertiary); margin: 0;">
1127
- Created by <a href="https://www.anjan.dev/" target="_blank">Anjan Jagirdar</a>
1143
+ v<%= RailsErrorDashboard::VERSION %> &middot; Built with ❤️ by <a href="https://anjan.dev" target="_blank">Anjan Jagirdar</a>
1128
1144
  </p>
1129
1145
  </footer>
1130
1146
  </div>
@@ -1133,10 +1149,10 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
1133
1149
  <!-- Mobile Sidebar (Offcanvas) -->
1134
1150
  <div class="offcanvas offcanvas-start" tabindex="-1" id="sidebarMenu" aria-labelledby="sidebarMenuLabel">
1135
1151
  <div class="offcanvas-header">
1136
- <div style="display: flex; align-items: center; gap: 10px;">
1152
+ <a href="/" style="display: flex; align-items: center; gap: 10px; text-decoration: none; color: inherit;" title="Back to app home">
1137
1153
  <div class="red-sidebar-logo-icon">R</div>
1138
1154
  <strong>RED</strong>
1139
- </div>
1155
+ </a>
1140
1156
  <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
1141
1157
  </div>
1142
1158
  <div class="offcanvas-body">
@@ -1201,7 +1217,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
1201
1217
  <script src="https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.umd.js"></script>
1202
1218
 
1203
1219
  <!-- Loading State Controller -->
1204
- <script>
1220
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
1205
1221
  (function() {
1206
1222
  'use strict';
1207
1223
  if (typeof Stimulus === 'undefined') return;
@@ -1280,7 +1296,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
1280
1296
  </script>
1281
1297
 
1282
1298
  <!-- Syntax Highlighting -->
1283
- <script>
1299
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
1284
1300
  (function() {
1285
1301
  'use strict';
1286
1302
  if (typeof hljs === 'undefined') return;
@@ -1324,7 +1340,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
1324
1340
  </script>
1325
1341
 
1326
1342
  <!-- Theme Toggle + Chart Theme -->
1327
- <script>
1343
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
1328
1344
  document.addEventListener('DOMContentLoaded', function() {
1329
1345
  var themeToggle = document.getElementById('themeToggle');
1330
1346
  var themeIcon = document.getElementById('themeIcon');
@@ -1411,7 +1427,7 @@ document.addEventListener('DOMContentLoaded', function() {
1411
1427
  </script>
1412
1428
 
1413
1429
  <!-- Utilities (tooltips, clipboard, time conversion, keyboard shortcuts) -->
1414
- <script>
1430
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
1415
1431
  document.addEventListener('DOMContentLoaded', function() {
1416
1432
 
1417
1433
  // Tooltips
@@ -1581,11 +1597,114 @@ document.addEventListener('DOMContentLoaded', function() {
1581
1597
  window.location.href = '<%= analytics_errors_path %>';
1582
1598
  }
1583
1599
  });
1600
+
1601
+ // ==========================================================================
1602
+ // Delegated event handlers for [data-red-action]
1603
+ // Replaces inline event handlers (which strict CSP blocks).
1604
+ // ==========================================================================
1605
+ function findActionEl(target) {
1606
+ var el = target;
1607
+ while (el && el !== document.body) {
1608
+ if (el.dataset && el.dataset.redAction) return el;
1609
+ el = el.parentElement;
1610
+ }
1611
+ return null;
1612
+ }
1613
+
1614
+ document.addEventListener('click', function(e) {
1615
+ // Row navigation: <tr data-red-row-href="/path"> — clicking anywhere on the row
1616
+ // navigates, except when clicking inside a checkbox cell or interactive elements.
1617
+ var rowEl = e.target.closest && e.target.closest('[data-red-row-href]');
1618
+ if (rowEl && !e.defaultPrevented) {
1619
+ var inIgnore = e.target.closest('input, button, a, label, [data-red-stop]');
1620
+ if (!inIgnore) {
1621
+ window.location = rowEl.dataset.redRowHref;
1622
+ return;
1623
+ }
1624
+ }
1625
+
1626
+ var actionEl = findActionEl(e.target);
1627
+ if (!actionEl) return;
1628
+ var action = actionEl.dataset.redAction;
1629
+
1630
+ switch (action) {
1631
+ case 'navigate':
1632
+ if (actionEl.dataset.redHref) {
1633
+ window.location = actionEl.dataset.redHref;
1634
+ }
1635
+ break;
1636
+ case 'copy':
1637
+ if (typeof window.copyToClipboard === 'function') {
1638
+ var text = actionEl.dataset.redCopyText;
1639
+ if (text == null && actionEl.dataset.redCopyFrom) {
1640
+ text = actionEl.dataset[actionEl.dataset.redCopyFrom];
1641
+ }
1642
+ window.copyToClipboard(text || '', actionEl);
1643
+ }
1644
+ break;
1645
+ case 'copy-markdown':
1646
+ if (typeof window.copyToClipboard === 'function') {
1647
+ var md = (actionEl.dataset.markdown || '').replace(/\\(.)/g, function(m, c) {
1648
+ return c === 'n' ? '\n' : c;
1649
+ });
1650
+ window.copyToClipboard(md, actionEl);
1651
+ }
1652
+ break;
1653
+ case 'download-json':
1654
+ if (typeof window.downloadErrorJSON === 'function') {
1655
+ // Provide currentTarget pointing at the button so the handler can show feedback.
1656
+ window.downloadErrorJSON({ currentTarget: actionEl });
1657
+ }
1658
+ break;
1659
+ case 'switch-tab':
1660
+ if (typeof window.switchTab === 'function' && actionEl.dataset.redTab) {
1661
+ window.switchTab(actionEl.dataset.redTab);
1662
+ }
1663
+ break;
1664
+ case 'toggle-display':
1665
+ var tgtSel = actionEl.dataset.redTarget;
1666
+ if (tgtSel) {
1667
+ var tgt = document.querySelector(tgtSel);
1668
+ if (tgt) {
1669
+ var openDisplay = actionEl.dataset.redDisplay || 'block';
1670
+ tgt.style.display = (tgt.style.display === 'none' || tgt.style.display === '') ? openDisplay : 'none';
1671
+ }
1672
+ }
1673
+ break;
1674
+ case 'toggle-next':
1675
+ var nextEl = actionEl.nextElementSibling;
1676
+ if (nextEl) {
1677
+ var isHidden = nextEl.style.display === 'none' || nextEl.style.display === '';
1678
+ nextEl.style.display = isHidden ? (actionEl.dataset.redDisplay || 'block') : 'none';
1679
+ var chev = actionEl.querySelector('.chev');
1680
+ if (chev) {
1681
+ chev.className = isHidden ? 'bi bi-chevron-up chev' : 'bi bi-chevron-down chev';
1682
+ }
1683
+ }
1684
+ break;
1685
+ case 'dismiss-creds-warning':
1686
+ if (actionEl.parentElement) actionEl.parentElement.style.display = 'none';
1687
+ try { sessionStorage.setItem('red_dismiss_creds_warning', '1'); } catch (err) {}
1688
+ break;
1689
+ case 'toggle-nav-section':
1690
+ if (typeof window.toggleNavSection === 'function' && actionEl.dataset.redTarget) {
1691
+ window.toggleNavSection(actionEl.dataset.redTarget);
1692
+ }
1693
+ break;
1694
+ case 'confirm-submit':
1695
+ var msg = actionEl.dataset.redConfirmMessage || 'Are you sure?';
1696
+ if (!window.confirm(msg)) {
1697
+ e.preventDefault();
1698
+ e.stopPropagation();
1699
+ }
1700
+ break;
1701
+ }
1702
+ });
1584
1703
  });
1585
1704
  </script>
1586
1705
 
1587
1706
  <!-- Sidebar Toggle + Nav Section Toggle -->
1588
- <script>
1707
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
1589
1708
  document.addEventListener('DOMContentLoaded', function() {
1590
1709
  var sidebarToggle = document.getElementById('sidebarToggle');
1591
1710
  var sidebar = document.getElementById('sidebar');
@@ -1612,7 +1731,7 @@ document.addEventListener('DOMContentLoaded', function() {
1612
1731
  });
1613
1732
 
1614
1733
  // Nav section collapse/expand
1615
- function toggleNavSection(itemsId) {
1734
+ window.toggleNavSection = function(itemsId) {
1616
1735
  var items = document.getElementById(itemsId);
1617
1736
  var chevron = document.getElementById(itemsId.replace('Items', 'Chevron'));
1618
1737
  if (items) {
@@ -1621,7 +1740,7 @@ function toggleNavSection(itemsId) {
1621
1740
  if (chevron) chevron.className = hidden ? 'bi bi-chevron-down' : 'bi bi-chevron-right';
1622
1741
  localStorage.setItem('red_nav_' + itemsId, hidden ? 'open' : 'closed');
1623
1742
  }
1624
- }
1743
+ };
1625
1744
 
1626
1745
  // Restore nav section states
1627
1746
  document.addEventListener('DOMContentLoaded', function() {
@@ -1638,7 +1757,7 @@ document.addEventListener('DOMContentLoaded', function() {
1638
1757
  </script>
1639
1758
 
1640
1759
  <!-- Flash Messages -->
1641
- <script>
1760
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
1642
1761
  <% if defined?(flash) && flash.present? %>
1643
1762
  <% if flash[:notice] %>
1644
1763
  showToast('<%= j flash[:notice] %>', 'success');
@@ -13,7 +13,7 @@
13
13
  </span>
14
14
  <% end %>
15
15
  </h5>
16
- <button class="btn btn-sm btn-outline-light" onclick="copyToClipboard('<%= j error.error_type %>', this)" title="Copy error type">
16
+ <button type="button" class="btn btn-sm btn-outline-light" data-red-action="copy" data-red-copy-text="<%= j error.error_type %>" title="Copy error type">
17
17
  <i class="bi bi-clipboard"></i>
18
18
  </button>
19
19
  </div>
@@ -21,7 +21,7 @@
21
21
  <div class="card-body">
22
22
  <div class="d-flex justify-content-between align-items-center mb-3">
23
23
  <h6 class="text-muted mb-0">Error Message:</h6>
24
- <button class="btn btn-sm btn-outline-secondary" onclick="copyToClipboard('<%= j error.message %>', this)" title="Copy error message">
24
+ <button type="button" class="btn btn-sm btn-outline-secondary" data-red-action="copy" data-red-copy-text="<%= j error.message %>" title="Copy error message">
25
25
  <i class="bi bi-clipboard"></i> Copy
26
26
  </button>
27
27
  </div>
@@ -95,7 +95,7 @@
95
95
  <% end %>
96
96
  </h6>
97
97
  <% if error.backtrace.present? %>
98
- <button class="btn btn-sm btn-outline-secondary" onclick="copyToClipboard(`<%= j error.backtrace %>`, this)" title="Copy full backtrace">
98
+ <button type="button" class="btn btn-sm btn-outline-secondary" data-red-action="copy" data-red-copy-text="<%= j error.backtrace %>" title="Copy full backtrace">
99
99
  <i class="bi bi-clipboard"></i> Copy Full Backtrace
100
100
  </button>
101
101
  <% end %>
@@ -1,13 +1,11 @@
1
1
  <tr id="error_<%= error.id %>"
2
- onclick="window.location='<%= error_path(error, **app_context) %>'"
3
- style="border-bottom: 1px solid var(--border-primary); cursor: pointer; transition: background 0.1s ease;"
4
- onmouseenter="this.style.background='var(--surface-hover)'; this.querySelector('.sev-bar').style.opacity='1';"
5
- onmouseleave="this.style.background='transparent'; this.querySelector('.sev-bar').style.opacity='0';">
6
- <td style="padding: var(--space-3) var(--space-4); text-align: center; position: relative;" onclick="event.stopPropagation(); var cb = this.querySelector('input'); cb.checked = !cb.checked; cb.dispatchEvent(new Event('change'));">
7
- <span class="sev-bar" style="position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: <%= severity_color_var(error.severity) %>; opacity: 0; transition: opacity 0.1s ease; border-radius: 0 2px 2px 0;"></span>
8
- <input type="checkbox" class="error-checkbox form-check-input" value="<%= error.id %>" data-error-id="<%= error.id %>" onclick="event.stopPropagation();" style="accent-color: var(--accent);">
2
+ data-red-row-href="<%= error_path(error, **app_context) %>"
3
+ style="border-bottom: 1px solid var(--border-primary); cursor: pointer;">
4
+ <td style="padding: var(--space-3) var(--space-4); text-align: center; position: relative;" data-red-stop>
5
+ <span class="sev-bar" style="position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: <%= severity_color_var(error.severity) %>; opacity: 0; border-radius: 0 2px 2px 0;"></span>
6
+ <input type="checkbox" class="error-checkbox form-check-input" value="<%= error.id %>" data-error-id="<%= error.id %>" data-red-stop style="accent-color: var(--accent);">
9
7
  </td>
10
- <td style="padding: var(--space-3) var(--space-4);" onclick="window.location='<%= error_path(error, **app_context) %>'">
8
+ <td style="padding: var(--space-3) var(--space-4);">
11
9
  <div style="display: flex; align-items: center; gap: 8px;">
12
10
  <span class="badge bg-<%= severity_color(error.severity) %>" style="display: inline-flex; align-items: center; gap: 4px;">
13
11
  <span style="width: 6px; height: 6px; border-radius: 50%; background: currentColor;"></span>
@@ -20,11 +18,11 @@
20
18
  <span style="display: inline-block; padding: 1px 6px; font-size: 9px; font-weight: 600; border-radius: var(--radius-full); background: var(--status-success-bg); color: var(--status-success); margin-left: 4px; text-transform: uppercase;">NEW</span>
21
19
  <% end %>
22
20
  </div>
23
- <div style="color: var(--text-tertiary); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 400px;" title="<%= error.message %>"><%= error.message %></div>
21
+ <div style="color: var(--text-tertiary); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="<%= error.message %>"><%= error.message %></div>
24
22
  </div>
25
23
  </div>
26
24
  </td>
27
- <td style="padding: var(--space-3) var(--space-4);" onclick="window.location='<%= error_path(error, **app_context) %>'">
25
+ <td style="padding: var(--space-3) var(--space-4);">
28
26
  <%
29
27
  # Use the model's status field if it has a meaningful workflow status
30
28
  raw_status = error.respond_to?(:status) ? error.status : nil
@@ -58,26 +56,26 @@
58
56
  <i class="bi bi-arrow-counterclockwise" style="color: var(--status-warning); margin-left: 4px; font-size: 12px;" title="Reopened"></i>
59
57
  <% end %>
60
58
  </td>
61
- <td style="padding: var(--space-3) var(--space-4); text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text-primary);" onclick="window.location='<%= error_path(error, **app_context) %>'">
59
+ <td style="padding: var(--space-3) var(--space-4); text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text-primary);">
62
60
  <%= error.occurrence_count.to_s(:delimited) rescue error.occurrence_count %>
63
61
  </td>
64
- <td style="padding: var(--space-3) var(--space-4); text-align: right; font-variant-numeric: tabular-nums; color: var(--text-secondary);" onclick="window.location='<%= error_path(error, **app_context) %>'">
62
+ <td style="padding: var(--space-3) var(--space-4); text-align: right; font-variant-numeric: tabular-nums; color: var(--text-secondary);">
65
63
  <% if error.user_id %>
66
64
  <%= error.user_id %>
67
65
  <% else %>
68
66
  <span style="color: var(--text-tertiary);">&mdash;</span>
69
67
  <% end %>
70
68
  </td>
71
- <td style="padding: var(--space-3) var(--space-4); text-align: right; color: var(--text-tertiary); font-size: 12px;" onclick="window.location='<%= error_path(error, **app_context) %>'">
69
+ <td style="padding: var(--space-3) var(--space-4); text-align: right; color: var(--text-tertiary); font-size: 12px;">
72
70
  <%= local_time_ago(error.last_seen_at) %>
73
71
  </td>
74
72
  <% if local_assigns[:show_application] %>
75
- <td style="padding: var(--space-3) var(--space-4); font-size: 12px;" onclick="window.location='<%= error_path(error, **app_context) %>'">
73
+ <td style="padding: var(--space-3) var(--space-4); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="<%= error.application&.name || 'Unknown' %>">
76
74
  <span class="badge bg-info"><%= error.application&.name || 'Unknown' %></span>
77
75
  </td>
78
76
  <% end %>
79
77
  <% if local_assigns[:show_platform] %>
80
- <td style="padding: var(--space-3) var(--space-4); font-size: 12px;" onclick="window.location='<%= error_path(error, **app_context) %>'">
78
+ <td style="padding: var(--space-3) var(--space-4); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="<%= error.platform || 'API' %>">
81
79
  <% if error.platform == 'iOS' %>
82
80
  <span class="badge badge-ios"><i class="bi bi-apple"></i> iOS</span>
83
81
  <% elsif error.platform == 'Android' %>
@@ -112,7 +112,7 @@
112
112
  </div>
113
113
 
114
114
  <% if flash[:new_issue_url].present? %>
115
- <script>
115
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
116
116
  (function() {
117
117
  window.open('<%= j flash[:new_issue_url] %>', '_blank');
118
118
  })();
@@ -84,17 +84,17 @@
84
84
  <th>Replay Request:</th>
85
85
  <td>
86
86
  <% if curl_cmd.present? %>
87
- <button class="btn btn-sm btn-outline-primary me-2"
88
- onclick="copyToClipboard(this.dataset.curl, this)"
89
- data-curl="<%= h curl_cmd %>"
87
+ <button type="button" class="btn btn-sm btn-outline-primary me-2"
88
+ data-red-action="copy"
89
+ data-red-copy-text="<%= h curl_cmd %>"
90
90
  title="Copy curl command to clipboard">
91
91
  <i class="bi bi-terminal"></i> Copy as curl
92
92
  </button>
93
93
  <% end %>
94
94
  <% if rspec_cmd.present? %>
95
- <button class="btn btn-sm btn-outline-success"
96
- onclick="copyToClipboard(this.dataset.curl, this)"
97
- data-curl="<%= h rspec_cmd %>"
95
+ <button type="button" class="btn btn-sm btn-outline-success"
96
+ data-red-action="copy"
97
+ data-red-copy-text="<%= h rspec_cmd %>"
98
98
  title="Copy RSpec request spec to clipboard">
99
99
  <i class="bi bi-code-slash"></i> Copy as RSpec
100
100
  </button>
@@ -1,5 +1,5 @@
1
- <script>
2
- function downloadErrorJSON(event) {
1
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
2
+ window.downloadErrorJSON = function(event) {
3
3
  const errorData = {
4
4
  id: <%= raw error.id.to_json %>,
5
5
  error_type: <%= raw error.error_type.to_json %>,
@@ -52,7 +52,7 @@
52
52
  button.classList.remove('btn-success');
53
53
  button.classList.add('btn-outline-secondary');
54
54
  }, 2000);
55
- }
55
+ };
56
56
 
57
57
  // Section Navigation — pill bar with scroll spy
58
58
  document.addEventListener('DOMContentLoaded', function() {
@@ -526,7 +526,7 @@
526
526
  <small class="metadata-label d-block mb-1">Error ID</small>
527
527
  <div class="d-flex align-items-center gap-2">
528
528
  <code><%= error.id %></code>
529
- <button class="btn btn-sm btn-outline-secondary" onclick="copyToClipboard('<%= error.id %>', this)" title="Copy error ID">
529
+ <button type="button" class="btn btn-sm btn-outline-secondary" data-red-action="copy" data-red-copy-text="<%= error.id %>" title="Copy error ID">
530
530
  <i class="bi bi-clipboard"></i>
531
531
  </button>
532
532
  </div>
@@ -87,11 +87,11 @@
87
87
  </div>
88
88
 
89
89
  <% if error.resolved? && error.resolution_comment.present? %>
90
- <div class="mt-2 p-2 bg-success bg-opacity-10 border border-success rounded">
91
- <small class="text-success fw-bold">
90
+ <div style="margin-top: var(--space-2); padding: var(--space-2) var(--space-3); background: var(--status-success-bg); border: 1px solid var(--status-success); border-radius: var(--radius-sm);">
91
+ <small style="color: var(--status-success); font-weight: 600;">
92
92
  <i class="bi bi-journal-text"></i> Resolution Notes:
93
93
  </small>
94
- <div class="mb-0 small text-muted mt-1">
94
+ <div style="font-size: 13px; color: var(--text-primary); margin-top: 4px;">
95
95
  <%= auto_link_urls(truncate(error.resolution_comment, length: 200), error: error).html_safe %>
96
96
  </div>
97
97
  </div>
@@ -1,6 +1,6 @@
1
1
  <% content_for :page_title, "Analytics" %>
2
2
 
3
- <script>
3
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
4
4
  // Dynamic chart colors based on theme
5
5
  window.getChartColors = function() {
6
6
  const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
@@ -67,7 +67,7 @@
67
67
  </div>
68
68
  <div class="card-body">
69
69
  <div id="errors-over-time-chart"></div>
70
- <script>
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
73
  new Chartkick.LineChart("errors-over-time-chart", <%= raw @errors_over_time.to_json %>, {
@@ -112,7 +112,7 @@
112
112
  </div>
113
113
  <div class="card-body">
114
114
  <div id="errors-by-platform-chart"></div>
115
- <script>
115
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
116
116
  document.addEventListener('DOMContentLoaded', function() {
117
117
  const colors = window.getChartColors();
118
118
  // Distinct, accessible colors for 5 platforms with good contrast
@@ -166,7 +166,7 @@
166
166
  </div>
167
167
  <div class="card-body">
168
168
  <div id="errors-by-type-chart"></div>
169
- <script>
169
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
170
170
  document.addEventListener('DOMContentLoaded', function() {
171
171
  const colors = window.getChartColors();
172
172
  new Chartkick.BarChart("errors-by-type-chart", <%= raw @errors_by_type.to_json %>, {
@@ -208,7 +208,7 @@
208
208
  </div>
209
209
  <div class="card-body">
210
210
  <div id="errors-by-hour-chart"></div>
211
- <script>
211
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
212
212
  document.addEventListener('DOMContentLoaded', function() {
213
213
  const colors = window.getChartColors();
214
214
  new Chartkick.ColumnChart("errors-by-hour-chart", <%= raw @errors_by_hour.to_json %>, {
@@ -410,7 +410,7 @@
410
410
  </div>
411
411
  <div class="card-body">
412
412
  <div id="errors-by-version-chart"></div>
413
- <script>
413
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
414
414
  document.addEventListener('DOMContentLoaded', function() {
415
415
  const versionData = <%= raw @errors_by_version.transform_values { |v| v[:count] }.to_json %>;
416
416
  const colors = window.getChartColors();
@@ -549,7 +549,7 @@
549
549
  <div class="mt-4">
550
550
  <h6 class="text-muted mb-3">MTTR by Platform</h6>
551
551
  <div id="mttr-by-platform-chart"></div>
552
- <script>
552
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
553
553
  document.addEventListener('DOMContentLoaded', function() {
554
554
  const colors = window.getChartColors();
555
555
  new Chartkick.BarChart("mttr-by-platform-chart",
@@ -602,7 +602,7 @@
602
602
  <div class="mt-4">
603
603
  <h6 class="text-muted mb-3">MTTR Trend (Weekly)</h6>
604
604
  <div id="mttr-trend-chart"></div>
605
- <script>
605
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
606
606
  document.addEventListener('DOMContentLoaded', function() {
607
607
  const colors = window.getChartColors();
608
608
  new Chartkick.LineChart("mttr-trend-chart",
@@ -40,20 +40,6 @@
40
40
  All time
41
41
  <% end %>
42
42
  </span>
43
- <div id="batch-actions-inline" style="display: none; gap: 6px;">
44
- <%= form_with url: batch_action_errors_path, method: :post, id: "batch-form", style: "display: flex; gap: 6px; align-items: center;" do |f| %>
45
- <% if params[:application_id].present? %>
46
- <%= hidden_field_tag :application_id, params[:application_id] %>
47
- <% end %>
48
- <span id="selected-count" style="font-size: 12px; font-weight: 600; color: var(--text-secondary);"></span>
49
- <%= button_tag type: "submit", name: "action_type", value: "resolve", class: "btn btn-sm", style: "padding: 4px 12px; font-size: 12px;" do %>
50
- Resolve
51
- <% end %>
52
- <%= 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 %>
53
- Delete
54
- <% end %>
55
- <% end %>
56
- </div>
57
43
  </div>
58
44
 
59
45
  <!-- Spike Detection Alert -->
@@ -100,7 +86,7 @@
100
86
  current_status = params[:status]
101
87
  is_unresolved_only = params[:unresolved] != "false" && params[:unresolved] != "0"
102
88
  %>
103
- <%= link_to errors_path(app_context), class: "btn filter-pill #{current_status.blank? && !is_unresolved_only ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>All<% end %>
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 %>
104
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 %>
105
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 %>
106
92
  <%= link_to errors_path(app_context.merge(assigned_to: '__assigned__')), class: "btn filter-pill #{params[:assigned_to] == '__assigned__' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Assigned<% end %>
@@ -120,13 +106,13 @@
120
106
 
121
107
  <!-- Advanced filters toggle -->
122
108
  <div style="width: 1px; height: 24px; background: var(--border-primary);"></div>
123
- <button type="button" onclick="document.getElementById('advanced-filters').style.display = document.getElementById('advanced-filters').style.display === 'none' ? 'flex' : 'none'" class="filter-pill" style="border: none;">
109
+ <button type="button" data-red-action="toggle-display" data-red-target="#advanced-filters" data-red-display="flex" class="filter-pill" style="border: none;">
124
110
  <i class="bi bi-sliders"></i> More filters
125
111
  </button>
126
112
  </div>
127
113
 
128
114
  <!-- Advanced filters -->
129
- <div id="advanced-filters" style="display: flex; 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);">
115
+ <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);">
130
116
  <% if @applications.size > 1 %>
131
117
  <%= select_tag :application_id, options_for_select([['All Apps', '']] + @applications, params[:application_id]), class: "form-select", style: "width: auto; min-width: 120px;" %>
132
118
  <% end %>
@@ -208,23 +194,41 @@
208
194
  <!-- Error Table -->
209
195
  <div class="card" style="overflow: hidden;">
210
196
  <% if @errors.any? %>
197
+ <!-- Batch action toolbar (shown when one or more errors are selected). Sits above the table
198
+ so it doesn't disrupt the table's column widths the way a colspan thead row does. -->
199
+ <div id="batch-toolbar" style="display: none; padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border-primary); background: var(--surface-secondary);">
200
+ <%= form_with url: batch_action_errors_path, method: :post, id: "batch-form", style: "display: flex; gap: 8px; align-items: center; flex-wrap: wrap;" do |f| %>
201
+ <% if params[:application_id].present? %>
202
+ <%= hidden_field_tag :application_id, params[:application_id] %>
203
+ <% end %>
204
+ <input type="checkbox" id="select-all-batch" class="form-check-input" style="accent-color: var(--accent); margin: 0;" checked>
205
+ <span id="selected-count" style="font-size: 13px; font-weight: 600; color: var(--text-primary); margin-right: var(--space-2);"></span>
206
+ <%= button_tag type: "submit", name: "action_type", value: "resolve", class: "btn btn-sm", style: "padding: 4px 12px; font-size: 12px;" do %>
207
+ <i class="bi bi-check-circle"></i> Resolve
208
+ <% end %>
209
+ <%= 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 %>
210
+ <i class="bi bi-trash"></i> Delete
211
+ <% end %>
212
+ <% end %>
213
+ </div>
211
214
  <div data-loading-target="content">
212
- <table class="table table-hover" style="margin-bottom: 0;">
215
+ <div style="overflow-x: auto;">
216
+ <table class="table table-hover" style="margin-bottom: 0; width: 100%; table-layout: fixed;">
213
217
  <thead>
214
- <tr style="border-bottom: 1px solid var(--border-primary);">
215
- <th style="width: 40px; padding: var(--space-3) var(--space-4); text-align: center;">
218
+ <tr id="thead-columns" style="border-bottom: 1px solid var(--border-primary);">
219
+ <th style="width: 36px; padding: var(--space-3) var(--space-4); text-align: center;">
216
220
  <input type="checkbox" id="select-all" class="form-check-input" style="accent-color: var(--accent);">
217
221
  </th>
218
222
  <th style="padding: var(--space-3) var(--space-4); text-align: left;">Error</th>
219
- <th style="padding: var(--space-3) var(--space-4); text-align: left; width: 90px;">Status</th>
220
- <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 90px;">Events</th>
221
- <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 70px;">Users</th>
222
- <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 90px;">Last seen</th>
223
+ <th style="padding: var(--space-3) var(--space-4); text-align: left; width: 100px;">Status</th>
224
+ <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 70px;">Events</th>
225
+ <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 55px;">Users</th>
226
+ <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 85px;">Last seen</th>
223
227
  <% if @applications.size > 1 && params[:application_id].blank? %>
224
- <th style="padding: var(--space-3) var(--space-4); width: 90px;">App</th>
228
+ <th style="padding: var(--space-3) var(--space-4); width: 100px;">App</th>
225
229
  <% end %>
226
230
  <% if @platforms.size > 1 %>
227
- <th style="padding: var(--space-3) var(--space-4); width: 80px;">Platform</th>
231
+ <th style="padding: var(--space-3) var(--space-4); width: 100px;">Platform</th>
228
232
  <% end %>
229
233
  </tr>
230
234
  </thead>
@@ -234,11 +238,13 @@
234
238
  <% end %>
235
239
  </tbody>
236
240
  </table>
241
+ </div>
237
242
 
238
243
  <!-- Pagination -->
239
244
  <% if @pagy.pages > 1 %>
240
- <div style="padding: var(--space-4); border-top: 1px solid var(--border-primary);">
241
- <%== @pagy.series_nav(:bootstrap) %>
245
+ <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);">
246
+ <span style="font-size: 12px; color: var(--text-tertiary);">Showing <%= @pagy.from %>–<%= @pagy.to %> of <%= @pagy.count %> errors</span>
247
+ <div><%== @pagy.series_nav(:bootstrap) %></div>
242
248
  </div>
243
249
  <% end %>
244
250
  </div>
@@ -268,10 +274,11 @@
268
274
  </div>
269
275
  </div>
270
276
 
271
- <script>
277
+ <%= red_javascript_tag do %>
272
278
  document.addEventListener('DOMContentLoaded', function() {
273
279
  var selectAllCheckbox = document.getElementById('select-all');
274
- var batchInline = document.getElementById('batch-actions-inline');
280
+ var selectAllBatchCheckbox = document.getElementById('select-all-batch');
281
+ var batchToolbar = document.getElementById('batch-toolbar');
275
282
  var selectedCountSpan = document.getElementById('selected-count');
276
283
  var batchForm = document.getElementById('batch-form');
277
284
 
@@ -282,31 +289,47 @@ document.addEventListener('DOMContentLoaded', function() {
282
289
  function updateBatchToolbar() {
283
290
  var checkedBoxes = document.querySelectorAll('.error-checkbox:checked');
284
291
  var count = checkedBoxes.length;
292
+ var boxes = getErrorCheckboxes();
293
+ var allChecked = boxes.length > 0 && Array.from(boxes).every(function(cb) { return cb.checked; });
294
+ var someChecked = Array.from(boxes).some(function(cb) { return cb.checked; });
295
+
285
296
  if (count > 0) {
286
- batchInline.style.display = 'flex';
287
- selectedCountSpan.textContent = count + ' selected';
297
+ if (batchToolbar) batchToolbar.style.display = '';
298
+ if (selectedCountSpan) selectedCountSpan.textContent = count + ' selected';
299
+ if (selectAllBatchCheckbox) {
300
+ selectAllBatchCheckbox.checked = allChecked;
301
+ selectAllBatchCheckbox.indeterminate = someChecked && !allChecked;
302
+ }
288
303
  } else {
289
- batchInline.style.display = 'none';
304
+ if (batchToolbar) batchToolbar.style.display = 'none';
305
+ }
306
+
307
+ if (selectAllCheckbox) {
308
+ selectAllCheckbox.checked = allChecked;
309
+ selectAllCheckbox.indeterminate = someChecked && !allChecked;
290
310
  }
291
311
  }
292
312
 
313
+ function handleSelectAll(checked) {
314
+ getErrorCheckboxes().forEach(function(cb) { cb.checked = checked; });
315
+ updateBatchToolbar();
316
+ }
317
+
293
318
  if (selectAllCheckbox) {
294
319
  selectAllCheckbox.addEventListener('change', function() {
295
- getErrorCheckboxes().forEach(function(cb) { cb.checked = selectAllCheckbox.checked; });
296
- updateBatchToolbar();
320
+ handleSelectAll(selectAllCheckbox.checked);
321
+ });
322
+ }
323
+
324
+ if (selectAllBatchCheckbox) {
325
+ selectAllBatchCheckbox.addEventListener('change', function() {
326
+ handleSelectAll(selectAllBatchCheckbox.checked);
297
327
  });
298
328
  }
299
329
 
300
330
  document.addEventListener('change', function(e) {
301
331
  if (e.target.classList.contains('error-checkbox')) {
302
332
  updateBatchToolbar();
303
- var boxes = getErrorCheckboxes();
304
- var allChecked = Array.from(boxes).every(function(cb) { return cb.checked; });
305
- var someChecked = Array.from(boxes).some(function(cb) { return cb.checked; });
306
- if (selectAllCheckbox) {
307
- selectAllCheckbox.checked = allChecked;
308
- selectAllCheckbox.indeterminate = someChecked && !allChecked;
309
- }
310
333
  }
311
334
  });
312
335
 
@@ -368,4 +391,4 @@ document.addEventListener('DOMContentLoaded', function() {
368
391
  });
369
392
  }
370
393
  });
371
- </script>
394
+ <% end %>
@@ -290,7 +290,7 @@
290
290
  <% end %>
291
291
  </div>
292
292
 
293
- <script>
293
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
294
294
  // Platform color mapping - consistent with analytics page
295
295
  const platformColorMap = {
296
296
  'api': '#2563EB', // Blue
@@ -169,7 +169,10 @@
169
169
  %>
170
170
 
171
171
  <div>
172
- <h1 style="font-size: 20px; font-weight: 700; margin-bottom: var(--space-4);">Settings</h1>
172
+ <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-4);">
173
+ <h1 style="font-size: 20px; font-weight: 700; margin: 0;">Settings</h1>
174
+ <span style="font-family: var(--font-mono); font-size: 12px; font-weight: 500; color: var(--text-tertiary); padding: 2px 10px; border: 1px solid var(--border-primary); border-radius: var(--radius-full);">v<%= RailsErrorDashboard::VERSION %></span>
175
+ </div>
173
176
 
174
177
  <p style="font-size: 13px; color: var(--text-tertiary); margin-bottom: var(--space-6);">
175
178
  Read-only view of current configuration. Edit via <code>config/initializers/rails_error_dashboard.rb</code> or environment variables.
@@ -178,7 +181,7 @@
178
181
  <!-- Dynamically render setting groups -->
179
182
  <% setting_groups.each do |group_name, group_config| %>
180
183
  <div class="card" style="margin-bottom: var(--space-4);">
181
- <button onclick="var b = this.nextElementSibling; b.style.display = b.style.display === 'none' ? 'block' : 'none'; var chev = this.querySelector('.chev'); if(chev) chev.className = b.style.display === 'none' ? 'bi bi-chevron-down chev' : 'bi bi-chevron-up chev';" style="display: flex; align-items: center; justify-content: space-between; width: 100%; padding: var(--space-4) var(--space-6); background: none; border: none; cursor: pointer; border-bottom: 1px solid var(--border-primary);">
184
+ <button type="button" data-red-action="toggle-next" data-red-display="block" style="display: flex; align-items: center; justify-content: space-between; width: 100%; padding: var(--space-4) var(--space-6); background: none; border: none; cursor: pointer; border-bottom: 1px solid var(--border-primary);">
182
185
  <span style="font-size: 14px; font-weight: 600; color: var(--text-primary);"><i class="<%= group_config[:icon] %>" style="margin-right: 6px;"></i> <%= group_name %></span>
183
186
  <div style="display: flex; align-items: center; gap: 8px;">
184
187
  <span style="font-size: 11px; color: var(--text-tertiary);"><%= group_config[:settings].size %> options</span>
@@ -255,6 +258,25 @@
255
258
  </div>
256
259
  </div>
257
260
 
261
+ <!-- Test Error -->
262
+ <div class="card" style="margin-bottom: var(--space-4);">
263
+ <div style="padding: var(--space-4) var(--space-6); border-bottom: 1px solid var(--border-primary);">
264
+ <span style="font-size: 14px; font-weight: 600; color: var(--text-primary);"><i class="bi bi-send-check" style="margin-right: 6px;"></i> Test Notifications</span>
265
+ </div>
266
+ <div style="padding: var(--space-5) var(--space-6);">
267
+ <p style="font-size: 13px; color: var(--text-secondary); margin-bottom: var(--space-4);">
268
+ Send a test error through the full capture and notification pipeline. This creates a <code>RailsErrorDashboard::TestError</code> in the
269
+ error log and dispatches notifications to all configured channels (Slack, Discord, PagerDuty, email, webhooks).
270
+ The test error is clearly marked and safe to resolve or delete.
271
+ </p>
272
+ <%= form_tag test_error_errors_path(application_id: @current_application_id.presence), method: :post, style: "display: inline;" do %>
273
+ <button type="submit" class="btn btn-outline" data-red-action="confirm-submit" data-red-confirm-message="This will create a test error and send notifications to all configured channels. Continue?">
274
+ <i class="bi bi-send" style="margin-right: 4px;"></i> Send Test Error
275
+ </button>
276
+ <% end %>
277
+ </div>
278
+ </div>
279
+
258
280
  <!-- Help Text -->
259
281
  <div class="alert alert-light border">
260
282
  <h6 class="alert-heading">
@@ -77,32 +77,32 @@
77
77
  <button type="button" class="btn" data-bs-toggle="modal" data-bs-target="#assignModal" id="hero-assign-btn">
78
78
  Assign
79
79
  </button>
80
- <button type="button" class="btn" onclick="copyToClipboard(this.dataset.markdown.replace(/\\(.)/g, function(m,c){return c==='n'?'\n':c}), this)" data-markdown="<%= j @error_markdown %>">
80
+ <button type="button" class="btn" data-red-action="copy-markdown" data-markdown="<%= j @error_markdown %>">
81
81
  <i class="bi bi-clipboard" style="margin-right: 4px;"></i>Copy for LLM
82
82
  </button>
83
- <button type="button" class="btn" onclick="downloadErrorJSON(event)" title="Export JSON">
83
+ <button type="button" class="btn" data-red-action="download-json" title="Export JSON">
84
84
  <i class="bi bi-download"></i>
85
85
  </button>
86
86
  </div>
87
87
  </div>
88
88
 
89
89
  <!-- Main content: tabs + sidebar -->
90
- <div style="display: grid; grid-template-columns: 1fr 280px; gap: var(--space-4);">
90
+ <div class="red-detail-grid">
91
91
  <!-- Left: tabbed content -->
92
92
  <div>
93
93
  <!-- Tab bar -->
94
94
  <div style="display: flex; gap: 0; border-bottom: 1px solid var(--border-primary); margin-bottom: var(--space-4);">
95
- <button onclick="switchTab('details')" class="red-tab active" id="tab-details" style="padding: 10px 16px; font-size: 13px; font-weight: 600; color: var(--accent); background: none; border: none; border-bottom: 2px solid var(--accent); margin-bottom: -1px; cursor: pointer;">
95
+ <button type="button" data-red-action="switch-tab" data-red-tab="details" class="red-tab active" id="tab-details" style="padding: 10px 16px; font-size: 13px; font-weight: 600; color: var(--accent); background: none; border: none; border-bottom: 2px solid var(--accent); margin-bottom: -1px; cursor: pointer;">
96
96
  <i class="bi bi-code-slash" style="margin-right: 6px;"></i>Details
97
97
  </button>
98
- <button onclick="switchTab('context')" class="red-tab" id="tab-context" style="padding: 10px 16px; font-size: 13px; font-weight: 400; color: var(--text-secondary); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer;">
98
+ <button type="button" data-red-action="switch-tab" data-red-tab="context" class="red-tab" id="tab-context" style="padding: 10px 16px; font-size: 13px; font-weight: 400; color: var(--text-secondary); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer;">
99
99
  <i class="bi bi-layers" style="margin-right: 6px;"></i>Context
100
100
  </button>
101
- <button onclick="switchTab('history')" class="red-tab" id="tab-history" style="padding: 10px 16px; font-size: 13px; font-weight: 400; color: var(--text-secondary); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer;">
101
+ <button type="button" data-red-action="switch-tab" data-red-tab="history" class="red-tab" id="tab-history" style="padding: 10px 16px; font-size: 13px; font-weight: 400; color: var(--text-secondary); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer;">
102
102
  <i class="bi bi-clock-history" style="margin-right: 6px;"></i>History
103
103
  </button>
104
104
  <% if RailsErrorDashboard.configuration.enable_issue_tracking %>
105
- <button onclick="switchTab('issues')" class="red-tab" id="tab-issues" style="padding: 10px 16px; font-size: 13px; font-weight: 400; color: var(--text-secondary); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer;">
105
+ <button type="button" data-red-action="switch-tab" data-red-tab="issues" class="red-tab" id="tab-issues" style="padding: 10px 16px; font-size: 13px; font-weight: 400; color: var(--text-secondary); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer;">
106
106
  <i class="bi bi-github" style="margin-right: 6px;"></i>Issues
107
107
  </button>
108
108
  <% end %>
@@ -162,7 +162,7 @@
162
162
  </div>
163
163
 
164
164
  <!-- Right: sidebar -->
165
- <div class="error-sidebar" style="display: flex; flex-direction: column; gap: var(--space-4); min-width: 0;">
165
+ <div class="error-sidebar" style="display: flex; flex-direction: column; gap: var(--space-4); min-width: 0; overflow-wrap: break-word;">
166
166
  <%= render "sidebar_metadata", error: @error, related_errors: @related_errors %>
167
167
 
168
168
  <!-- Baseline Statistics -->
@@ -225,8 +225,8 @@
225
225
 
226
226
  <%= render "modals", error: @error %>
227
227
 
228
- <script>
229
- function switchTab(tabId) {
228
+ <%= red_javascript_tag do %>
229
+ window.switchTab = function(tabId) {
230
230
  // Deactivate all tabs
231
231
  document.querySelectorAll('.red-tab').forEach(function(el) {
232
232
  el.style.color = 'var(--text-secondary)';
@@ -243,5 +243,5 @@ function switchTab(tabId) {
243
243
  // Scroll to selected panel
244
244
  var panel = document.getElementById('panel-' + tabId);
245
245
  if (panel) { panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
246
- }
247
- </script>
246
+ };
247
+ <% end %>
@@ -79,23 +79,23 @@
79
79
  <div class="card-body p-0">
80
80
  <div class="table-responsive">
81
81
  <table class="table table-hover mb-0">
82
- <thead class="table-light">
83
- <tr>
84
- <th>Exception Class</th>
85
- <th width="250">Raise Location</th>
86
- <th width="250">Rescue Location</th>
87
- <th width="100">Raises</th>
88
- <th width="100">Rescues</th>
89
- <th width="100">Ratio</th>
90
- <th width="140">Last Seen</th>
82
+ <thead>
83
+ <tr style="border-bottom: 1px solid var(--border-primary);">
84
+ <th style="padding: var(--space-3) var(--space-4); color: var(--text-secondary); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;">Exception Class</th>
85
+ <th style="padding: var(--space-3) var(--space-4); color: var(--text-secondary); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;" width="250">Raise Location</th>
86
+ <th style="padding: var(--space-3) var(--space-4); color: var(--text-secondary); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;" width="250">Rescue Location</th>
87
+ <th style="padding: var(--space-3) var(--space-4); color: var(--text-secondary); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;" width="100">Raises</th>
88
+ <th style="padding: var(--space-3) var(--space-4); color: var(--text-secondary); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;" width="100">Rescues</th>
89
+ <th style="padding: var(--space-3) var(--space-4); color: var(--text-secondary); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;" width="100">Ratio</th>
90
+ <th style="padding: var(--space-3) var(--space-4); color: var(--text-secondary); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;" width="140">Last Seen</th>
91
91
  </tr>
92
92
  </thead>
93
93
  <tbody>
94
94
  <% @entries.each do |entry| %>
95
95
  <tr>
96
96
  <td><code style="font-size: 0.85em;"><%= entry[:exception_class] %></code></td>
97
- <td><small class="text-muted text-break"><%= entry[:raise_location] %></small></td>
98
- <td><small class="text-muted text-break"><%= entry[:rescue_location] || "Unknown" %></small></td>
97
+ <td style="color: var(--text-secondary); font-size: 12px; word-break: break-all;"><%= entry[:raise_location] %></td>
98
+ <td style="color: var(--text-secondary); font-size: 12px; word-break: break-all;"><%= entry[:rescue_location] || "Unknown" %></td>
99
99
  <td><span class="badge bg-info text-dark"><%= number_with_delimiter(entry[:raise_count]) %></span></td>
100
100
  <td><span class="badge bg-warning text-dark"><%= number_with_delimiter(entry[:rescue_count]) %></span></td>
101
101
  <td>
@@ -104,7 +104,7 @@
104
104
  <%= ratio_pct %>%
105
105
  </span>
106
106
  </td>
107
- <td><%= local_time_ago(entry[:last_seen]) %></td>
107
+ <td style="color: var(--text-secondary); font-size: 12px;"><%= local_time_ago(entry[:last_seen]) %></td>
108
108
  </tr>
109
109
  <% end %>
110
110
  </tbody>
data/config/routes.rb CHANGED
@@ -44,6 +44,7 @@ RailsErrorDashboard::Engine.routes.draw do
44
44
  post :enable_coverage
45
45
  post :disable_coverage
46
46
  post :batch_action
47
+ post :test_error
47
48
  end
48
49
  end
49
50
  end
@@ -113,7 +113,7 @@ module RailsErrorDashboard
113
113
  server.pubsub
114
114
  @broadcast_unavailable_until = nil
115
115
  true
116
- rescue => e
116
+ rescue LoadError, StandardError => e
117
117
  @broadcast_unavailable_until = Time.current + 60
118
118
  RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Broadcast not available (pausing 60s): #{e.message}")
119
119
  false
@@ -195,7 +195,7 @@ module RailsErrorDashboard
195
195
  connections: server.connections.count,
196
196
  adapter: server.pubsub&.class&.name&.demodulize
197
197
  }
198
- rescue => e
198
+ rescue LoadError, StandardError => e
199
199
  nil
200
200
  end
201
201
 
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Custom exception class for test errors triggered from the dashboard.
5
+ # Clearly identifiable in error lists so users know it's not a real issue.
6
+ class TestError < StandardError; end
7
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.6.0"
2
+ VERSION = "0.6.2"
3
3
  end
@@ -1,6 +1,7 @@
1
1
  require "rails_error_dashboard/version"
2
2
  require "rails_error_dashboard/engine"
3
3
  require "rails_error_dashboard/configuration_error"
4
+ require "rails_error_dashboard/test_error"
4
5
  require "rails_error_dashboard/configuration"
5
6
  require "rails_error_dashboard/logger"
6
7
  require "rails_error_dashboard/manual_error_reporter"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_error_dashboard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anjan Jagirdar
@@ -480,6 +480,7 @@ files:
480
480
  - lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb
481
481
  - lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb
482
482
  - lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb
483
+ - lib/rails_error_dashboard/test_error.rb
483
484
  - lib/rails_error_dashboard/value_objects/error_context.rb
484
485
  - lib/rails_error_dashboard/version.rb
485
486
  - lib/tasks/error_dashboard.rake
@@ -496,7 +497,7 @@ metadata:
496
497
  funding_uri: https://github.com/sponsors/AnjanJ
497
498
  post_install_message: |
498
499
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
499
- RED (Rails Error Dashboard) v0.6.0
500
+ RED (Rails Error Dashboard) v0.6.2
500
501
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
501
502
 
502
503
  First install: