rails_error_dashboard 0.5.14 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/rails_error_dashboard/errors_controller.rb +31 -26
  3. data/app/helpers/rails_error_dashboard/application_helper.rb +12 -5
  4. data/app/views/layouts/rails_error_dashboard.html.erb +1218 -1936
  5. data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +4 -4
  6. data/app/views/rails_error_dashboard/errors/_co_occurring_errors.html.erb +1 -1
  7. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +3 -3
  8. data/app/views/rails_error_dashboard/errors/_error_cascades.html.erb +1 -1
  9. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +69 -79
  10. data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +1 -1
  11. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +1 -1
  12. data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +1 -1
  13. data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +2 -2
  14. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +1 -1
  15. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +1 -1
  16. data/app/views/rails_error_dashboard/errors/_similar_errors.html.erb +1 -1
  17. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +1 -1
  18. data/app/views/rails_error_dashboard/errors/actioncable_health_summary.html.erb +6 -6
  19. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +6 -6
  20. data/app/views/rails_error_dashboard/errors/analytics.html.erb +34 -50
  21. data/app/views/rails_error_dashboard/errors/cache_health_summary.html.erb +7 -7
  22. data/app/views/rails_error_dashboard/errors/correlation.html.erb +11 -11
  23. data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +114 -172
  24. data/app/views/rails_error_dashboard/errors/deprecations.html.erb +7 -7
  25. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +6 -6
  26. data/app/views/rails_error_dashboard/errors/index.html.erb +294 -622
  27. data/app/views/rails_error_dashboard/errors/job_health_summary.html.erb +7 -7
  28. data/app/views/rails_error_dashboard/errors/n_plus_one_summary.html.erb +7 -7
  29. data/app/views/rails_error_dashboard/errors/overview.html.erb +192 -363
  30. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +11 -11
  31. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +6 -6
  32. data/app/views/rails_error_dashboard/errors/releases.html.erb +6 -6
  33. data/app/views/rails_error_dashboard/errors/settings.html.erb +32 -52
  34. data/app/views/rails_error_dashboard/errors/show.html.erb +200 -203
  35. data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +7 -7
  36. data/app/views/rails_error_dashboard/errors/user_impact.html.erb +6 -6
  37. data/lib/rails_error_dashboard/commands/log_error.rb +14 -3
  38. data/lib/rails_error_dashboard/configuration.rb +6 -0
  39. data/lib/rails_error_dashboard/error_reporter.rb +11 -1
  40. data/lib/rails_error_dashboard/services/backtrace_parser.rb +10 -4
  41. data/lib/rails_error_dashboard/services/backtrace_processor.rb +44 -2
  42. data/lib/rails_error_dashboard/services/error_broadcaster.rb +19 -4
  43. data/lib/rails_error_dashboard/services/notification_helpers.rb +9 -2
  44. data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +21 -0
  45. data/lib/rails_error_dashboard/version.rb +1 -1
  46. metadata +2 -2
@@ -1,179 +1,185 @@
1
1
  <% content_for :page_title, "Errors" %>
2
2
 
3
- <!-- Subscribe to Turbo Stream updates (only if Turbo Streams is available) -->
4
- <% if defined?(Turbo::StreamsHelper) && defined?(ActionCable) && respond_to?(:turbo_stream_from) %>
3
+ <!-- Subscribe to Turbo Stream updates (only if Turbo Streams + ActionCable pubsub are available) -->
4
+ <% if defined?(Turbo::StreamsHelper) && defined?(ActionCable) && respond_to?(:turbo_stream_from) && RailsErrorDashboard::Services::ErrorBroadcaster.available? %>
5
5
  <%= turbo_stream_from "error_list" %>
6
6
  <% end %>
7
7
 
8
- <div class="py-4" data-controller="loading">
9
- <div class="d-flex justify-content-between align-items-center mb-4">
10
- <h2 class="mb-0"><i class="bi bi-bug-fill text-primary"></i> Error Overview</h2>
11
- <div class="text-muted">
12
- <small>
13
- Last updated: <%= local_time(Time.current, format: :full) %>
14
- <span class="badge bg-success ms-2" id="live-indicator">
15
- <i class="bi bi-broadcast"></i> Live
8
+ <div data-controller="loading">
9
+ <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-4);">
10
+ <h1 style="font-size: 20px; font-weight: 700; margin: 0;">Errors</h1>
11
+ <span style="font-size: 12px; color: var(--text-tertiary);">
12
+ Last updated: <%= local_time(Time.current, format: :full) %>
13
+ <% if defined?(Turbo::StreamsHelper) && defined?(ActionCable) && respond_to?(:turbo_stream_from) && RailsErrorDashboard::Services::ErrorBroadcaster.available? %>
14
+ <span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; font-size: 10px; font-weight: 600; border-radius: var(--radius-full); background: var(--status-success-bg); color: var(--status-success); margin-left: 6px;">
15
+ <i class="bi bi-broadcast" style="font-size: 10px;"></i> Live
16
16
  </span>
17
- </small>
18
- </div>
17
+ <% end %>
18
+ </span>
19
19
  </div>
20
20
 
21
- <!-- Stats Cards -->
22
- <div id="dashboard_stats" class="mb-4">
23
- <div data-loading-target="content">
24
- <%= render "stats", stats: @stats %>
25
- </div>
26
- <div class="loading-skeleton" data-loading-target="skeleton">
27
- <div class="row g-4">
28
- <% 4.times do %>
29
- <div class="col-md-3">
30
- <div class="card stat-card">
31
- <div class="card-body">
32
- <div class="skeleton skeleton-text skeleton-text-short mb-2"></div>
33
- <div class="skeleton skeleton-card"></div>
34
- </div>
35
- </div>
36
- </div>
37
- <% end %>
38
- </div>
21
+ <!-- Hidden skeleton placeholders for loading state management -->
22
+ <div class="loading-skeleton" data-loading-target="skeleton" style="display: none;">
23
+ <div style="display: flex; gap: var(--space-4); margin-bottom: var(--space-4);">
24
+ <div class="skeleton skeleton-card" style="flex: 1;"></div>
25
+ <div class="skeleton skeleton-card" style="flex: 1;"></div>
26
+ <div class="skeleton skeleton-card" style="flex: 1;"></div>
39
27
  </div>
