rails_error_dashboard 0.1.29 → 0.1.31

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +34 -6
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +22 -0
  4. data/app/helpers/rails_error_dashboard/application_helper.rb +79 -7
  5. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +149 -0
  6. data/app/models/rails_error_dashboard/application.rb +1 -1
  7. data/app/models/rails_error_dashboard/error_log.rb +44 -16
  8. data/app/views/layouts/rails_error_dashboard.html.erb +66 -1237
  9. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +10 -2
  10. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +76 -0
  11. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +18 -82
  12. data/app/views/rails_error_dashboard/errors/index.html.erb +64 -31
  13. data/app/views/rails_error_dashboard/errors/overview.html.erb +181 -3
  14. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +2 -1
  15. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +286 -0
  16. data/app/views/rails_error_dashboard/errors/settings.html.erb +146 -480
  17. data/app/views/rails_error_dashboard/errors/show.html.erb +44 -20
  18. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +188 -0
  19. data/db/migrate/20251224000001_create_rails_error_dashboard_error_logs.rb +5 -0
  20. data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +3 -0
  21. data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +3 -0
  22. data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +4 -0
  23. data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +3 -0
  24. data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +3 -0
  25. data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +3 -0
  26. data/db/migrate/20251225100236_create_error_occurrences.rb +3 -0
  27. data/db/migrate/20251225101920_create_cascade_patterns.rb +3 -0
  28. data/db/migrate/20251225102500_create_error_baselines.rb +3 -0
  29. data/db/migrate/20251226020000_add_workflow_fields_to_error_logs.rb +3 -0
  30. data/db/migrate/20251226020100_create_error_comments.rb +3 -0
  31. data/db/migrate/20251229111223_add_additional_performance_indexes.rb +4 -0
  32. data/db/migrate/20260106094220_create_rails_error_dashboard_applications.rb +3 -0
  33. data/db/migrate/20260106094233_add_application_to_error_logs.rb +3 -0
  34. data/db/migrate/20260106094318_finalize_application_foreign_key.rb +5 -0
  35. data/lib/generators/rails_error_dashboard/install/install_generator.rb +32 -0
  36. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +37 -4
  37. data/lib/rails_error_dashboard/configuration.rb +160 -3
  38. data/lib/rails_error_dashboard/configuration_error.rb +24 -0
  39. data/lib/rails_error_dashboard/engine.rb +17 -0
  40. data/lib/rails_error_dashboard/helpers/user_model_detector.rb +138 -0
  41. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +19 -4
  42. data/lib/rails_error_dashboard/queries/errors_list.rb +20 -8
  43. data/lib/rails_error_dashboard/services/error_normalizer.rb +143 -0
  44. data/lib/rails_error_dashboard/services/git_blame_reader.rb +195 -0
  45. data/lib/rails_error_dashboard/services/github_link_generator.rb +159 -0
  46. data/lib/rails_error_dashboard/services/source_code_reader.rb +214 -0
  47. data/lib/rails_error_dashboard/version.rb +1 -1
  48. data/lib/rails_error_dashboard.rb +6 -0
  49. metadata +16 -12
  50. data/app/assets/stylesheets/rails_error_dashboard/_catppuccin_mocha.scss +0 -107
  51. data/app/assets/stylesheets/rails_error_dashboard/_components.scss +0 -625
  52. data/app/assets/stylesheets/rails_error_dashboard/_layout.scss +0 -257
  53. data/app/assets/stylesheets/rails_error_dashboard/_theme_variables.scss +0 -203
  54. data/app/assets/stylesheets/rails_error_dashboard/application.css +0 -15
  55. data/app/assets/stylesheets/rails_error_dashboard/application.css.map +0 -7
  56. data/app/assets/stylesheets/rails_error_dashboard/application.scss +0 -61
  57. data/app/views/layouts/rails_error_dashboard/application.html.erb +0 -55
@@ -75,9 +75,17 @@
75
75
  </td>
76
76
  <td onclick="window.location='<%= error_path(error) %>';">
77
77
  <% if error.resolved? %>
78
- <i class="bi bi-check-circle-fill text-success" title="Resolved"></i>
78
+ <i class="bi bi-check-circle-fill text-success"
79
+ data-bs-toggle="tooltip"
80
+ data-bs-placement="left"
81
+ data-bs-html="true"
82
+ title="<%= if error.resolution_comment.present?
83
+ "<strong>Resolved</strong><br><em>#{error.resolution_comment.truncate(100).gsub('"', '&quot;').gsub("'", '&#39;')}</em>"
84
+ else
85
+ 'Resolved'
86
+ end %>"></i>
79
87
  <% else %>
80
- <i class="bi bi-exclamation-circle-fill text-danger" title="Unresolved"></i>
88
+ <i class="bi bi-exclamation-circle-fill text-danger" data-bs-toggle="tooltip" title="Unresolved"></i>
81
89
  <% end %>
82
90
  </td>
83
91
  <td onclick="event.stopPropagation();">
@@ -0,0 +1,76 @@
1
+ <%# Displays source code for a backtrace frame with git blame and repository links %>
2
+ <%# Usage: render "source_code", frame: frame, error: @error, index: 0 %>
3
+
4
+ <% if RailsErrorDashboard.configuration.enable_source_code_integration %>
5
+ <% source_data = read_source_code(frame) %>
6
+ <% blame_data = read_git_blame(frame) if RailsErrorDashboard.configuration.enable_git_blame %>
7
+ <% repo_link = generate_repository_link(frame, error) %>
8
+
9
+ <% if source_data && source_data[:lines].present? %>
10
+ <div class="source-code-viewer mt-2 mb-3 border rounded shadow-sm">
11
+ <!-- Header with file info and actions -->
12
+ <div class="source-code-header bg-light border-bottom">
13
+ <!-- Top row: File path and View Source button -->
14
+ <div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
15
+ <div class="d-flex align-items-center gap-2">
16
+ <i class="bi bi-file-code text-primary"></i>
17
+ <span class="font-monospace source-file-path fw-medium" style="font-size: 0.9rem;">
18
+ <%= frame[:short_path] %>:<%= frame[:line_number] %>
19
+ </span>
20
+ </div>
21
+
22
+ <% if repo_link %>
23
+ <%= link_to repo_link, target: "_blank", rel: "noopener",
24
+ class: "btn btn-sm btn-primary",
25
+ title: "View on #{repo_link.include?('github') ? 'GitHub' : repo_link.include?('gitlab') ? 'GitLab' : 'Bitbucket'}" do %>
26
+ <i class="bi bi-box-arrow-up-right"></i> View Source
27
+ <% end %>
28
+ <% end %>
29
+ </div>
30
+
31
+ <!-- Bottom row: Git blame info (if available) -->
32
+ <% if blame_data %>
33
+ <div class="git-blame-info px-3 py-2 bg-white">
34
+ <div class="d-flex align-items-center gap-3 text-muted" style="font-size: 0.85rem;">
35
+ <div class="d-flex align-items-center gap-1">
36
+ <i class="bi bi-person-circle"></i>
37
+ <span><%= blame_data[:author] %></span>
38
+ </div>
39
+ <div class="d-flex align-items-center gap-1">
40
+ <i class="bi bi-clock"></i>
41
+ <span><%= time_ago_in_words(blame_data[:date]) %> ago</span>
42
+ </div>
43
+ <% if blame_data[:commit_message] %>
44
+ <div class="d-flex align-items-center gap-1 flex-grow-1">
45
+ <i class="bi bi-chat-left-text"></i>
46
+ <span class="text-truncate" style="max-width: 400px;" title="<%= blame_data[:commit_message] %>">
47
+ <%= blame_data[:commit_message] %>
48
+ </span>
49
+ </div>
50
+ <% end %>
51
+ </div>
52
+ </div>
53
+ <% end %>
54
+ </div>
55
+
56
+ <!-- Source code lines with syntax highlighting -->
57
+ <div class="source-code-content bg-white">
58
+ <div class="code-block" style="max-height: 500px; overflow-y: auto; overflow-x: auto;">
59
+ <%
60
+ # Find the error line number for highlighting
61
+ error_line_number = source_data[:lines].find { |l| l[:highlight] }&.dig(:number)
62
+ # Get start line number (first line in context)
63
+ start_line = source_data[:lines].first[:number]
64
+ %>
65
+ <pre class="mb-0"><code class="language-<%= source_data[:language] || 'plaintext' %>" data-error-line="<%= error_line_number %>" data-start-line="<%= start_line %>"><%= source_data[:lines].map { |l| l[:content] }.join("\n") %></code></pre>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ <% elsif source_data && source_data[:error] %>
70
+ <!-- Error message if source couldn't be read -->
71
+ <div class="alert alert-warning mt-2 mb-2 py-2">
72
+ <i class="bi bi-exclamation-triangle"></i>
73
+ <small>Could not read source: <%= source_data[:error] %></small>
74
+ </div>
75
+ <% end %>
76
+ <% end %>
@@ -74,96 +74,32 @@
74
74
  <% end %>
75
75
 
76
76
  <% if error.resolved? %>
77
- <span class="badge bg-success">
77
+ <span class="badge bg-success"
78
+ <% if error.resolution_comment.present? %>
79
+ data-bs-toggle="tooltip"
80
+ data-bs-placement="top"
81
+ data-bs-html="true"
82
+ title="<strong>Resolution Notes:</strong><br><%= error.resolution_comment.truncate(150).gsub('"', '&quot;') %>"
83
+ <% end %>>
78
84
  <i class="bi bi-check-circle"></i> Resolved
79
85
  </span>
80
86
  <% end %>
81
87
  </div>
88
+
89
+ <% if error.resolved? && error.resolution_comment.present? %>
90
+ <div class="mt-2 p-2 bg-success bg-opacity-10 border border-success rounded">
91
+ <small class="text-success fw-bold">
92
+ <i class="bi bi-journal-text"></i> Resolution Notes:
93
+ </small>
94
+ <div class="mb-0 small text-muted mt-1">
95
+ <%= auto_link_urls(truncate(error.resolution_comment, length: 200), error: error).html_safe %>
96
+ </div>
97
+ </div>
98
+ <% end %>
82
99
  </div>
83
100
  </div>
84
101
  <% end %>
85
102
  </div>
86
103
  </div>
87
104
  </div>
88
-
89
- <style>
90
- .timeline {
91
- position: relative;
92
- padding-left: 0;
93
- }
94
-
95
- .timeline-item {
96
- position: relative;
97
- padding-left: 40px;
98
- padding-bottom: 30px;
99
- }
100
-
101
- .timeline-item-last {
102
- padding-bottom: 0;
103
- }
104
-
105
- .timeline-item::before {
106
- content: '';
107
- position: absolute;
108
- left: 11px;
109
- top: 30px;
110
- bottom: 0;
111
- width: 2px;
112
- background: var(--bs-border-color);
113
- }
114
-
115
- .timeline-item-last::before {
116
- display: none;
117
- }
118
-
119
- .timeline-marker {
120
- position: absolute;
121
- left: 0;
122
- top: 0;
123
- width: 24px;
124
- height: 24px;
125
- display: flex;
126
- align-items: center;
127
- justify-content: center;
128
- background: white;
129
- border: 2px solid var(--bs-border-color);
130
- border-radius: 50%;
131
- z-index: 1;
132
- }
133
-
134
- .timeline-marker-current {
135
- border-color: var(--bs-danger);
136
- background: var(--bs-danger-bg-subtle);
137
- box-shadow: 0 0 0 4px var(--bs-danger-bg-subtle);
138
- }
139
-
140
- .timeline-marker i {
141
- font-size: 12px;
142
- }
143
-
144
- .timeline-content {
145
- background: var(--bs-body-bg);
146
- padding: 12px;
147
- border-radius: 8px;
148
- border: 1px solid var(--bs-border-color);
149
- }
150
-
151
- .timeline-item:hover .timeline-content {
152
- border-color: var(--bs-primary);
153
- background: var(--bs-primary-bg-subtle);
154
- }
155
-
156
- /* Dark mode support */
157
- body.dark-mode .timeline-marker,
158
- html[data-theme="dark"] body .timeline-marker {
159
- background: var(--card-bg);
160
- border-color: var(--border-color);
161
- }
162
-
163
- body.dark-mode .timeline-marker-current,
164
- html[data-theme="dark"] body .timeline-marker-current {
165
- border-color: var(--bs-danger);
166
- background: rgba(220, 53, 69, 0.2);
167
- }
168
- </style>
169
105
  <% end %>
@@ -148,6 +148,10 @@
148
148
  active_filters << { label: "Unassigned", param: :assigned_to }
149
149
  elsif params[:assigned_to] == '__assigned__'
150
150
  active_filters << { label: "Assigned", param: :assigned_to }
151
+ # Show assignee name filter if present
152
+ if params[:assignee_name].present?
153
+ active_filters << { label: "Assignee: #{params[:assignee_name]}", param: :assignee_name }
154
+ end
151
155
  end
152
156
  %>
153
157
 
@@ -185,8 +189,6 @@
185
189
  class: "btn btn-sm #{params[:assigned_to].blank? ? 'btn-primary' : 'btn-outline-primary'}" %>
186
190
  <%= link_to "Unassigned", errors_path(assigned_to: '__unassigned__'),
187
191
  class: "btn btn-sm #{params[:assigned_to] == '__unassigned__' ? 'btn-primary' : 'btn-outline-primary'}" %>
188
- <%= link_to "My Errors", errors_path(assigned_to: current_user_name),
189
- class: "btn btn-sm #{params[:assigned_to] == current_user_name ? 'btn-primary' : 'btn-outline-primary'}" %>
190
192
  </div>
191
193
 
192
194
  <%= form_with url: errors_path, method: :get, class: "row g-3", data: { turbo: false } do %>
@@ -268,44 +270,54 @@
268
270
  ['All Assignments', ''],
269
271
  ['Unassigned', '__unassigned__'],
270
272
  ['Assigned', '__assigned__']
271
- ], params[:assigned_to]), class: "form-select" %>
273
+ ], params[:assigned_to]), class: "form-select", id: "assigned_to_filter" %>
272
274
  </div>
273
275
 
274
- <div class="col-md-2">
275
- <%= select_tag :priority_level, options_for_select([
276
- ['All Priorities', ''],
277
- ['🔴 Critical (P3)', 3],
278
- ['🟠 High (P2)', 2],
279
- ['🟡 Medium (P1)', 1],
280
- ['⚪ Low (P0)', 0]
281
- ], params[:priority_level]), class: "form-select" %>
276
+ <!-- Assignee Name Filter - Only shown when "Assigned" is selected -->
277
+ <div class="col-md-2" id="assignee_name_filter" style="<%= params[:assigned_to] == '__assigned__' ? '' : 'display: none;' %>">
278
+ <%= select_tag :assignee_name, options_for_select(
279
+ [['All Assignees', '']] + @assignees.map { |name| [name, name] },
280
+ params[:assignee_name]
281
+ ), class: "form-select", placeholder: "Filter by assignee..." %>
282
282
  </div>
283
283
 
284
- <div class="col-auto">
285
- <div class="form-check mt-2">
286
- <%
287
- # Determine checkbox state based on params
288
- # If unresolved param is missing or nil, default to checked (true)
289
- # If unresolved param is explicitly "false" or "0", uncheck it
290
- is_checked = if params[:unresolved].nil?
291
- true # Default to checked when first loading
292
- else
293
- params[:unresolved] != "false" && params[:unresolved] != "0"
294
- end
295
- %>
296
- <%= check_box_tag :unresolved, "1", is_checked, class: "form-check-input", id: "unresolved_checkbox" %>
297
- <%= label_tag :unresolved, "Unresolved only", class: "form-check-label" %>
298
- </div>
284
+ <div class="col-md-2">
285
+ <%= select_tag :priority_level, options_for_select(
286
+ [['All Priorities', '']] + RailsErrorDashboard::ErrorLog.priority_options(include_emoji: true),
287
+ params[:priority_level]
288
+ ), class: "form-select" %>
299
289
  </div>
300
290
 
