rails_error_dashboard 0.6.1 → 0.6.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8730984999d2ab508eeefe889bfd320e54cb9395c6baacbe87fef1a3aa20de7a
4
- data.tar.gz: 6a301193d907d990418f6a74b83ee36a54caeac1b05dc57a75ea24213e9b7f7c
3
+ metadata.gz: 938ab24cad38b8b20bc0dd0f2a1eb6e5c9e46a4634fb97e2c01b3487221c9fc3
4
+ data.tar.gz: 6e053a3f8c41e3c3f75f95ab1f9bd8908e9bcea4c473e2d1d3a0c11b7bdf0bdb
5
5
  SHA512:
6
- metadata.gz: af05b90ebe19d1b90adb82b7e8d3a4cd57faf695327c3a51c2d36ced221d1951265e4d88d77e4ddb843c465582fe14d6911141e3f081b2de76ca3037a4e637f0
7
- data.tar.gz: add93453ac6ebee6a3f320fc336fe30c81a952f58fc30b3b8554c410071586da7d9c25da1d886c5976b4ade28cc6504c5b355509a29dcf5025aa8b502d1fff65
6
+ metadata.gz: 2b84b4a4f917863e7577e253f2324100a06b6fef6f33d56cef211f28a4f1dc4688932f618489f19924baa8aa065135cfcaeffb2c7431f15bea898d3e334395b7
7
+ data.tar.gz: 4149d8da317c77751e04db12b46f02924fe46e1a420075232e3fa7c96f12218166249596b33a55f1790e696b5fca3580cd5da62d38a39585e356660a702a518c
@@ -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)
@@ -237,7 +240,7 @@ module RailsErrorDashboard
237
240
  resolution_comment: params[:resolution_comment]
238
241
  )
239
242
  when "mute"
240
- Commands::BatchMuteErrors.call(error_ids, muted_by: params[:muted_by])
243
+ Commands::BatchMuteErrors.call(error_ids, muted_by: params[:muted_by], reason: params[:reason])
241
244
  when "unmute"
242
245
  Commands::BatchUnmuteErrors.call(error_ids)
243
246
  when "delete"
@@ -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)) {
@@ -880,6 +880,12 @@ main { animation: contentFadeIn 0.15s ease; }
880
880
  details { background: var(--surface-primary); border: 1px solid var(--border-primary); border-radius: var(--radius-md); }
881
881
  summary { padding: var(--space-3) var(--space-4); cursor: pointer; font-weight: 500; color: var(--text-primary); background: var(--surface-hover); }
882
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; }
883
889
  </style>
884
890
  </head>
885
891
 
@@ -957,7 +963,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
957
963
 
958
964
  <% if health_items.any? %>
959
965
  <div style="margin-bottom: var(--space-2);" id="navHealthSection">
960
- <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">
961
967
  Health
962
968
  <i class="bi bi-chevron-down" style="font-size: 10px;" id="navHealthChevron"></i>
963
969
  </button>
@@ -988,7 +994,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
988
994
 
989
995
  <% if diag_items.any? %>
990
996
  <div style="margin-bottom: var(--space-2);" id="navDiagSection">
991
- <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">
992
998
  Diagnostics
993
999
  <i class="bi bi-chevron-down" style="font-size: 10px;" id="navDiagChevron"></i>
994
1000
  </button>
@@ -1006,7 +1012,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
1006
1012
 
1007
1013
  <!-- INSIGHTS section -->
1008
1014
  <div style="margin-bottom: var(--space-2);" id="navInsightsSection">
1009
- <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">
1010
1016
  Insights
1011
1017
  <i class="bi bi-chevron-down" style="font-size: 10px;" id="navInsightsChevron"></i>
1012
1018
  </button>
@@ -1120,9 +1126,9 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
1120
1126
  Set <code>ERROR_DASHBOARD_USER</code> and <code>ERROR_DASHBOARD_PASSWORD</code> environment variables,
1121
1127
  or configure <code>authenticate_with</code> in your initializer.