40
28
  </div>
41
29
 
42
- <!-- Top Error Types (Only show if there are errors) -->
43
- <% if @stats[:top_errors].any? %>
44
- <div class="row g-4 mb-4">
45
- <div class="col-md-12">
46
- <div class="card">
47
- <div class="card-header bg-white">
48
- <h5 class="mb-0">Top Error Types</h5>
49
- </div>
50
- <div class="card-body">
51
- <div class="row">
52
- <% @stats[:top_errors].first(5).each do |error_type, count| %>
53
- <div class="col-md-2">
54
- <div class="text-center p-3 border rounded">
55
- <div class="fw-bold text-danger" style="font-size: 1.5rem;"><%= count %></div>
56
- <small class="text-muted text-truncate d-block" title="<%= error_type %>"><%= error_type.split('::').last %></small>
57
- </div>
58
- </div>
59
- <% end %>
60
- </div>
61
- </div>
62
- </div>
63
- </div>
30
+ <!-- Summary line + batch actions -->
31
+ <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-4);">
32
+ <span style="font-size: 13px; color: var(--text-secondary);">
33
+ <strong style="color: var(--text-primary);"><%= @pagy.count %></strong> errors
34
+ <span style="margin: 0 6px; color: var(--border-secondary);">&middot;</span>
35
+ <strong style="color: var(--status-critical);"><%= @stats[:unresolved] %></strong> unresolved
36
+ <span style="margin: 0 6px; color: var(--border-secondary);">&middot;</span>
37
+ <% if params[:timeframe].present? %>
38
+ <%= params[:timeframe].humanize %>
39
+ <% else %>
40
+ All time
41
+ <% end %>
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 %>
64
56
  </div>
65
- <% end %>
57
+ </div>
66
58
 
67
59
  <!-- Spike Detection Alert -->
68
60
  <% if @stats[:spike_detected] %>
69
- <div class="alert alert-warning mb-4" role="alert">
70
- <div class="d-flex align-items-center">
71
- <i class="bi bi-exclamation-triangle-fill fs-3 me-3"></i>
72
- <div>
73
- <h5 class="alert-heading mb-1">
74
- <% case @stats[:spike_info][:severity] %>
75
- <% when :critical %>
76
- 🚨 Critical Error Spike Detected!
77
- <% when :high %>
78
- ⚠️ High Error Spike Detected
79
- <% when :elevated %>
80
- 📈 Elevated Error Activity
81
- <% end %>
82
- </h5>
83
- <p class="mb-0">
84
- Today: <strong><%= @stats[:spike_info][:today_count] %> errors</strong>
85
- (7-day avg: <%= @stats[:spike_info][:avg_count] %>)
86
- <strong><%= @stats[:spike_info][:multiplier] %>x normal levels</strong>
87
- </p>
88
- </div>
61
+ <div class="alert alert-warning" role="alert" style="display: flex; align-items: center; gap: 10px; padding: var(--space-3) var(--space-5); background: var(--status-caution-bg); border: 1px solid var(--status-caution); border-radius: var(--radius-md); margin-bottom: var(--space-4); font-size: 13px; color: var(--text-primary);">
62
+ <i class="bi bi-exclamation-triangle-fill" style="font-size: 18px; color: var(--status-caution); flex-shrink: 0;"></i>
63
+ <div>
64
+ <strong>
65
+ <% case @stats[:spike_info][:severity] %>
66
+ <% when :critical %>
67
+ Critical Error Spike Detected!
68
+ <% when :high %>
69
+ High Error Spike Detected
70
+ <% when :elevated %>
71
+ Elevated Error Activity
72
+ <% end %>
73
+ </strong>
74
+ <span style="color: var(--text-secondary);">
75
+ Today: <strong><%= @stats[:spike_info][:today_count] %> errors</strong>
76
+ (7-day avg: <%= @stats[:spike_info][:avg_count] %>) &mdash;
77
+ <strong><%= @stats[:spike_info][:multiplier] %>x normal levels</strong>
78
+ </span>
89
79
  </div>
90
80
  </div>
91
81
  <% end %>
92
82
 
93
- <!-- 7-Day Error Trend (requires chartkick gem) -->
94
- <% if @stats[:errors_trend_7d]&.any? && respond_to?(:line_chart) %>
95
- <div class="row g-4 mb-4">
96
- <div class="col-md-8" data-loading-target="content">
97
- <div class="card">
98
- <div class="card-header bg-white d-flex justify-content-between align-items-center">
99
- <h5 class="mb-0"><i class="bi bi-graph-up"></i> 7-Day Error Trend</h5>
100
- <%= link_to analytics_errors_path, class: "btn btn-sm btn-outline-primary" do %>
101
- <i class="bi bi-bar-chart"></i> Full Analytics
102
- <% end %>
103
- </div>
104
- <div class="card-body">
105
- <%= line_chart @stats[:errors_trend_7d],
106
- color: "#8B5CF6",
107
- curve: false,
108
- points: true,
109
- height: "250px",
110
- library: {
111
- plugins: {
112
- legend: { display: false }
113
- },
114
- scales: {
115
- y: {
116
- beginAtZero: true,
117
- ticks: { precision: 0 }
118
- }
119
- }
120
- } %>
121
- </div>
122
- </div>
83
+ <!-- Filter bar -->
84
+ <%= form_with url: errors_path, method: :get, data: { turbo: false, action: "submit->loading#submit" }, id: "filter-form" do %>
85
+ <% if params[:reopened].present? %>
86
+ <%= hidden_field_tag :reopened, params[:reopened] %>
87
+ <% end %>
88
+ <div style="display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); background: var(--surface-primary); border-radius: var(--radius-md); border: 1px solid var(--border-primary); margin-bottom: var(--space-4); flex-wrap: wrap;">
89
+ <!-- Search -->
90
+ <div style="position: relative; flex: 1 1 200px; min-width: 160px;">
91
+ <i class="bi bi-search" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 13px; color: var(--text-tertiary);"></i>
92
+ <%= text_field_tag :search, params[:search], placeholder: "Search errors...", style: "width: 100%; padding: 6px 10px 6px 30px; font-size: 13px; border: 1px solid var(--border-primary); border-radius: var(--radius-sm); background: var(--surface-base); color: var(--text-primary); outline: none; font-family: var(--font-sans);" %>
123
93
  </div>
124
- <div class="col-md-4" data-loading-target="content">
125
- <div class="card">
126
- <div class="card-header bg-white">
127
- <h5 class="mb-0"><i class="bi bi-pie-chart"></i> By Severity (7d)</h5>
128
- </div>
129
- <div class="card-body">
130
- <%= pie_chart @stats[:errors_by_severity_7d],
131
- colors: ["#EF4444", "#F59E0B", "#3B82F6", "#6B7280"],
132
- height: "250px",
133
- legend: "bottom",
134
- donut: true %>
135
- </div>
136
- </div>
94
+
95
+ <div style="width: 1px; height: 24px; background: var(--border-primary);"></div>
96
+
97
+ <!-- Status pills -->
98
+ <div style="display: flex; gap: 4px; flex-wrap: wrap;">
99
+ <%
100
+ current_status = params[:status]
101
+ is_unresolved_only = params[:unresolved] != "false" && params[:unresolved] != "0"
102
+ %>
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 %>
104
+ <%= 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
+ <%= 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
+ <%= 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 %>
107
+ <%= 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 %>
108
+ </div>
109
+
110
+ <div style="width: 1px; height: 24px; background: var(--border-primary);"></div>
111
+
112
+ <!-- Severity pills -->
113
+ <div style="display: flex; gap: 4px;">
114
+ <% current_severity = params[:severity] %>
115
+ <%= link_to errors_path(permitted_filter_params.except(:severity)), class: "filter-pill #{current_severity.blank? ? 'active' : ''}", style: "text-decoration: none;" do %>Any severity<% end %>
116
+ <% %w[critical high medium low].each do |sev| %>
117
+ <%= link_to errors_path(permitted_filter_params.merge(severity: sev)), class: "filter-pill #{current_severity == sev ? 'active' : ''}", style: "text-decoration: none;" do %><%= sev.capitalize %><% end %>
118
+ <% end %>
137
119
  </div>
120
+
121
+ <!-- Advanced filters toggle -->
122
+ <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;">
124
+ <i class="bi bi-sliders"></i> More filters
125
+ </button>
138
126
  </div>
139
127
 
140
- <div class="loading-skeleton" data-loading-target="skeleton">
141
- <div class="row g-4 mb-4">
142
- <div class="col-md-8">
143
- <div class="card">
144
- <div class="card-header bg-white">
145
- <div class="skeleton skeleton-text" style="width: 200px;"></div>
146
- </div>
147
- <div class="card-body">
148
- <div class="skeleton skeleton-chart"></div>
149
- </div>
150
- </div>
151
- </div>
152
- <div class="col-md-4">
153
- <div class="card">
154
- <div class="card-header bg-white">
155
- <div class="skeleton skeleton-text" style="width: 150px;"></div>
156
- </div>
157
- <div class="card-body">
158
- <div class="skeleton skeleton-chart"></div>
159
- </div>
160
- </div>
161
- </div>
128
+ <!-- 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);">
130
+ <% if @applications.size > 1 %>
131
+ <%= select_tag :application_id, options_for_select([['All Apps', '']] + @applications, params[:application_id]), class: "form-select", style: "width: auto; min-width: 120px;" %>
132
+ <% end %>
133
+ <% if @platforms.size > 1 %>
134
+ <%= select_tag :platform, options_for_select([['All Platforms', '']] + @platforms.map { |p| [p, p] }, params[:platform]), class: "form-select", style: "width: auto; min-width: 120px;" %>
135
+ <% end %>
136
+ <%= select_tag :error_type, options_for_select([['All Types', '']] + @error_types.map { |t| [t, t] }, params[:error_type]), class: "form-select", style: "width: auto; min-width: 120px;" %>
137
+ <%= select_tag :timeframe, options_for_select([['All Time', ''], ['Last Hour', 'last_hour'], ['Today', 'today'], ['Yesterday', 'yesterday'], ['Last 7 Days', 'last_7_days'], ['Last 30 Days', 'last_30_days'], ['Last 90 Days', 'last_90_days']], params[:timeframe]), class: "form-select", style: "width: auto; min-width: 120px;" %>
138
+ <%= select_tag :frequency, options_for_select([['All Frequencies', ''], ['Once', 'once'], ['2-9 Times', 'few'], ['10-99 Times', 'frequent'], ['100+ Times', 'very_frequent'], ['Recurring', 'recurring']], params[:frequency]), class: "form-select", style: "width: auto; min-width: 120px;" %>
139
+ <%= select_tag :priority_level, options_for_select([['All Priorities', '']] + RailsErrorDashboard::ErrorLog.priority_options(include_emoji: true), params[:priority_level]), class: "form-select", style: "width: auto; min-width: 120px;" %>
140
+ <%= select_tag :assigned_to, options_for_select([['All Assignments', ''], ['Unassigned', '__unassigned__'], ['Assigned', '__assigned__']], params[:assigned_to]), class: "form-select", id: "assigned_to_filter", style: "width: auto; min-width: 120px;" %>
141
+ <div id="assignee_name_filter" style="<%= params[:assigned_to] == '__assigned__' ? '' : 'display: none;' %>">
142
+ <% assignees = defined?(@assignees) ? @assignees : [] %>
143
+ <%= select_tag :assignee_name, options_for_select([['All Assignees', '']] + assignees.map { |name| [name, name] }, params[:assignee_name]), class: "form-select", style: "width: auto; min-width: 120px;" %>
144
+ </div>
145
+
146
+ <div style="display: flex; align-items: center; gap: var(--space-4); font-size: 12px; color: var(--text-secondary);">
147
+ <label style="display: flex; align-items: center; gap: 4px; font-weight: 400;">
148
+ <%
149
+ is_checked = if params[:unresolved].nil?
150
+ true
151
+ else
152
+ params[:unresolved] != "false" && params[:unresolved] != "0"
153
+ end
154
+ %>
155
+ <%= check_box_tag :unresolved, "1", is_checked, class: "form-check-input", id: "unresolved_checkbox", style: "width: 14px; height: 14px;" %>
156
+ Unresolved only
157
+ </label>
158
+ <label style="display: flex; align-items: center; gap: 4px; font-weight: 400;">
159
+ <%= check_box_tag :hide_snoozed, "1", params[:hide_snoozed] == "1", class: "form-check-input", style: "width: 14px; height: 14px;" %>
160
+ Hide snoozed
161
+ </label>
162
+ <label style="display: flex; align-items: center; gap: 4px; font-weight: 400;">
163
+ <%= check_box_tag :hide_muted, "1", params[:hide_muted] == "1", class: "form-check-input", style: "width: 14px; height: 14px;" %>
164
+ Hide muted
165
+ </label>
166
+ </div>
167
+
168
+ <div style="display: flex; gap: 6px; margin-left: auto;">
169
+ <%= submit_tag "Apply Filters", class: "btn btn-primary btn-sm", data: { loading_target: "submitButton" } %>
170
+ <%= link_to "Clear", errors_path, class: "btn btn-sm", style: "font-size: 12px;" %>
162
171
  </div>
163
172
  </div>
164
173
  <% end %>
165
174
 
166
- <!-- Active Filters Pills -->
175
+ <!-- Active Filter Pills -->
167
176
  <%
168
177
  active_filters = []
169
178
  active_filters << { label: "Search: #{params[:search]}", param: :search } if params[:search].present?
170
-
171
- # Application filter pill
172
179
  if params[:application_id].present? && defined?(@applications)
173
180
  app_name = @applications.find { |name, id| id.to_s == params[:application_id].to_s }&.first
174
181
  active_filters << { label: "App: #{app_name}", param: :application_id } if app_name
175
182
  end
176
-
177
183
  active_filters << { label: "Platform: #{params[:platform]}", param: :platform } if params[:platform].present?
178
184
  active_filters << { label: "Type: #{params[:error_type]}", param: :error_type } if params[:error_type].present?
179
185
  active_filters << { label: "Severity: #{params[:severity].titleize}", param: :severity } if params[:severity].present?
@@ -181,519 +187,185 @@
181
187
  active_filters << { label: "Frequency: #{params[:frequency].humanize}", param: :frequency } if params[:frequency].present?
182
188
  active_filters << { label: "Status: #{params[:status].humanize}", param: :status } if params[:status].present?
183
189
  active_filters << { label: "Priority: P#{params[:priority_level]}", param: :priority_level } if params[:priority_level].present?
184
-
185
- # Special handling for assigned_to
186
- if params[:assigned_to].present? && params[:assigned_to] != '__unassigned__' && params[:assigned_to] != '__assigned__'
187
- active_filters << { label: "Assigned to: #{params[:assigned_to]}", param: :assigned_to }
188
- elsif params[:assigned_to] == '__unassigned__'
190
+ if params[:assigned_to] == '__unassigned__'
189
191
  active_filters << { label: "Unassigned", param: :assigned_to }
190
192
  elsif params[:assigned_to] == '__assigned__'
191
193
  active_filters << { label: "Assigned", param: :assigned_to }
192
- # Show assignee name filter if present
193
- if params[:assignee_name].present?
194
- active_filters << { label: "Assignee: #{params[:assignee_name]}", param: :assignee_name }
195
- end
196
194
  end
197
195
  %>
198
-
199
196
  <% if active_filters.any? %>
200
- <div class="mb-3">
201
- <div class="d-flex align-items-center gap-2 flex-wrap">
202
- <small class="text-muted fw-bold">Active filters:</small>
203
- <% active_filters.each do |filter| %>
204
- <%
205
- # Build URL without this specific filter
206
- filter_params = permitted_filter_params.except(filter[:param])
207
- %>
208
- <%= link_to errors_path(filter_params), class: "badge bg-primary text-decoration-none filter-pill" do %>
209
- <%= filter[:label] %>
210
- <i class="bi bi-x ms-1"></i>
211
- <% end %>
197
+ <div style="display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: var(--space-4); font-size: 12px;">
198
+ <span style="color: var(--text-tertiary); font-weight: 500;">Filters:</span>
199
+ <% active_filters.each do |filter| %>
200
+ <%= link_to errors_path(permitted_filter_params.except(filter[:param])), class: "filter-pill active", style: "text-decoration: none; font-size: 11px; padding: 3px 8px;" do %>
201
+ <%= filter[:label] %> <i class="bi bi-x" style="font-size: 12px;"></i>
212
202
  <% end %>