301
- <div class="col-auto">
302
- <div class="form-check mt-2">
303
- <%= check_box_tag :hide_snoozed, "1", params[:hide_snoozed] == "1", class: "form-check-input" %>
304
- <%= label_tag :hide_snoozed, "Hide snoozed", class: "form-check-label" %>
291
+ <!-- Checkboxes on their own row -->
292
+ <div class="col-12 mt-2">
293
+ <div class="row g-3">
294
+ <div class="col-auto">
295
+ <div class="form-check">
296
+ <%
297
+ # Determine checkbox state based on params
298
+ # If unresolved param is missing or nil, default to checked (true)
299
+ # If unresolved param is explicitly "false" or "0", uncheck it
300
+ is_checked = if params[:unresolved].nil?
301
+ true # Default to checked when first loading
302
+ else
303
+ params[:unresolved] != "false" && params[:unresolved] != "0"
304
+ end
305
+ %>
306
+ <%= check_box_tag :unresolved, "1", is_checked, class: "form-check-input", id: "unresolved_checkbox" %>
307
+ <%= label_tag :unresolved, "Unresolved only", class: "form-check-label" %>
308
+ </div>
309
+ </div>
310
+
311
+ <div class="col-auto">
312
+ <div class="form-check">
313
+ <%= check_box_tag :hide_snoozed, "1", params[:hide_snoozed] == "1", class: "form-check-input" %>
314
+ <%= label_tag :hide_snoozed, "Hide snoozed", class: "form-check-label" %>
315
+ </div>
316
+ </div>
305
317
  </div>
306
318
  </div>
307
319
 
308
- <div class="col-12">
320
+ <div class="col-12 mt-3">
309
321
  <%= submit_tag "Apply Filters", class: "btn btn-primary" %>
310
322
  <%= link_to "Clear", errors_path, class: "btn btn-outline-secondary" %>
311
323
  </div>
@@ -600,4 +612,25 @@
600
612
  document.title = `(${unresolvedCount}) ${document.title}`;
601
613
  }
602
614
  });
615
+
616
+ // Toggle assignee name filter visibility based on assigned_to selection
617
+ document.addEventListener('DOMContentLoaded', function() {
618
+ const assignedToFilter = document.getElementById('assigned_to_filter');
619
+ const assigneeNameFilter = document.getElementById('assignee_name_filter');
620
+
621
+ if (assignedToFilter && assigneeNameFilter) {
622
+ assignedToFilter.addEventListener('change', function() {
623
+ if (this.value === '__assigned__') {
624
+ assigneeNameFilter.style.display = 'block';
625
+ } else {
626
+ assigneeNameFilter.style.display = 'none';
627
+ // Reset assignee name filter when hiding
628
+ const assigneeNameSelect = assigneeNameFilter.querySelector('select');
629
+ if (assigneeNameSelect) {
630
+ assigneeNameSelect.value = '';
631
+ }
632
+ }
633
+ });
634
+ }
635
+ });
603
636
  </script>
@@ -45,8 +45,23 @@
45
45
  </div>
46
46
  </div>
47
47
 
48
+ <!-- Unresolved Errors Card -->
49
+ <div class="col-12 col-md-6 col-lg-4">
50
+ <div class="card stat-card h-100">
51
+ <div class="card-body text-center">
52
+ <div class="stat-label mb-2">UNRESOLVED ERRORS</div>
53
+ <div class="stat-value text-danger">
54
+ <%= @stats[:unresolved] %>
55
+ </div>
56
+ <small class="text-muted">
57
+ Pending resolution
58
+ </small>
59
+ </div>
60
+ </div>
61
+ </div>
62
+
48
63
  <!-- Trend Card -->
49
- <div class="col-12 col-lg-4">
64
+ <div class="col-12 col-md-6 col-lg-4">
50
65
  <div class="card stat-card h-100">
51
66
  <div class="card-body text-center">
52
67
  <div class="stat-label mb-2">ERROR TREND</div>
@@ -60,15 +75,58 @@
60
75
  </div>
61
76
  </div>
62
77
  </div>