1122
1128
  </div>
1123
- <button type="button" onclick="this.parentElement.style.display='none'; try { sessionStorage.setItem('red_dismiss_creds_warning','1') } catch(e) {}" 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>
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>
1124
1130
  </div>
1125
- <script>try { if (sessionStorage.getItem('red_dismiss_creds_warning') === '1') { document.getElementById('security-warning').style.display = 'none'; } } catch(e) {}</script>
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>
1126
1132
  <% end %>
1127
1133
  <%= yield %>
1128
1134
  </main>
@@ -1211,7 +1217,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
1211
1217
  <script src="https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.umd.js"></script>
1212
1218
 
1213
1219
  <!-- Loading State Controller -->
1214
- <script>
1220
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
1215
1221
  (function() {
1216
1222
  'use strict';
1217
1223
  if (typeof Stimulus === 'undefined') return;
@@ -1290,7 +1296,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
1290
1296
  </script>
1291
1297
 
1292
1298
  <!-- Syntax Highlighting -->
1293
- <script>
1299
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
1294
1300
  (function() {
1295
1301
  'use strict';
1296
1302
  if (typeof hljs === 'undefined') return;
@@ -1334,7 +1340,7 @@ details[open] summary { border-bottom: 1px solid var(--border-primary); }
1334
1340
  </script>
1335
1341
 
1336
1342
  <!-- Theme Toggle + Chart Theme -->
1337
- <script>
1343
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
1338
1344
  document.addEventListener('DOMContentLoaded', function() {
1339
1345
  var themeToggle = document.getElementById('themeToggle');
1340
1346
  var themeIcon = document.getElementById('themeIcon');
@@ -1421,7 +1427,7 @@ document.addEventListener('DOMContentLoaded', function() {
1421
1427
  </script>
1422
1428
 
1423
1429
  <!-- Utilities (tooltips, clipboard, time conversion, keyboard shortcuts) -->
1424
- <script>
1430
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
1425
1431
  document.addEventListener('DOMContentLoaded', function() {
1426
1432
 
1427
1433
  // Tooltips
@@ -1591,11 +1597,114 @@ document.addEventListener('DOMContentLoaded', function() {
1591
1597
  window.location.href = '<%= analytics_errors_path %>';
1592
1598
  }
1593
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
+ });
1594
1703
  });
1595
1704
  </script>
1596
1705
 
1597
1706
  <!-- Sidebar Toggle + Nav Section Toggle -->
1598
- <script>
1707
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
1599
1708
  document.addEventListener('DOMContentLoaded', function() {
1600
1709
  var sidebarToggle = document.getElementById('sidebarToggle');
1601
1710
  var sidebar = document.getElementById('sidebar');
@@ -1622,7 +1731,7 @@ document.addEventListener('DOMContentLoaded', function() {
1622
1731
  });
1623
1732
 
1624
1733
  // Nav section collapse/expand
1625
- function toggleNavSection(itemsId) {
1734
+ window.toggleNavSection = function(itemsId) {
1626
1735
  var items = document.getElementById(itemsId);
1627
1736
  var chevron = document.getElementById(itemsId.replace('Items', 'Chevron'));
1628
1737
  if (items) {
@@ -1631,7 +1740,7 @@ function toggleNavSection(itemsId) {
1631
1740
  if (chevron) chevron.className = hidden ? 'bi bi-chevron-down' : 'bi bi-chevron-right';
1632
1741
  localStorage.setItem('red_nav_' + itemsId, hidden ? 'open' : 'closed');
1633
1742
  }
1634
- }
1743
+ };
1635
1744
 
1636
1745
  // Restore nav section states
1637
1746
  document.addEventListener('DOMContentLoaded', function() {
@@ -1648,7 +1757,7 @@ document.addEventListener('DOMContentLoaded', function() {
1648
1757
  </script>
1649
1758
 
1650
1759
  <!-- Flash Messages -->
1651
- <script>
1760
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
1652
1761
  <% if defined?(flash) && flash.present? %>
1653
1762
  <% if flash[:notice] %>
1654
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>
@@ -24,7 +22,7 @@
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; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="<%= error.application&.name || 'Unknown' %>" 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; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="<%= error.platform || 'API' %>" 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>
@@ -61,7 +61,7 @@
61
61
  </td>
62
62
  <% end %>
63
63
  <td>
64
- <%= link_to "View Errors", errors_path(user_id: user_data[:user_id]), class: "btn btn-sm btn-outline-primary" %>
64
+ <%= link_to "View Errors", errors_path(user_id: user_data[:user_id], unresolved: '0'), class: "btn btn-sm btn-outline-primary" %>
65
65
  </td>
66
66
  </tr>
67
67
  <% end %>
@@ -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
@@ -148,7 +148,7 @@
148
148
  <div class="d-flex gap-2 flex-wrap">
149
149
  <small class="text-muted me-2">Quick Links:</small>
150
150
  <% @errors_by_platform.keys.each do |platform| %>
151
- <%= link_to platform, errors_path(platform: platform), class: "btn btn-sm btn-outline-secondary" %>
151
+ <%= link_to platform, errors_path(platform: platform, unresolved: '0'), class: "btn btn-sm btn-outline-secondary" %>
152
152
  <% end %>
153
153
  </div>
154
154
  </div>
@@ -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 %>, {
@@ -304,7 +304,7 @@
304
304
  </div>
305
305
  </td>
306
306
  <td>
307
- <%= link_to "View Errors", errors_path(error_type: error_type), class: "btn btn-sm btn-outline-primary" %>
307
+ <%= link_to "View Errors", errors_path(error_type: error_type, unresolved: '0'), class: "btn btn-sm btn-outline-primary" %>
308
308
  </td>
309
309
  </tr>
310
310
  <% end %>
@@ -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",
@@ -587,7 +587,7 @@
587
587
  </td>
588
588
  <td><strong><%= hours %> hours</strong></td>
589
589
  <td>
590
- <%= link_to "View", errors_path(severity: severity), class: "btn btn-sm btn-outline-primary" %>
590
+ <%= link_to "View", errors_path(severity: severity, unresolved: '0'), class: "btn btn-sm btn-outline-primary" %>
591
591
  </td>
592
592
  </tr>
593
593
  <% end %>
@@ -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",
@@ -105,7 +105,7 @@
105
105
  <% end %>
106
106
  </td>
107
107
  <td>
108
- <%= link_to "View", errors_path(search: release[:version]), class: "btn btn-sm btn-outline-primary" %>
108
+ <%= link_to "View", errors_path(search: release[:version], unresolved: '0'), class: "btn btn-sm btn-outline-primary" %>
109
109
  </td>
110
110
  </tr>
111
111
  <% end %>
@@ -86,11 +86,11 @@
86
86
  current_status = params[:status]
87
87
  is_unresolved_only = params[:unresolved] != "false" && params[:unresolved] != "0"
88
88
  %>
89
- <%= 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 %>
90
90
  <%= link_to errors_path(app_context.merge(unresolved: '1')), class: "btn filter-pill #{is_unresolved_only && current_status.blank? ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Unresolved <span style="font-size: 11px; opacity: 0.7;"><%= @stats[:unresolved] %></span><% end %>
91
91
  <%= link_to errors_path(app_context.merge(status: 'resolved', unresolved: '0')), class: "btn filter-pill #{current_status == 'resolved' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Resolved<% end %>
92
- <%= link_to errors_path(app_context.merge(assigned_to: '__assigned__')), class: "btn filter-pill #{params[:assigned_to] == '__assigned__' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Assigned<% end %>
93
- <%= link_to errors_path(app_context.merge(reopened: 'true')), class: "btn filter-pill #{params[:reopened] == 'true' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Reopened<% end %>
92
+ <%= link_to errors_path(app_context.merge(assigned_to: '__assigned__', unresolved: '0')), class: "btn filter-pill #{params[:assigned_to] == '__assigned__' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Assigned<% end %>
93
+ <%= link_to errors_path(app_context.merge(reopened: 'true', unresolved: '0')), class: "btn filter-pill #{params[:reopened] == 'true' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Reopened<% end %>
94
94
  </div>
95
95
 
96
96
  <div style="width: 1px; height: 24px; background: var(--border-primary);"></div>
@@ -106,7 +106,7 @@
106
106
 
107
107
  <!-- Advanced filters toggle -->
108
108
  <div style="width: 1px; height: 24px; background: var(--border-primary);"></div>
109
- <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;">
110
110
  <i class="bi bi-sliders"></i> More filters
111
111
  </button>
112
112
  </div>
@@ -194,6 +194,23 @@
194
194
  <!-- Error Table -->
195
195
  <div class="card" style="overflow: hidden;">
196
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>
197
214
  <div data-loading-target="content">
198
215
  <div style="overflow-x: auto;">
199
216
  <table class="table table-hover" style="margin-bottom: 0; width: 100%; table-layout: fixed;">
@@ -214,25 +231,6 @@
214
231
  <th style="padding: var(--space-3) var(--space-4); width: 100px;">Platform</th>
215
232
  <% end %>
216
233
  </tr>
217
- <tr id="thead-batch" style="display: none; border-bottom: 1px solid var(--border-primary); background: var(--surface-secondary);">
218
- <th style="width: 40px; padding: var(--space-3) var(--space-4); text-align: center;">
219
- <input type="checkbox" id="select-all-batch" class="form-check-input" style="accent-color: var(--accent);" checked>
220
- </th>
221
- <th colspan="99" style="padding: var(--space-3) var(--space-4);">
222
- <%= form_with url: batch_action_errors_path, method: :post, id: "batch-form", style: "display: flex; gap: 8px; align-items: center;" do |f| %>
223
- <% if params[:application_id].present? %>
224
- <%= hidden_field_tag :application_id, params[:application_id] %>
225
- <% end %>
226
- <span id="selected-count" style="font-size: 13px; font-weight: 600; color: var(--text-primary);"></span>
227
- <%= button_tag type: "submit", name: "action_type", value: "resolve", class: "btn btn-sm", style: "padding: 4px 12px; font-size: 12px;" do %>
228
- <i class="bi bi-check-circle"></i> Resolve
229
- <% end %>
230
- <%= 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 %>
231
- <i class="bi bi-trash"></i> Delete
232
- <% end %>
233
- <% end %>
234
- </th>
235
- </tr>
236
234
  </thead>
237
235
  <tbody id="error_list">
238
236
  <% @errors.each do |error| %>
@@ -276,12 +274,11 @@
276
274
  </div>
277
275
  </div>
278
276
 
279
- <script>
277
+ <%= red_javascript_tag do %>
280
278
  document.addEventListener('DOMContentLoaded', function() {
281
279
  var selectAllCheckbox = document.getElementById('select-all');
282
280
  var selectAllBatchCheckbox = document.getElementById('select-all-batch');
283
- var theadColumns = document.getElementById('thead-columns');
284
- var theadBatch = document.getElementById('thead-batch');
281
+ var batchToolbar = document.getElementById('batch-toolbar');
285
282
  var selectedCountSpan = document.getElementById('selected-count');
286
283
  var batchForm = document.getElementById('batch-form');
287
284
 
@@ -297,14 +294,14 @@ document.addEventListener('DOMContentLoaded', function() {
297
294
  var someChecked = Array.from(boxes).some(function(cb) { return cb.checked; });
298
295
 
299
296
  if (count > 0) {
300
- theadColumns.style.display = 'none';
301
- theadBatch.style.display = '';
302
- selectedCountSpan.textContent = count + ' selected';
303
- selectAllBatchCheckbox.checked = allChecked;
304
- selectAllBatchCheckbox.indeterminate = someChecked && !allChecked;
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
+ }
305
303
  } else {
306
- theadColumns.style.display = '';
307
- theadBatch.style.display = 'none';
304
+ if (batchToolbar) batchToolbar.style.display = 'none';
308
305
  }
309
306
 
310
307
  if (selectAllCheckbox) {
@@ -394,4 +391,4 @@ document.addEventListener('DOMContentLoaded', function() {
394
391
  });
395
392
  }
396
393
  });
397
- </script>
394
+ <% end %>
@@ -88,7 +88,7 @@
88
88
  </div>
89
89
  </div>
90
90
  <div class="card-footer border-top">
91
- <%= link_to "View #{platform} Errors", errors_path(platform: platform), class: "btn btn-sm btn-outline-primary w-100" %>
91
+ <%= link_to "View #{platform} Errors", errors_path(platform: platform, unresolved: '0'), class: "btn btn-sm btn-outline-primary w-100" %>
92
92
  </div>
93
93
  </div>
94
94
  </div>
@@ -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
@@ -181,7 +181,7 @@
181
181
  <!-- Dynamically render setting groups -->
182
182
  <% setting_groups.each do |group_name, group_config| %>
183
183
  <div class="card" style="margin-bottom: var(--space-4);">
184
- <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);">
185
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>
186
186
  <div style="display: flex; align-items: center; gap: 8px;">
187
187
  <span style="font-size: 11px; color: var(--text-tertiary);"><%= group_config[:settings].size %> options</span>
@@ -270,7 +270,7 @@
270
270
  The test error is clearly marked and safe to resolve or delete.
271
271
  </p>
272
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" onclick="return confirm('This will create a test error and send notifications to all configured channels. Continue?')">
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
274
  <i class="bi bi-send" style="margin-right: 4px;"></i> Send Test Error
275
275
  </button>
276
276
  <% end %>
@@ -77,10 +77,10 @@
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>
@@ -92,17 +92,17 @@
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 %>
@@ -203,15 +203,15 @@
203
203
  <span style="font-size: 13px; font-weight: 600;">Quick Actions</span>
204
204
  </div>
205
205
  <div style="padding: var(--space-3) var(--space-5); display: flex; flex-direction: column; gap: var(--space-2);">
206
- <%= link_to errors_path(app_context.merge(error_type: @error.error_type)), class: "btn btn-sm" do %>
206
+ <%= link_to errors_path(app_context.merge(error_type: @error.error_type, unresolved: '0')), class: "btn btn-sm" do %>
207
207
  <i class="bi bi-filter"></i> View Similar Errors
208
208
  <% end %>
209
209
  <% if @error.user_id %>
210
- <%= link_to errors_path(app_context.merge(user_id: @error.user_id)), class: "btn btn-sm" do %>
210
+ <%= link_to errors_path(app_context.merge(user_id: @error.user_id, unresolved: '0')), class: "btn btn-sm" do %>
211
211
  <i class="bi bi-person"></i> View User's Errors
212
212
  <% end %>
213
213
  <% end %>
214
- <%= link_to errors_path(app_context.merge(platform: @error.platform)), class: "btn btn-sm" do %>
214
+ <%= link_to errors_path(app_context.merge(platform: @error.platform, unresolved: '0')), class: "btn btn-sm" do %>
215
215
  <i class="bi bi-phone"></i> View <%= @error.platform %> Errors
216
216
  <% end %>
217
217
  <%= link_to analytics_errors_path(app_context), class: "btn btn-sm" do %>
@@ -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 %>
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backfill `status` column for errors that were bulk-resolved before v0.6.3.
4
+ #
5
+ # Versions 0.6.0 through 0.6.2 had a bug in BatchResolveErrors that set
6
+ # `resolved: true` and `resolved_at` but skipped the `status` column. The
7
+ # errors-index "Resolved" filter pill queries `where(status: 'resolved')`,
8
+ # so bulk-resolved errors silently disappeared from that view even though
9
+ # they were marked resolved.
10
+ #
11
+ # This migration is idempotent: it only updates rows that are out-of-sync
12
+ # (resolved but not status='resolved'). Running it twice is a no-op.
13
+ #
14
+ # See: https://github.com/AnjanJ/rails_error_dashboard
15
+ class BackfillResolvedStatus < ActiveRecord::Migration[7.0]
16
+ def up
17
+ return unless table_exists?(:rails_error_dashboard_error_logs)
18
+ return unless column_exists?(:rails_error_dashboard_error_logs, :status)
19
+ return unless column_exists?(:rails_error_dashboard_error_logs, :resolved)
20
+
21
+ # Use ActiveRecord update_all so the count is portable across adapters
22
+ # (PostgreSQL, MySQL, SQLite all return the affected row count).
23
+ table = ActiveRecord::Base.connection.quote_table_name("rails_error_dashboard_error_logs")
24
+ klass = Class.new(ActiveRecord::Base) { self.table_name = "rails_error_dashboard_error_logs" }
25
+ updated = klass.where(resolved: true).where("status IS NULL OR status != ?", "resolved")
26
+ .update_all(status: "resolved")
27
+
28
+ say "Backfilled status='resolved' on #{updated} error log(s) that were bulk-resolved on v0.6.0–v0.6.2."
29
+ end
30
+
31
+ def down
32
+ # No-op: we cannot reliably distinguish errors that were bulk-resolved
33
+ # before the fix from errors that are legitimately resolved now. Leaving
34
+ # status='resolved' is the safe choice on rollback.
35
+ end
36
+ end
@@ -4,13 +4,14 @@ module RailsErrorDashboard
4
4
  module Commands
5
5
  # Command: Mute multiple errors at once
6
6
  class BatchMuteErrors
7
- def self.call(error_ids, muted_by: nil)
8
- new(error_ids, muted_by).call
7
+ def self.call(error_ids, muted_by: nil, reason: nil)
8
+ new(error_ids, muted_by, reason).call
9
9
  end
10
10
 
11
- def initialize(error_ids, muted_by = nil)
11
+ def initialize(error_ids, muted_by = nil, reason = nil)
12
12
  @error_ids = Array(error_ids).compact
13
13
  @muted_by = muted_by
14
+ @reason = reason
14
15
  end
15
16
 
16
17
  def call
@@ -27,7 +28,8 @@ module RailsErrorDashboard
27
28
  error.update!(
28
29
  muted: true,
29
30
  muted_at: Time.current,
30
- muted_by: @muted_by
31
+ muted_by: @muted_by,
32
+ muted_reason: @reason
31
33
  )
32
34
  muted_count += 1
33
35
  muted_errors << error
@@ -31,7 +31,8 @@ module RailsErrorDashboard
31
31
  resolved: true,
32
32
  resolved_at: Time.current,
33
33
  resolved_by_name: @resolved_by_name,
34
- resolution_comment: @resolution_comment
34
+ resolution_comment: @resolution_comment,
35
+ status: "resolved"
35
36
  )
36
37
  resolved_count += 1
37
38
  resolved_errors << error
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.6.1"
2
+ VERSION = "0.6.3"
3
3
  end
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.1
4
+ version: 0.6.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anjan Jagirdar
@@ -355,6 +355,7 @@ files:
355
355
  - db/migrate/20260323000001_add_muted_to_error_logs.rb
356
356
  - db/migrate/20260325000001_fix_swallowed_exceptions_index_for_mysql.rb
357
357
  - db/migrate/20260326000001_add_issue_tracking_to_error_logs.rb
358
+ - db/migrate/20260503000001_backfill_resolved_status.rb
358
359
  - lib/generators/rails_error_dashboard/install/install_generator.rb
359
360
  - lib/generators/rails_error_dashboard/install/templates/README
360
361
  - lib/generators/rails_error_dashboard/install/templates/initializer.rb
@@ -497,7 +498,7 @@ metadata:
497
498
  funding_uri: https://github.com/sponsors/AnjanJ
498
499
  post_install_message: |
499
500
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
500
- RED (Rails Error Dashboard) v0.6.1
501
+ RED (Rails Error Dashboard) v0.6.3
501
502
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
502
503
 
503
504
  First install: