rails_error_dashboard 0.5.15 → 0.6.1

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/rails_error_dashboard/application_controller.rb +37 -12
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +48 -26
  4. data/app/helpers/rails_error_dashboard/application_helper.rb +12 -5
  5. data/app/views/layouts/rails_error_dashboard.html.erb +1219 -1927
  6. data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +4 -4
  7. data/app/views/rails_error_dashboard/errors/_co_occurring_errors.html.erb +1 -1
  8. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +3 -3
  9. data/app/views/rails_error_dashboard/errors/_error_cascades.html.erb +1 -1
  10. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +69 -79
  11. data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +1 -1
  12. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +1 -1
  13. data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +1 -1
  14. data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +2 -2
  15. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +1 -1
  16. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +1 -1
  17. data/app/views/rails_error_dashboard/errors/_similar_errors.html.erb +1 -1
  18. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +4 -4
  19. data/app/views/rails_error_dashboard/errors/actioncable_health_summary.html.erb +6 -6
  20. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +6 -6
  21. data/app/views/rails_error_dashboard/errors/analytics.html.erb +34 -50
  22. data/app/views/rails_error_dashboard/errors/cache_health_summary.html.erb +7 -7
  23. data/app/views/rails_error_dashboard/errors/correlation.html.erb +11 -11
  24. data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +114 -172
  25. data/app/views/rails_error_dashboard/errors/deprecations.html.erb +7 -7
  26. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +6 -6
  27. data/app/views/rails_error_dashboard/errors/index.html.erb +311 -613
  28. data/app/views/rails_error_dashboard/errors/job_health_summary.html.erb +7 -7
  29. data/app/views/rails_error_dashboard/errors/n_plus_one_summary.html.erb +7 -7
  30. data/app/views/rails_error_dashboard/errors/overview.html.erb +192 -363
  31. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +11 -11
  32. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +6 -6
  33. data/app/views/rails_error_dashboard/errors/releases.html.erb +6 -6
  34. data/app/views/rails_error_dashboard/errors/settings.html.erb +53 -51
  35. data/app/views/rails_error_dashboard/errors/show.html.erb +200 -203
  36. data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +19 -19
  37. data/app/views/rails_error_dashboard/errors/user_impact.html.erb +6 -6
  38. data/config/routes.rb +1 -0
  39. data/lib/rails_error_dashboard/configuration.rb +6 -0
  40. data/lib/rails_error_dashboard/services/error_broadcaster.rb +1 -1
  41. data/lib/rails_error_dashboard/services/system_health_snapshot.rb +1 -1
  42. data/lib/rails_error_dashboard/test_error.rb +7 -0
  43. data/lib/rails_error_dashboard/version.rb +1 -1
  44. data/lib/rails_error_dashboard.rb +1 -0
  45. metadata +3 -2
@@ -5,175 +5,167 @@
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>
64
- </div>
65
- <% end %>
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>
66
44
 
67
45
  <!-- Spike Detection Alert -->
68
46
  <% 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>
47
+ <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);">
48
+ <i class="bi bi-exclamation-triangle-fill" style="font-size: 18px; color: var(--status-caution); flex-shrink: 0;"></i>
49
+ <div>
50
+ <strong>
51
+ <% case @stats[:spike_info][:severity] %>
52
+ <% when :critical %>
53
+ Critical Error Spike Detected!
54
+ <% when :high %>
55
+ High Error Spike Detected
56
+ <% when :elevated %>
57
+ Elevated Error Activity
58
+ <% end %>
59
+ </strong>
60
+ <span style="color: var(--text-secondary);">
61
+ Today: <strong><%= @stats[:spike_info][:today_count] %> errors</strong>
62
+ (7-day avg: <%= @stats[:spike_info][:avg_count] %>) &mdash;
63
+ <strong><%= @stats[:spike_info][:multiplier] %>x normal levels</strong>
64
+ </span>
89
65
  </div>
90
66
  </div>
91
67
  <% end %>
92
68
 
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>
69
+ <!-- Filter bar -->
70
+ <%= form_with url: errors_path, method: :get, data: { turbo: false, action: "submit->loading#submit" }, id: "filter-form" do %>
71
+ <% if params[:reopened].present? %>
72
+ <%= hidden_field_tag :reopened, params[:reopened] %>
73
+ <% end %>
74
+ <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;">
75
+ <!-- Search -->
76
+ <div style="position: relative; flex: 1 1 200px; min-width: 160px;">
77
+ <i class="bi bi-search" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 13px; color: var(--text-tertiary);"></i>
78
+ <%= 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
79
  </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>
80
+
81
+ <div style="width: 1px; height: 24px; background: var(--border-primary);"></div>
82
+
83
+ <!-- Status pills -->
84
+ <div style="display: flex; gap: 4px; flex-wrap: wrap;">
85
+ <%
86
+ current_status = params[:status]
87
+ is_unresolved_only = params[:unresolved] != "false" && params[:unresolved] != "0"
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 %>
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
+ <%= 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 %>
137
94
  </div>
95
+
96
+ <div style="width: 1px; height: 24px; background: var(--border-primary);"></div>
97
+
98
+ <!-- Severity pills -->
99
+ <div style="display: flex; gap: 4px;">
100
+ <% current_severity = params[:severity] %>
101
+ <%= link_to errors_path(permitted_filter_params.except(:severity)), class: "filter-pill #{current_severity.blank? ? 'active' : ''}", style: "text-decoration: none;" do %>Any severity<% end %>
102
+ <% %w[critical high medium low].each do |sev| %>
103
+ <%= 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 %>
104
+ <% end %>
105
+ </div>
106
+
107
+ <!-- Advanced filters toggle -->
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;">
110
+ <i class="bi bi-sliders"></i> More filters
111
+ </button>
138
112
  </div>
139
113
 
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>
114
+ <!-- Advanced filters -->
115
+ <div id="advanced-filters" style="display: none; gap: var(--space-3); flex-wrap: wrap; margin-bottom: var(--space-4); padding: var(--space-3) var(--space-4); background: var(--surface-primary); border-radius: var(--radius-md); border: 1px solid var(--border-primary);">
116
+ <% if @applications.size > 1 %>
117
+ <%= select_tag :application_id, options_for_select([['All Apps', '']] + @applications, params[:application_id]), class: "form-select", style: "width: auto; min-width: 120px;" %>
118
+ <% end %>
119
+ <% if @platforms.size > 1 %>
120
+ <%= select_tag :platform, options_for_select([['All Platforms', '']] + @platforms.map { |p| [p, p] }, params[:platform]), class: "form-select", style: "width: auto; min-width: 120px;" %>
121
+ <% end %>
122
+ <%= 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;" %>
123
+ <%= 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;" %>
124
+ <%= 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;" %>
125
+ <%= 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;" %>
126
+ <%= 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;" %>
127
+ <div id="assignee_name_filter" style="<%= params[:assigned_to] == '__assigned__' ? '' : 'display: none;' %>">
128
+ <% assignees = defined?(@assignees) ? @assignees : [] %>
129
+ <%= 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;" %>
130
+ </div>
131
+
132
+ <div style="display: flex; align-items: center; gap: var(--space-4); font-size: 12px; color: var(--text-secondary);">
133
+ <label style="display: flex; align-items: center; gap: 4px; font-weight: 400;">
134
+ <%
135
+ is_checked = if params[:unresolved].nil?
136
+ true
137
+ else
138
+ params[:unresolved] != "false" && params[:unresolved] != "0"
139
+ end
140
+ %>
141
+ <%= check_box_tag :unresolved, "1", is_checked, class: "form-check-input", id: "unresolved_checkbox", style: "width: 14px; height: 14px;" %>
142
+ Unresolved only
143
+ </label>
144
+ <label style="display: flex; align-items: center; gap: 4px; font-weight: 400;">
145
+ <%= check_box_tag :hide_snoozed, "1", params[:hide_snoozed] == "1", class: "form-check-input", style: "width: 14px; height: 14px;" %>
146
+ Hide snoozed
147
+ </label>
148
+ <label style="display: flex; align-items: center; gap: 4px; font-weight: 400;">
149
+ <%= check_box_tag :hide_muted, "1", params[:hide_muted] == "1", class: "form-check-input", style: "width: 14px; height: 14px;" %>
150
+ Hide muted
151
+ </label>
152
+ </div>
153
+
154
+ <div style="display: flex; gap: 6px; margin-left: auto;">
155
+ <%= submit_tag "Apply Filters", class: "btn btn-primary btn-sm", data: { loading_target: "submitButton" } %>
156
+ <%= link_to "Clear", errors_path, class: "btn btn-sm", style: "font-size: 12px;" %>
162
157
  </div>
163
158
  </div>
164
159
  <% end %>
165
160
 
166
- <!-- Active Filters Pills -->
161
+ <!-- Active Filter Pills -->
167
162
  <%
168
163
  active_filters = []
169
164
  active_filters << { label: "Search: #{params[:search]}", param: :search } if params[:search].present?
170
-
171
- # Application filter pill
172
165
  if params[:application_id].present? && defined?(@applications)
173
166
  app_name = @applications.find { |name, id| id.to_s == params[:application_id].to_s }&.first
174
167
  active_filters << { label: "App: #{app_name}", param: :application_id } if app_name
175
168
  end
176
-
177
169
  active_filters << { label: "Platform: #{params[:platform]}", param: :platform } if params[:platform].present?
178
170
  active_filters << { label: "Type: #{params[:error_type]}", param: :error_type } if params[:error_type].present?
179
171
  active_filters << { label: "Severity: #{params[:severity].titleize}", param: :severity } if params[:severity].present?
@@ -181,519 +173,225 @@
181
173
  active_filters << { label: "Frequency: #{params[:frequency].humanize}", param: :frequency } if params[:frequency].present?
182
174
  active_filters << { label: "Status: #{params[:status].humanize}", param: :status } if params[:status].present?
183
175
  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__'
176
+ if params[:assigned_to] == '__unassigned__'
189
177
  active_filters << { label: "Unassigned", param: :assigned_to }
190
178
  elsif params[:assigned_to] == '__assigned__'
191
179
  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
180
  end
197
181
  %>
198
-
199
182
  <% 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 %>
212
- <% 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>
183
+ <div style="display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: var(--space-4); font-size: 12px;">
184
+ <span style="color: var(--text-tertiary); font-weight: 500;">Filters:</span>
185
+ <% active_filters.each do |filter| %>
186
+ <%= 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 %>
187
+ <%= filter[:label] %> <i class="bi bi-x" style="font-size: 12px;"></i>
255
188
  <% 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
- <% end %>
378
- </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>
387
-
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
403
- <% 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
189
  <% end %>
190
+ <%= link_to errors_path, style: "color: var(--text-tertiary); font-size: 11px;" do %>Clear all<% end %>
412
191
  </div>
192
+ <% end %>
413
193
 
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>
429
-
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>
194
+ <!-- Error Table -->
195
+ <div class="card" style="overflow: hidden;">
196
+ <% if @errors.any? %>
197
+ <div data-loading-target="content">
198
+ <div style="overflow-x: auto;">
199
+ <table class="table table-hover" style="margin-bottom: 0; width: 100%; table-layout: fixed;">
200
+ <thead>
201
+ <tr id="thead-columns" style="border-bottom: 1px solid var(--border-primary);">
202
+ <th style="width: 36px; padding: var(--space-3) var(--space-4); text-align: center;">
203
+ <input type="checkbox" id="select-all" class="form-check-input" style="accent-color: var(--accent);">
204
+ </th>
205
+ <th style="padding: var(--space-3) var(--space-4); text-align: left;">Error</th>
206
+ <th style="padding: var(--space-3) var(--space-4); text-align: left; width: 100px;">Status</th>
207
+ <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 70px;">Events</th>
208
+ <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 55px;">Users</th>
209
+ <th style="padding: var(--space-3) var(--space-4); text-align: right; width: 85px;">Last seen</th>
210
+ <% if @applications.size > 1 && params[:application_id].blank? %>
211
+ <th style="padding: var(--space-3) var(--space-4); width: 100px;">App</th>
212
+ <% end %>
213
+ <% if @platforms.size > 1 %>
214
+ <th style="padding: var(--space-3) var(--space-4); width: 100px;">Platform</th>
215
+ <% end %>
216
+ </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] %>
433
225
  <% end %>
434
-
435
- <% if @platforms.size > 1 %>
436
- <th><%= sortable_header("Platform", "platform") %></th>
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
437
232
  <% 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
233
  <% end %>