78
+
79
+ <!-- Resolution Rate Card -->
80
+ <div class="col-12 col-md-6 col-lg-4">
81
+ <div class="card stat-card h-100">
82
+ <div class="card-body text-center">
83
+ <div class="stat-label mb-2">RESOLUTION RATE</div>
84
+ <%
85
+ total_errors = @stats[:resolved] + @stats[:unresolved]
86
+ resolution_rate = total_errors > 0 ? ((@stats[:resolved].to_f / total_errors) * 100).round(1) : 0.0
87
+ %>
88
+ <div class="stat-value <%= resolution_rate >= 80 ? 'text-success' : resolution_rate >= 50 ? 'text-warning' : 'text-danger' %>">
89
+ <%= resolution_rate %>%
90
+ </div>
91
+ <small class="text-muted">
92
+ <%= @stats[:resolved] %> of <%= total_errors %> resolved
93
+ </small>
94
+ </div>
95
+ </div>
96
+ </div>
97
+
98
+ <!-- Average Resolution Time Card -->
99
+ <div class="col-12 col-md-6 col-lg-4">
100
+ <div class="card stat-card h-100">
101
+ <div class="card-body text-center">
102
+ <div class="stat-label mb-2">AVG RESOLUTION TIME</div>
103
+ <% if @stats[:average_resolution_time].present? %>
104
+ <div class="stat-value text-warning">
105
+ <%= @stats[:average_resolution_time] %>h
106
+ </div>
107
+ <small class="text-muted">
108
+ Mean time to resolution
109
+ </small>
110
+ <% else %>
111
+ <div class="stat-value text-muted">
112
+ --
113
+ </div>
114
+ <small class="text-muted">
115
+ No resolved errors yet
116
+ </small>
117
+ <% end %>
118
+ </div>
119
+ </div>
120
+ </div>
63
121
  </div>
64
122
 
65
- <!-- Top 5 Errors by Impact (Mobile: stack, Desktop: 2 columns) -->
123
+ <!-- Top 6 Errors by Impact (Mobile: stack, Desktop: 2 columns) -->
66
124
  <% if @stats[:top_errors_by_impact].any? %>
67
125
  <div class="card mb-4">
68
126
  <div class="card-header bg-white d-flex justify-content-between align-items-center">
69
127
  <h5 class="mb-0">
70
128
  <i class="bi bi-exclamation-triangle me-2"></i>
71
- Top 5 Errors by Impact
129
+ Top 6 Errors by Impact
72
130
  </h5>
73
131
  <%= link_to "View All Errors →", errors_path, class: "btn btn-sm btn-outline-primary" %>
74
132
  </div>
@@ -174,6 +232,126 @@
174
232
  </div>
175
233
  <% end %>
176
234
 