213
- <%= link_to errors_path, class: "badge bg-secondary text-decoration-none filter-pill" do %>
214
- <i class="bi bi-x-circle me-1"></i>
215
- Clear All
216
- <% end %>
217
- </div>
218
- </div>
219
- <% end %>
220
-
221
- <!-- Filters -->
222
- <div class="card mb-4" id="filters-section">
223
- <div class="card-header bg-white">
224
- <h5 class="mb-0">Filters & Search</h5>
225
- </div>
226
- <div class="card-body">
227
- <!-- Quick Filter Buttons -->
228
- <div class="d-flex gap-2 mb-3">
229
- <%= link_to "All Errors", errors_path,
230
- class: "btn btn-sm #{params[:assigned_to].blank? && params[:reopened].blank? ? 'btn-primary' : 'btn-outline-primary'}" %>
231
- <%= link_to "Unassigned", errors_path(assigned_to: '__unassigned__'),
232
- class: "btn btn-sm #{params[:assigned_to] == '__unassigned__' ? 'btn-primary' : 'btn-outline-primary'}" %>
233
- <%= link_to "Reopened", errors_path(reopened: 'true'),
234
- class: "btn btn-sm #{params[:reopened] == 'true' ? 'btn-primary' : 'btn-outline-primary'}" %>
235
- </div>
236
-
237
- <%= form_with url: errors_path, method: :get, class: "row g-3", data: { turbo: false, action: "submit->loading#submit" } do %>
238
- <% if params[:reopened].present? %>
239
- <%= hidden_field_tag :reopened, params[:reopened] %>
240
- <% end %>
241
- <div class="col-md-4">
242
- <%= text_field_tag :search, params[:search], placeholder: "Search errors...", class: "form-control" %>
243
- </div>
244
-
245
- <!-- Application Filter (only show if multiple apps) -->
246
- <% if @applications.size > 1 %>
247
- <div class="col-md-2">
248
- <%= select_tag :application_id,
249
- options_for_select(
250
- [['All Apps', '']] + @applications,
251
- params[:application_id]
252
- ),
253
- class: "form-select" %>
254
- </div>
255
- <% end %>
256
-
257
- <% if @platforms.size > 1 %>
258
- <div class="col-md-2">
259
- <%= select_tag :platform, options_for_select([['All Platforms', '']] + @platforms.map { |p| [p, p] }, params[:platform]), class: "form-select" %>
260
- </div>
261
- <% end %>
262
-
263
- <div class="col-md-2">
264
- <%= select_tag :error_type, options_for_select([['All Types', '']] + @error_types.map { |t| [t, t] }, params[:error_type]), class: "form-select" %>
265
- </div>
266
-
267
- <div class="col-md-2">
268
- <%= select_tag :severity, options_for_select([
269
- ['All Severities', ''],
270
- ['🔴 Critical', 'critical'],
271
- ['🟠 High', 'high'],
272
- ['🟡 Medium', 'medium'],
273
- ['⚪ Low', 'low']
274
- ], params[:severity]), class: "form-select" %>
275
- </div>
276
-
277
- <!-- Time Range Filter -->
278
- <div class="col-md-2">
279
- <%= select_tag :timeframe, options_for_select([
280
- ['All Time', ''],
281
- ['Last Hour', 'last_hour'],
282
- ['Today', 'today'],
283
- ['Yesterday', 'yesterday'],
284
- ['Last 7 Days', 'last_7_days'],
285
- ['Last 30 Days', 'last_30_days'],
286
- ['Last 90 Days', 'last_90_days']
287
- ], params[:timeframe]), class: "form-select" %>
288
- </div>
289
-
290
- <!-- Frequency Filter -->
291
- <div class="col-md-2">
292
- <%= select_tag :frequency, options_for_select([
293
- ['All Frequencies', ''],
294
- ['Once', 'once'],
295
- ['2-9 Times', 'few'],
296
- ['10-99 Times', 'frequent'],
297
- ['100+ Times', 'very_frequent'],
298
- ['Recurring (Active)', 'recurring']
299
- ], params[:frequency]), class: "form-select" %>
300
- </div>
301
-
302
- <!-- Phase 3: Workflow Filters -->
303
- <div class="col-md-2">
304
- <%= select_tag :status, options_for_select([
305
- ['All Statuses', ''],
306
- ['🆕 New', 'new'],
307
- ['🔵 In Progress', 'in_progress'],
308
- ['🟡 Investigating', 'investigating'],
309
- ['✅ Resolved', 'resolved'],
310
- ['⚫ Won\'t Fix', 'wont_fix']
311
- ], params[:status]), class: "form-select" %>
312
- </div>
313
-
314
- <div class="col-md-2">
315
- <%= select_tag :assigned_to, options_for_select([
316
- ['All Assignments', ''],
317
- ['Unassigned', '__unassigned__'],
318
- ['Assigned', '__assigned__']
319
- ], params[:assigned_to]), class: "form-select", id: "assigned_to_filter" %>
320
- </div>
321
-
322
- <!-- Assignee Name Filter - Only shown when "Assigned" is selected -->
323
- <div class="col-md-2" id="assignee_name_filter" style="<%= params[:assigned_to] == '__assigned__' ? '' : 'display: none;' %>">
324
- <%= select_tag :assignee_name, options_for_select(
325
- [['All Assignees', '']] + @assignees.map { |name| [name, name] },
326
- params[:assignee_name]
327
- ), class: "form-select", placeholder: "Filter by assignee..." %>
328
- </div>
329
-
330
- <div class="col-md-2">
331
- <%= select_tag :priority_level, options_for_select(
332
- [['All Priorities', '']] + RailsErrorDashboard::ErrorLog.priority_options(include_emoji: true),
333
- params[:priority_level]
334
- ), class: "form-select" %>
335
- </div>
336
-
337
- <!-- Checkboxes on their own row -->
338
- <div class="col-12 mt-2">
339
- <div class="row g-3">
340
- <div class="col-auto">
341
- <div class="form-check">
342
- <%
343
- # Determine checkbox state based on params
344
- # If unresolved param is missing or nil, default to checked (true)
345
- # If unresolved param is explicitly "false" or "0", uncheck it
346
- is_checked = if params[:unresolved].nil?
347
- true # Default to checked when first loading
348
- else
349
- params[:unresolved] != "false" && params[:unresolved] != "0"
350
- end
351
- %>
352
- <%= check_box_tag :unresolved, "1", is_checked, class: "form-check-input", id: "unresolved_checkbox" %>
353
- <%= label_tag :unresolved, "Unresolved only", class: "form-check-label" %>
354
- </div>
355
- </div>
356
-
357
- <div class="col-auto">
358
- <div class="form-check">
359
- <%= check_box_tag :hide_snoozed, "1", params[:hide_snoozed] == "1", class: "form-check-input" %>
360
- <%= label_tag :hide_snoozed, "Hide snoozed", class: "form-check-label" %>
361
- </div>
362
- </div>
363
-
364
- <div class="col-auto">
365
- <div class="form-check">
366
- <%= check_box_tag :hide_muted, "1", params[:hide_muted] == "1", class: "form-check-input" %>
367
- <%= label_tag :hide_muted, "Hide muted", class: "form-check-label" %>
368
- </div>
369
- </div>
370
- </div>
371
- </div>
372
-
373
- <div class="col-12 mt-3">
374
- <%= submit_tag "Apply Filters", class: "btn btn-primary", data: { loading_target: "submitButton" } %>
375
- <%= link_to "Clear", errors_path, class: "btn btn-outline-secondary" %>
376
- </div>
377
203
  <% end %>
204
+ <%= link_to errors_path, style: "color: var(--text-tertiary); font-size: 11px;" do %>Clear all<% end %>
378
205
  </div>
379
- </div>
380
-
381
- <!-- Error List Table -->
382
- <div class="card">
383
- <div class="card-header bg-white d-flex justify-content-between align-items-center">
384
- <h5 class="mb-0">Recent Errors</h5>
385
- <small class="text-muted"><%== @pagy.info_tag %></small>
386
- </div>
206
+ <% end %>
387
207
 