446
- </tbody>
447
- </table>
448
- </div>
449
-
450
- <!-- Pagination -->
451
- <div class="p-3">
452
- <%== @pagy.series_nav(:bootstrap) if @pagy.pages > 1 %>
453
- </div>
234
+ </th>
235
+ </tr>
236
+ </thead>
237
+ <tbody id="error_list">
238
+ <% @errors.each do |error| %>
239
+ <%= render "error_row", error: error, show_platform: @platforms.size > 1, show_application: (@applications.size > 1 && params[:application_id].blank?) %>
240
+ <% end %>
241
+ </tbody>
242
+ </table>
454
243
  </div>
455
244
 
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 %>
245
+ <!-- Pagination -->
246
+ <% if @pagy.pages > 1 %>
247
+ <div style="padding: var(--space-4); border-top: 1px solid var(--border-primary); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3);">
248
+ <span style="font-size: 12px; color: var(--text-tertiary);">Showing <%= @pagy.from %>–<%= @pagy.to %> of <%= @pagy.count %> errors</span>
249
+ <div><%== @pagy.series_nav(:bootstrap) %></div>
461
250
  </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>
251
+ <% end %>
252
+ </div>
253
+
254
+ <div class="loading-skeleton" data-loading-target="skeleton">
255
+ <div style="padding: var(--space-4);">
256
+ <% 5.times do %>
257
+ <div class="skeleton skeleton-row" style="margin-bottom: 2px;"></div>
478
258
  <% end %>
479
259
  </div>
480
- <% end %>
481
- </div>
260
+ </div>
261
+ <% else %>
262
+ <!-- Empty State -->
263
+ <div class="red-empty-state">
264
+ <% if params[:search].present? || params[:error_type].present? || params[:severity].present? %>
265
+ <div class="red-empty-state-icon"><i class="bi bi-funnel"></i></div>
266
+ <div class="red-empty-state-title">No errors match your filters</div>
267
+ <div class="red-empty-state-message">Try adjusting your search or filter criteria to find what you're looking for.</div>
268
+ <%= link_to errors_path, class: "red-empty-state-cta" do %><i class="bi bi-x-circle"></i> Clear all filters<% end %>
269
+ <% else %>
270
+ <div class="red-empty-state-icon" style="background: var(--status-success-bg); color: var(--status-success);"><i class="bi bi-check-lg"></i></div>
271
+ <div class="red-empty-state-title">All clear!</div>
272
+ <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>
273
+ <% end %>
274
+ </div>
275
+ <% end %>
482
276
  </div>
483
277
  </div>
484
278
 
485
279
  <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
- }
280
+ document.addEventListener('DOMContentLoaded', function() {
281
+ var selectAllCheckbox = document.getElementById('select-all');
282
+ var selectAllBatchCheckbox = document.getElementById('select-all-batch');
283
+ var theadColumns = document.getElementById('thead-columns');
284
+ var theadBatch = document.getElementById('thead-batch');
285
+ var selectedCountSpan = document.getElementById('selected-count');
286
+ var batchForm = document.getElementById('batch-form');
287
+
288
+ function getErrorCheckboxes() {
289
+ return document.querySelectorAll('.error-checkbox');
290
+ }
291
+
292
+ function updateBatchToolbar() {
293
+ var checkedBoxes = document.querySelectorAll('.error-checkbox:checked');
294
+ var count = checkedBoxes.length;
295
+ var boxes = getErrorCheckboxes();
296
+ var allChecked = boxes.length > 0 && Array.from(boxes).every(function(cb) { return cb.checked; });
297
+ var someChecked = Array.from(boxes).some(function(cb) { return cb.checked; });
298
+
299
+ 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;
305
+ } else {
306
+ theadColumns.style.display = '';
307
+ theadBatch.style.display = 'none';
504
308
  }
505
309
 
506
- // Select all checkbox
507
310
  if (selectAllCheckbox) {
508
- selectAllCheckbox.addEventListener('change', function() {
509
- errorCheckboxes.forEach(checkbox => {
510
- checkbox.checked = this.checked;
511
- });
512
- updateBatchToolbar();
513
- });
311
+ selectAllCheckbox.checked = allChecked;
312
+ selectAllCheckbox.indeterminate = someChecked && !allChecked;
514
313
  }
314
+ }
515
315
 
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);
316
+ function handleSelectAll(checked) {
317
+ getErrorCheckboxes().forEach(function(cb) { cb.checked = checked; });
318
+ updateBatchToolbar();
319
+ }
524
320
 