235
+ <!-- Correlation Summary (Last 7 days) -->
236
+ <% if RailsErrorDashboard.configuration.enable_error_correlation && (@problematic_releases.any? || @time_correlated_errors.any? || @multi_error_users.any?) %>
237
+ <%
238
+ # Calculate column width based on number of cards present
239
+ cards_present = [@problematic_releases.any?, @time_correlated_errors.any?, @multi_error_users.any?].count(true)
240
+ col_class = case cards_present
241
+ when 1 then "col-12"
242
+ when 2 then "col-12 col-lg-6"
243
+ else "col-12 col-lg-4"
244
+ end
245
+ %>
246
+ <div class="card mb-4">
247
+ <div class="card-header bg-white d-flex justify-content-between align-items-center">
248
+ <h5 class="mb-0">
249
+ <i class="bi bi-diagram-3 me-2"></i>
250
+ Error Correlation Insights
251
+ </h5>
252
+ <%= link_to "Full Analysis →", correlation_errors_path, class: "btn btn-sm btn-outline-primary" %>
253
+ </div>
254
+ <div class="card-body">
255
+ <div class="row g-3">
256
+ <!-- Problematic Releases -->
257
+ <% if @problematic_releases.any? %>
258
+ <div class="<%= col_class %>">
259
+ <div class="card h-100 border-warning">
260
+ <div class="card-body">
261
+ <h6 class="mb-3">
262
+ <i class="bi bi-tag me-2"></i>
263
+ Problematic Releases
264
+ </h6>
265
+ <div class="list-group list-group-flush">
266
+ <% @problematic_releases.each do |release| %>
267
+ <div class="list-group-item px-0 py-2">
268
+ <div class="d-flex justify-content-between align-items-start">
269
+ <div class="flex-grow-1">
270
+ <code class="small"><%= release[:version] || release[:git_sha]&.truncate(8, omission: '') %></code>
271
+ <div class="small text-muted">
272
+ <%= release[:error_count] %> error<%= release[:error_count] != 1 ? 's' : '' %> •
273
+ <%= release[:unique_types] %> type<%= release[:unique_types] != 1 ? 's' : '' %>
274
+ </div>
275
+ </div>
276
+ <span class="badge bg-danger"><%= release[:severity_score] %></span>
277
+ </div>
278
+ </div>
279
+ <% end %>
280
+ </div>
281
+ </div>
282
+ </div>
283
+ </div>
284
+ <% end %>
285
+
286
+ <!-- Time-Correlated Errors -->
287
+ <% if @time_correlated_errors.any? %>
288
+ <div class="<%= col_class %>">
289
+ <div class="card h-100 border-info">
290
+ <div class="card-body">
291
+ <h6 class="mb-3">
292
+ <i class="bi bi-clock-history me-2"></i>
293
+ Time-Correlated Errors
294
+ </h6>
295
+ <div class="list-group list-group-flush">
296
+ <% @time_correlated_errors.each do |group| %>
297
+ <div class="list-group-item px-0 py-2">
298
+ <div class="small">
299
+ <strong><%= group[:error_types].size %> error types</strong>
300
+ <div class="text-muted">
301
+ occurred together <%= group[:occurrences] %> time<%= group[:occurrences] != 1 ? 's' : '' %>
302
+ </div>
303
+ <div class="mt-1">
304
+ <% group[:error_types].first(2).each do |type| %>
305
+ <code class="small d-block text-truncate"><%= type %></code>
306
+ <% end %>
307
+ <% if group[:error_types].size > 2 %>
308
+ <small class="text-muted">+<%= group[:error_types].size - 2 %> more</small>
309
+ <% end %>
310
+ </div>
311
+ </div>
312
+ </div>
313
+ <% end %>
314
+ </div>
315
+ </div>
316
+ </div>
317
+ </div>
318
+ <% end %>
319
+
320
+ <!-- Multi-Error Users -->
321
+ <% if @multi_error_users.any? %>
322
+ <div class="<%= col_class %>">
323
+ <div class="card h-100 border-danger">
324
+ <div class="card-body">
325
+ <h6 class="mb-3">
326
+ <i class="bi bi-people-fill me-2"></i>
327
+ Users with Multiple Errors
328
+ </h6>
329
+ <div class="list-group list-group-flush">
330
+ <% @multi_error_users.each do |user| %>
331
+ <div class="list-group-item px-0 py-2">
332
+ <div class="d-flex justify-content-between align-items-start">
333
+ <div class="flex-grow-1">
334
+ <div class="small">
335
+ User ID: <strong><%= user[:user_id] %></strong>
336
+ </div>
337
+ <div class="small text-muted">
338
+ <%= user[:error_types].size %> different error type<%= user[:error_types].size != 1 ? 's' : '' %>
339
+ </div>
340
+ </div>
341
+ <span class="badge bg-danger"><%= user[:total_errors] %></span>
342
+ </div>
343
+ </div>
344
+ <% end %>
345
+ </div>
346
+ </div>
347
+ </div>
348
+ </div>
349
+ <% end %>
350
+ </div>
351
+ </div>
352
+ </div>
353
+ <% end %>
354
+
177
355
  <!-- Critical Alerts (Last hour) -->
178
356
  <% if @critical_alerts.any? %>
179
357
  <div class="alert alert-danger border-danger mb-4" role="alert">
@@ -378,7 +378,7 @@
378
378
  const times = <%= raw @resolution_times.values.map { |v| v || 0 }.to_json %>;
379
379
 
380
380
  new Chart(resolutionTimeCtx, {
381
- type: 'horizontalBar',
381
+ type: 'bar',
382
382
  data: {
383
383
  labels: platforms,
384
384
  datasets: [{
@@ -390,6 +390,7 @@
390
390
  }]
391
391
  },
392
392
  options: {
393
+ indexAxis: 'y', // Horizontal bar chart
393
394
  responsive: true,
394
395
  maintainAspectRatio: false,
395
396
  plugins: {