388
- <!-- Batch Actions Toolbar -->
389
- <div class="card-body border-bottom" id="batch-actions-toolbar" style="display: none;">
390
- <%= form_with url: batch_action_errors_path, method: :post, id: "batch-form" do |f| %>
391
- <div class="row align-items-center">
392
- <div class="col-auto">
393
- <span id="selected-count" class="fw-bold">0 selected</span>
394
- </div>
395
- <div class="col-auto">
396
- <%= button_tag type: "submit", name: "action_type", value: "resolve", class: "btn btn-sm btn-success" do %>
397
- <i class="bi bi-check-circle"></i> Resolve Selected
398
- <% end %>
399
- </div>
400
- <div class="col-auto">
401
- <%= button_tag type: "submit", name: "action_type", value: "delete", class: "btn btn-sm btn-danger", data: { confirm: "Are you sure you want to delete the selected errors?" } do %>
402
- <i class="bi bi-trash"></i> Delete Selected
208
+ <!-- Error Table -->
209
+ <div class="card" style="overflow: hidden;">
210
+ <% if @errors.any? %>
211
+ <div data-loading-target="content">
212
+ <table class="table table-hover" style="margin-bottom: 0;">
213
+ <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;">
216
+ <input type="checkbox" id="select-all" class="form-check-input" style="accent-color: var(--accent);">
217
+ </th>
218
+ <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
+ <% if @applications.size > 1 && params[:application_id].blank? %>
224
+ <th style="padding: var(--space-3) var(--space-4); width: 90px;">App</th>
225
+ <% end %>
226
+ <% if @platforms.size > 1 %>
227
+ <th style="padding: var(--space-3) var(--space-4); width: 80px;">Platform</th>
228
+ <% end %>
229
+ </tr>
230
+ </thead>
231
+ <tbody id="error_list">
232
+ <% @errors.each do |error| %>
233
+ <%= render "error_row", error: error, show_platform: @platforms.size > 1, show_application: (@applications.size > 1 && params[:application_id].blank?) %>
403
234
  <% end %>
404
- </div>
405
- <div class="col-auto">
406
- <button type="button" class="btn btn-sm btn-outline-secondary" id="clear-selection">
407
- Clear Selection
408
- </button>
409
- </div>
410
- </div>
411
- <% end %>
412
- </div>
413
-
414
- <div class="card-body p-0">
415
- <% if @errors.any? %>
416
- <div data-loading-target="content">
417
- <div class="table-responsive">
418
- <table class="table table-hover mb-0">
419
- <thead class="table-light">
420
- <tr>
421
- <th style="width: 40px;">
422
- <input type="checkbox" id="select-all" class="form-check-input">
423
- </th>
424
- <th><%= sortable_header("Severity", "severity") %></th>
425
- <th><%= sortable_header("Error Type", "error_type") %></th>
426
- <th>Message</th>
427
- <th><%= sortable_header("Occurrences", "occurrence_count") %></th>
428
- <th><%= sortable_header("First / Last Seen", "last_seen_at") %></th>
235
+ </tbody>
236
+ </table>
429
237
 
430
- <!-- Show App column only when viewing all apps -->
431
- <% if @applications.size > 1 && params[:application_id].blank? %>
432
- <th><%= sortable_header("Application", "application_id") %></th>
433
- <% end %>
434
-
435
- <% if @platforms.size > 1 %>
436
- <th><%= sortable_header("Platform", "platform") %></th>
437
- <% end %>
438
- <th>Status</th>
439
- <th></th>
440
- </tr>
441
- </thead>
442
- <tbody id="error_list">
443
- <% @errors.each do |error| %>
444
- <%= render "error_row", error: error, show_platform: @platforms.size > 1, show_application: (@applications.size > 1 && params[:application_id].blank?) %>
445
- <% end %>
446
- </tbody>
447
- </table>
448
- </div>
449
-
450
- <!-- Pagination -->
451
- <div class="p-3">
452
- <%== @pagy.series_nav(:bootstrap) if @pagy.pages > 1 %>
238
+ <!-- Pagination -->
239
+ <% if @pagy.pages > 1 %>
240
+ <div style="padding: var(--space-4); border-top: 1px solid var(--border-primary);">
241
+ <%== @pagy.series_nav(:bootstrap) %>
453
242
  </div>
454
- </div>
243
+ <% end %>
244
+ </div>
455
245
 
456
- <div class="loading-skeleton" data-loading-target="skeleton">
457
- <div class="p-3">
458
- <% 5.times do %>
459
- <div class="skeleton skeleton-row mb-2"></div>
460
- <% end %>
461
- </div>
462
- </div>
463
- <% else %>
464
- <div class="text-center py-5">
465
- <i class="bi bi-check-circle display-1 text-success mb-3"></i>
466
- <h4 class="text-muted">All Clear!</h4>
467
- <p class="text-muted">
468
- <% if params[:search].present? || params[:error_type].present? || params[:platform].present? || params[:severity].present? %>
469
- No errors match your current filters. Try adjusting your search criteria.
470
- <% else %>
471
- No errors have been logged yet. Your application is running smoothly!
472
- <% end %>
473
- </p>
474
- <% unless params.values.compact.any? %>
475
- <small class="text-muted d-block mt-3">
476
- <i class="bi bi-lightbulb"></i> Errors will appear here automatically when they occur.
477
- </small>
246
+ <div class="loading-skeleton" data-loading-target="skeleton">
247
+ <div style="padding: var(--space-4);">
248
+ <% 5.times do %>
249
+ <div class="skeleton skeleton-row" style="margin-bottom: 2px;"></div>
478
250
  <% end %>
479
251
  </div>
480
- <% end %>
481
- </div>
252
+ </div>
253
+ <% else %>
254
+ <!-- Empty State -->
255
+ <div class="red-empty-state">
256
+ <% if params[:search].present? || params[:error_type].present? || params[:severity].present? %>
257
+ <div class="red-empty-state-icon"><i class="bi bi-funnel"></i></div>
258
+ <div class="red-empty-state-title">No errors match your filters</div>
259
+ <div class="red-empty-state-message">Try adjusting your search or filter criteria to find what you're looking for.</div>
260
+ <%= link_to errors_path, class: "red-empty-state-cta" do %><i class="bi bi-x-circle"></i> Clear all filters<% end %>
261
+ <% else %>
262
+ <div class="red-empty-state-icon" style="background: var(--status-success-bg); color: var(--status-success);"><i class="bi bi-check-lg"></i></div>
263
+ <div class="red-empty-state-title">All clear!</div>
264
+ <div class="red-empty-state-message">No errors have been logged yet. Your application is running smoothly. Errors will appear here automatically when they occur.</div>
265
+ <% end %>
266
+ </div>
267
+ <% end %>
482
268
  </div>