525
- if (selectAllCheckbox) {
526
- selectAllCheckbox.checked = allChecked;
527
- selectAllCheckbox.indeterminate = someChecked && !allChecked;
528
- }
529
- });
321
+ if (selectAllCheckbox) {
322
+ selectAllCheckbox.addEventListener('change', function() {
323
+ handleSelectAll(selectAllCheckbox.checked);
530
324
  });
325
+ }
531
326
 
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
- }
327
+ if (selectAllBatchCheckbox) {
328
+ selectAllBatchCheckbox.addEventListener('change', function() {
329
+ handleSelectAll(selectAllBatchCheckbox.checked);
330
+ });
331
+ }
556
332
 
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
- });
333
+ document.addEventListener('change', function(e) {
334
+ if (e.target.classList.contains('error-checkbox')) {
335
+ updateBatchToolbar();
566
336
  }
567
337
  });
568
338
 
569
- // Turbo Stream animation for new errors
570
- document.addEventListener('turbo:before-stream-render', (event) => {
571
- const { target, action } = event.detail.newStream;
339
+ if (batchForm) {
340
+ batchForm.addEventListener('submit', function(e) {
341
+ var checkedBoxes = document.querySelectorAll('.error-checkbox:checked');
342
+ if (checkedBoxes.length === 0) { e.preventDefault(); return false; }
343
+ checkedBoxes.forEach(function(cb) {
344
+ var input = document.createElement('input');
345
+ input.type = 'hidden'; input.name = 'error_ids[]'; input.value = cb.value;
346
+ batchForm.appendChild(input);
347
+ });
348
+ });
349
+ }
572
350
 
573
- // Highlight new errors when prepended
351
+ // Turbo Stream animations
352
+ document.addEventListener('turbo:before-stream-render', function(event) {
353
+ var target = event.detail.newStream.target;
354
+ var action = event.detail.newStream.action;
574
355
  if (action === 'prepend' && target === 'error_list') {
575
- setTimeout(() => {
576
- const firstRow = document.querySelector('#error_list tr:first-child');
356
+ setTimeout(function() {
357
+ var firstRow = document.querySelector('#error_list tr:first-child');
577
358
  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);
359
+ firstRow.style.background = 'var(--status-success-bg)';
360
+ setTimeout(function() { firstRow.style.background = ''; }, 3000);
584
361
  }
585
362
  }, 10);
586
363
  }
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
364
  });
599
365
 
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);
366
+ // Scroll position preservation
367
+ var scrollPos = sessionStorage.getItem('scrollPos');
368
+ if (scrollPos) { window.scrollTo(0, parseInt(scrollPos)); sessionStorage.removeItem('scrollPos'); }
369
+
370
+ var filterForm = document.getElementById('filter-form');
371
+ if (filterForm) {
372
+ filterForm.addEventListener('submit', function() {
373
+ sessionStorage.setItem('scrollPos', window.pageYOffset);
374
+ var unresolvedCb = document.getElementById('unresolved_checkbox');
375
+ if (unresolvedCb && !unresolvedCb.checked) {
376
+ var hidden = document.createElement('input');
377
+ hidden.type = 'hidden'; hidden.name = 'unresolved'; hidden.value = '0';
378
+ filterForm.appendChild(hidden);
379
+ unresolvedCb.disabled = true;
380
+ }
605
381
  });
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
- });
382
+ }
383
+
384
+ // Tab title with unresolved count
385
+ var unresolvedCount = <%= @stats[:unresolved] || 0 %>;
386
+ if (unresolvedCount > 0) { document.title = '(' + unresolvedCount + ') ' + document.title; }
387
+
388
+ // Toggle assignee name filter visibility
389
+ var assignedToFilter = document.getElementById('assigned_to_filter');
390
+ var assigneeNameFilter = document.getElementById('assignee_name_filter');
391
+ if (assignedToFilter && assigneeNameFilter) {
392
+ assignedToFilter.addEventListener('change', function() {
393
+ assigneeNameFilter.style.display = this.value === '__assigned__' ? '' : 'none';
394
+ });
395
+ }
396
+ });
699
397
  </script>