483
269
  </div>
484
270
 
485
271
  <script>
486
- document.addEventListener('DOMContentLoaded', function() {
487
- const selectAllCheckbox = document.getElementById('select-all');
488
- const errorCheckboxes = document.querySelectorAll('.error-checkbox');
489
- const batchToolbar = document.getElementById('batch-actions-toolbar');
490
- const selectedCountSpan = document.getElementById('selected-count');
491
- const batchForm = document.getElementById('batch-form');
492
- const clearSelectionBtn = document.getElementById('clear-selection');
493
-
494
- function updateBatchToolbar() {
495
- const checkedBoxes = document.querySelectorAll('.error-checkbox:checked');
496
- const count = checkedBoxes.length;
497
-
498
- if (count > 0) {
499
- batchToolbar.style.display = 'block';
500
- selectedCountSpan.textContent = `${count} selected`;
501
- } else {
502
- batchToolbar.style.display = 'none';
503
- }
504
- }
505
-
506
- // Select all checkbox
507
- if (selectAllCheckbox) {
508
- selectAllCheckbox.addEventListener('change', function() {
509
- errorCheckboxes.forEach(checkbox => {
510
- checkbox.checked = this.checked;
511
- });
512
- updateBatchToolbar();
513
- });
272
+ document.addEventListener('DOMContentLoaded', function() {
273
+ var selectAllCheckbox = document.getElementById('select-all');
274
+ var batchInline = document.getElementById('batch-actions-inline');
275
+ var selectedCountSpan = document.getElementById('selected-count');
276
+ var batchForm = document.getElementById('batch-form');
277
+
278
+ function getErrorCheckboxes() {
279
+ return document.querySelectorAll('.error-checkbox');
280
+ }
281
+
282
+ function updateBatchToolbar() {
283
+ var checkedBoxes = document.querySelectorAll('.error-checkbox:checked');
284
+ var count = checkedBoxes.length;
285
+ if (count > 0) {
286
+ batchInline.style.display = 'flex';
287
+ selectedCountSpan.textContent = count + ' selected';
288
+ } else {
289
+ batchInline.style.display = 'none';
514
290
  }
291
+ }
515
292
 
516
- // Individual checkboxes
517
- errorCheckboxes.forEach(checkbox => {
518
- checkbox.addEventListener('change', function() {
519
- updateBatchToolbar();
520
-
521
- // Update "select all" checkbox state
522
- const allChecked = Array.from(errorCheckboxes).every(cb => cb.checked);
523
- const someChecked = Array.from(errorCheckboxes).some(cb => cb.checked);
524
-
525
- if (selectAllCheckbox) {
526
- selectAllCheckbox.checked = allChecked;
527
- selectAllCheckbox.indeterminate = someChecked && !allChecked;
528
- }
529
- });
293
+ if (selectAllCheckbox) {
294
+ selectAllCheckbox.addEventListener('change', function() {
295
+ getErrorCheckboxes().forEach(function(cb) { cb.checked = selectAllCheckbox.checked; });
296
+ updateBatchToolbar();
530
297
  });
531
-
532
- // Clear selection button
533
- if (clearSelectionBtn) {
534
- clearSelectionBtn.addEventListener('click', function() {
535
- errorCheckboxes.forEach(checkbox => {
536
- checkbox.checked = false;
537
- });
538
- if (selectAllCheckbox) {
539
- selectAllCheckbox.checked = false;
540
- selectAllCheckbox.indeterminate = false;
541
- }
542
- updateBatchToolbar();
543
- });
544
- }
545
-
546
- // Form submission - add selected error IDs
547
- if (batchForm) {
548
- batchForm.addEventListener('submit', function(e) {
549
- const checkedBoxes = document.querySelectorAll('.error-checkbox:checked');
550
-
551
- if (checkedBoxes.length === 0) {
552
- e.preventDefault();
553
- alert('Please select at least one error');
554
- return false;
555
- }
556
-
557
- // Add hidden inputs for each selected error ID
558
- checkedBoxes.forEach(checkbox => {
559
- const input = document.createElement('input');
560
- input.type = 'hidden';
561
- input.name = 'error_ids[]';
562
- input.value = checkbox.value;
563
- this.appendChild(input);
564
- });
565
- });
298
+ }
299
+
300
+ document.addEventListener('change', function(e) {
301
+ if (e.target.classList.contains('error-checkbox')) {
302
+ 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
+ }
566
310
  }
567
311
  });
568
312
 
569
- // Turbo Stream animation for new errors
570
- document.addEventListener('turbo:before-stream-render', (event) => {
571
- const { target, action } = event.detail.newStream;
313
+ if (batchForm) {
314
+ batchForm.addEventListener('submit', function(e) {
315
+ var checkedBoxes = document.querySelectorAll('.error-checkbox:checked');
316
+ if (checkedBoxes.length === 0) { e.preventDefault(); return false; }
317
+ checkedBoxes.forEach(function(cb) {
318
+ var input = document.createElement('input');
319
+ input.type = 'hidden'; input.name = 'error_ids[]'; input.value = cb.value;
320
+ batchForm.appendChild(input);
321
+ });
322
+ });
323
+ }
572
324
 
573
- // Highlight new errors when prepended
325
+ // Turbo Stream animations
326
+ document.addEventListener('turbo:before-stream-render', function(event) {
327
+ var target = event.detail.newStream.target;
328
+ var action = event.detail.newStream.action;
574
329
  if (action === 'prepend' && target === 'error_list') {
575
- setTimeout(() => {
576
- const firstRow = document.querySelector('#error_list tr:first-child');
330
+ setTimeout(function() {
331
+ var firstRow = document.querySelector('#error_list tr:first-child');
577
332
  if (firstRow) {
578
- firstRow.classList.add('new-error');
579
-
580
- // Remove class after animation completes
581
- setTimeout(() => {
582
- firstRow.classList.remove('new-error');
583
- }, 3000);
333
+ firstRow.style.background = 'var(--status-success-bg)';
334
+ setTimeout(function() { firstRow.style.background = ''; }, 3000);
584
335
  }
585
336
  }, 10);
586
337
  }
587
-
588
- // Pulse stats cards when updated
589
- if (action === 'replace' && target === 'dashboard_stats') {
590
- setTimeout(() => {
591
- const statCards = document.querySelectorAll('.stat-card');
592
- statCards.forEach(card => {
593
- card.classList.add('updated');
594
- setTimeout(() => card.classList.remove('updated'), 500);
595
- });
596
- }, 10);
597
- }
598
338
  });
599
339
 
600
- // Initialize Bootstrap tooltips
601
- document.addEventListener('DOMContentLoaded', function() {
602
- const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
603
- tooltipTriggerList.map(function (tooltipTriggerEl) {
604
- return new bootstrap.Tooltip(tooltipTriggerEl);
340
+ // Scroll position preservation
341
+ var scrollPos = sessionStorage.getItem('scrollPos');
342
+ if (scrollPos) { window.scrollTo(0, parseInt(scrollPos)); sessionStorage.removeItem('scrollPos'); }
343
+
344
+ var filterForm = document.getElementById('filter-form');
345
+ if (filterForm) {
346
+ filterForm.addEventListener('submit', function() {
347
+ sessionStorage.setItem('scrollPos', window.pageYOffset);
348
+ var unresolvedCb = document.getElementById('unresolved_checkbox');
349
+ if (unresolvedCb && !unresolvedCb.checked) {
350
+ var hidden = document.createElement('input');
351
+ hidden.type = 'hidden'; hidden.name = 'unresolved'; hidden.value = '0';
352
+ filterForm.appendChild(hidden);
353
+ unresolvedCb.disabled = true;
354
+ }
605
355
  });
606
- });
607
-
608
- // Keyboard shortcuts
609
- document.addEventListener('keydown', function(e) {
610
- // Ignore if typing in input/textarea
611
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
612
-
613
- // 'r' - Refresh page
614
- if (e.key === 'r') {
615
- location.reload();
616
- }
617
-
618
- // '/' - Focus search
619
- if (e.key === '/') {
620
- e.preventDefault();
621
- const searchInput = document.querySelector('input[name="search"]');
622
- if (searchInput) searchInput.focus();
623
- }
624
-
625
- // 'a' - Go to analytics
626
- if (e.key === 'a') {
627
- window.location.href = '<%= analytics_errors_path %>';
628
- }
629
-
630
- // '?' - Show keyboard shortcuts help
631
- if (e.key === '?') {
632
- e.preventDefault();
633
- const modal = new bootstrap.Modal(document.getElementById('keyboardShortcutsModal'));
634
- modal.show();
635
- }
636
- });
637
-
638
- // Preserve scroll position on page load
639
- document.addEventListener('DOMContentLoaded', function() {
640
- const scrollPos = sessionStorage.getItem('scrollPos');
641
- if (scrollPos) {
642
- window.scrollTo(0, parseInt(scrollPos));
643
- sessionStorage.removeItem('scrollPos');
644
- }
645
- });
646
-
647
- // Handle filter form submission
648
- document.addEventListener('DOMContentLoaded', function() {
649
- const filterForm = document.querySelector('form[action="<%= errors_path %>"]');
650
- if (filterForm) {
651
- filterForm.addEventListener('submit', function(e) {
652
- // Save scroll position before form submission
653
- sessionStorage.setItem('scrollPos', window.pageYOffset);
654
-
655
- // Handle unchecked checkbox - when unchecked, send "0" explicitly
656
- const unresolvedCheckbox = document.getElementById('unresolved_checkbox');
657
- if (unresolvedCheckbox && !unresolvedCheckbox.checked) {
658
- // Create hidden input to send "0" when unchecked
659
- const hiddenInput = document.createElement('input');
660
- hiddenInput.type = 'hidden';
661
- hiddenInput.name = 'unresolved';
662
- hiddenInput.value = '0';
663
- filterForm.appendChild(hiddenInput);
664
- // Remove the checkbox from form submission to avoid sending empty value
665
- unresolvedCheckbox.disabled = true;
666
- }
667
- });
668
- }
669
- });
670
-
671
- // Update browser tab title with unresolved error count
672
- document.addEventListener('DOMContentLoaded', function() {
673
- const unresolvedCount = <%= @stats[:unresolved] || 0 %>;
674
- if (unresolvedCount > 0) {
675
- document.title = `(${unresolvedCount}) ${document.title}`;
676
- }
677
- });
678
-
679
- // Toggle assignee name filter visibility based on assigned_to selection
680
- document.addEventListener('DOMContentLoaded', function() {
681
- const assignedToFilter = document.getElementById('assigned_to_filter');
682
- const assigneeNameFilter = document.getElementById('assignee_name_filter');
683
-
684
- if (assignedToFilter && assigneeNameFilter) {
685
- assignedToFilter.addEventListener('change', function() {
686
- if (this.value === '__assigned__') {
687
- assigneeNameFilter.style.display = 'block';
688
- } else {
689
- assigneeNameFilter.style.display = 'none';
690
- // Reset assignee name filter when hiding
691
- const assigneeNameSelect = assigneeNameFilter.querySelector('select');
692
- if (assigneeNameSelect) {
693
- assigneeNameSelect.value = '';
694
- }
695
- }
696
- });
697
- }
698
- });
356
+ }
357
+
358
+ // Tab title with unresolved count
359
+ var unresolvedCount = <%= @stats[:unresolved] || 0 %>;
360
+ if (unresolvedCount > 0) { document.title = '(' + unresolvedCount + ') ' + document.title; }
361
+
362
+ // Toggle assignee name filter visibility
363
+ var assignedToFilter = document.getElementById('assigned_to_filter');
364
+ var assigneeNameFilter = document.getElementById('assignee_name_filter');
365
+ if (assignedToFilter && assigneeNameFilter) {
366
+ assignedToFilter.addEventListener('change', function() {
367
+ assigneeNameFilter.style.display = this.value === '__assigned__' ? '' : 'none';
368
+ });
369
+ }
370
+ });
699
371
  </script>