rails_error_dashboard 0.2.3 → 0.3.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/README.md +82 -14
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +88 -1
  4. data/app/helpers/rails_error_dashboard/application_helper.rb +25 -0
  5. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +18 -6
  6. data/app/views/layouts/rails_error_dashboard.html.erb +145 -1
  7. data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +236 -0
  8. data/app/views/rails_error_dashboard/errors/_co_occurring_errors.html.erb +70 -0
  9. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +107 -0
  10. data/app/views/rails_error_dashboard/errors/_error_cascades.html.erb +138 -0
  11. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +190 -0
  12. data/app/views/rails_error_dashboard/errors/_modals.html.erb +139 -0
  13. data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +1 -1
  14. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +97 -0
  15. data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +156 -0
  16. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +352 -0
  17. data/app/views/rails_error_dashboard/errors/_similar_errors.html.erb +75 -0
  18. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +1 -1
  19. data/app/views/rails_error_dashboard/errors/cache_health_summary.html.erb +143 -0
  20. data/app/views/rails_error_dashboard/errors/deprecations.html.erb +129 -0
  21. data/app/views/rails_error_dashboard/errors/n_plus_one_summary.html.erb +134 -0
  22. data/app/views/rails_error_dashboard/errors/settings.html.erb +17 -0
  23. data/app/views/rails_error_dashboard/errors/show.html.erb +20 -1132
  24. data/config/routes.rb +3 -0
  25. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +11 -5
  26. data/db/migrate/20260303000001_add_breadcrumbs_to_error_logs.rb +9 -0
  27. data/db/migrate/20260304000001_add_system_health_to_error_logs.rb +12 -0
  28. data/lib/generators/rails_error_dashboard/install/install_generator.rb +82 -6
  29. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +67 -5
  30. data/lib/rails_error_dashboard/commands/log_error.rb +33 -0
  31. data/lib/rails_error_dashboard/configuration.rb +45 -3
  32. data/lib/rails_error_dashboard/engine.rb +14 -0
  33. data/lib/rails_error_dashboard/middleware/error_catcher.rb +8 -0
  34. data/lib/rails_error_dashboard/queries/cache_health_summary.rb +72 -0
  35. data/lib/rails_error_dashboard/queries/deprecation_warnings.rb +80 -0
  36. data/lib/rails_error_dashboard/queries/n_plus_one_summary.rb +83 -0
  37. data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +182 -0
  38. data/lib/rails_error_dashboard/services/cache_analyzer.rb +76 -0
  39. data/lib/rails_error_dashboard/services/curl_generator.rb +80 -0
  40. data/lib/rails_error_dashboard/services/n_plus_one_detector.rb +74 -0
  41. data/lib/rails_error_dashboard/services/system_health_snapshot.rb +145 -0
  42. data/lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb +210 -0
  43. data/lib/rails_error_dashboard/version.rb +1 -1
  44. data/lib/rails_error_dashboard.rb +20 -0
  45. data/lib/tasks/error_dashboard.rake +68 -2
  46. metadata +37 -11
@@ -0,0 +1,190 @@
1
+ <% cache [error, 'error_details_v1'] do %>
2
+ <div class="card mb-4" id="section-error-info">
3
+ <div class="card-header bg-danger text-white">
4
+ <div class="d-flex justify-content-between align-items-center">
5
+ <h5 class="mb-0">
6
+ <i class="bi bi-bug-fill"></i> <%= error.error_type %>
7
+ <% if error.recent? %>
8
+ <span class="badge bg-success ms-2" data-bs-toggle="tooltip" title="Error occurred within the last hour">NEW</span>
9
+ <% end %>
10
+ <% if error.reopened? %>
11
+ <span class="badge bg-warning text-dark ms-2" data-bs-toggle="tooltip" title="Previously resolved, recurred <%= error.reopened_at&.strftime('%b %d, %Y %H:%M') %>">
12
+ <i class="bi bi-arrow-counterclockwise"></i> Reopened
13
+ </span>
14
+ <% end %>
15
+ </h5>
16
+ <button class="btn btn-sm btn-outline-light" onclick="copyToClipboard('<%= j error.error_type %>', this)" title="Copy error type">
17
+ <i class="bi bi-clipboard"></i>
18
+ </button>
19
+ </div>
20
+ </div>
21
+ <div class="card-body">
22
+ <div class="d-flex justify-content-between align-items-center mb-3">
23
+ <h6 class="text-muted mb-0">Error Message:</h6>
24
+ <button class="btn btn-sm btn-outline-secondary" onclick="copyToClipboard('<%= j error.message %>', this)" title="Copy error message">
25
+ <i class="bi bi-clipboard"></i> Copy
26
+ </button>
27
+ </div>
28
+ <div class="alert alert-danger">
29
+ <%= error.message %>
30
+ </div>
31
+
32
+ <% if error.respond_to?(:exception_cause) && error.exception_cause.present? %>
33
+ <% begin %>
34
+ <% cause_chain = JSON.parse(error.exception_cause) %>
35
+ <% if cause_chain.is_a?(Array) && cause_chain.any? %>
36
+ <div class="mt-4 mb-3">
37
+ <h6 class="text-muted mb-2">
38
+ <i class="bi bi-link-45deg"></i> Exception Cause Chain
39
+ <span class="badge bg-secondary ms-1"><%= cause_chain.length %></span>
40
+ </h6>
41
+ <div class="cause-chain">
42
+ <% cause_chain.each_with_index do |cause, index| %>
43
+ <% cause_bt = cause["backtrace"] %>
44
+ <% cause_bt = cause_bt.is_a?(String) ? cause_bt.split("\n") : Array(cause_bt) %>
45
+ <div class="card mb-2 border-warning">
46
+ <div class="card-header bg-warning bg-opacity-10 py-2">
47
+ <div class="d-flex justify-content-between align-items-center">
48
+ <div>
49
+ <small class="text-muted">Caused by<%= " (##{index + 1})" if cause_chain.length > 1 %>:</small>
50
+ <strong class="ms-1"><code><%= cause["class_name"] %></code></strong>
51
+ </div>
52
+ <% if cause_bt.any? %>
53
+ <button class="btn btn-sm btn-outline-secondary py-0" type="button"
54
+ data-bs-toggle="collapse"
55
+ data-bs-target="#cause-backtrace-<%= index %>"
56
+ aria-expanded="false">
57
+ <small><i class="bi bi-code-slash"></i> Backtrace</small>
58
+ </button>
59
+ <% end %>
60
+ </div>
61
+ </div>
62
+ <div class="card-body py-2">
63
+ <small><%= cause["message"] %></small>
64
+ </div>
65
+ <% if cause_bt.any? %>
66
+ <div class="collapse" id="cause-backtrace-<%= index %>">
67
+ <div class="card-body p-0 border-top">
68
+ <div class="code-block p-2" style="max-height: 200px; overflow-y: auto; overflow-x: auto; font-size: 0.8rem;">
69
+ <pre class="mb-0"><code><% cause_bt.each do |line| %><%= line %>
70
+ <% end %></code></pre>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ <% end %>
75
+ </div>
76
+ <% end %>
77
+ </div>
78
+ </div>
79
+ <% end %>
80
+ <% rescue JSON::ParserError %>
81
+ <%# Silently skip invalid JSON — safety first %>
82
+ <% end %>
83
+ <% end %>
84
+
85
+ <div class="d-flex justify-content-between align-items-center mb-2 mt-4">
86
+ <h6 class="text-muted mb-0">
87
+ Backtrace:
88
+ <% if error.backtrace.present? %>
89
+ <% frames = parse_backtrace(error.backtrace) %>
90
+ <% app_frames = filter_app_code(frames) %>
91
+ <% framework_frames = filter_framework_code(frames) %>
92
+ <small class="text-muted">
93
+ (<%= app_frames.count %> your code, <%= framework_frames.count %> framework/gems)
94
+ </small>
95
+ <% end %>
96
+ </h6>
97
+ <% if error.backtrace.present? %>
98
+ <button class="btn btn-sm btn-outline-secondary" onclick="copyToClipboard(`<%= j error.backtrace %>`, this)" title="Copy full backtrace">
99
+ <i class="bi bi-clipboard"></i> Copy Full Backtrace
100
+ </button>
101
+ <% end %>
102
+ </div>
103
+ <% if error.backtrace.present? %>
104
+ <% frames = parse_backtrace(error.backtrace) %>
105
+ <% app_frames = filter_app_code(frames) %>
106
+ <% framework_frames = filter_framework_code(frames) %>
107
+
108
+ <% if app_frames.any? %>
109
+ <!-- Your Code Section (Expanded by default) -->
110
+ <div class="card mb-3">
111
+ <div class="card-header bg-success bg-opacity-10 border-success">
112
+ <div class="d-flex justify-content-between align-items-center">
113
+ <strong class="text-success">
114
+ <i class="bi bi-code-square"></i> Your Code
115
+ </strong>
116
+ <span class="badge bg-success"><%= app_frames.count %> frames</span>
117
+ </div>
118
+ </div>
119
+ <div class="card-body p-0">
120
+ <div class="code-block p-3" style="max-height: none; overflow-y: visible; overflow-x: auto;">
121
+ <% app_frames.each_with_index do |frame, index| %>
122
+ <div class="backtrace-frame-wrapper mb-2">
123
+ <!-- Frame header (always visible) -->
124
+ <div class="<%= frame_bg_class(frame[:category]) %> d-flex justify-content-between align-items-center px-2 py-1">
125
+ <div>
126
+ <span class="backtrace-frame-number"><%= index + 1 %></span>
127
+ <span class="<%= frame_color_class(frame[:category]) %>">
128
+ <%= frame_icon(frame[:category]) %> <%= frame[:short_path] %>:<%= frame[:line_number] %>
129
+ </span> in <span class="backtrace-method-name">`<%= frame[:method_name] %>'</span>
130
+ </div>
131
+ <% if RailsErrorDashboard.configuration.enable_source_code_integration && frame[:category] == :app %>
132
+ <button class="btn btn-sm btn-outline-secondary" type="button"
133
+ data-bs-toggle="collapse"
134
+ data-bs-target="#source-code-<%= index %>"
135
+ aria-expanded="false"
136
+ aria-controls="source-code-<%= index %>">
137
+ <i class="bi bi-code-slash"></i> <span class="d-none d-md-inline">View Source</span>
138
+ </button>
139
+ <% end %>
140
+ </div>
141
+
142
+ <!-- Collapsible source code (only for app frames) -->
143
+ <% if RailsErrorDashboard.configuration.enable_source_code_integration && frame[:category] == :app %>
144
+ <div class="collapse" id="source-code-<%= index %>">
145
+ <%= render "source_code", frame: frame, error: error, index: index %>
146
+ </div>
147
+ <% end %>
148
+ </div>
149
+ <% end %>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ <% end %>
154
+
155
+ <% if framework_frames.any? %>
156
+ <!-- Framework/Gem Code Section (Collapsed by default) -->
157
+ <div class="accordion" id="frameworkBacktraceAccordion">
158
+ <div class="accordion-item">
159
+ <h2 class="accordion-header">
160
+ <button class="accordion-button collapsed bg-light" type="button" data-bs-toggle="collapse" data-bs-target="#frameworkBacktrace" aria-expanded="false" aria-controls="frameworkBacktrace">
161
+ <i class="bi bi-gear me-2 text-warning"></i>
162
+ <strong>Framework & Gem Code</strong>
163
+ <span class="badge bg-secondary ms-2"><%= framework_frames.count %> frames</span>
164
+ <small class="text-muted ms-2">(click to expand)</small>
165
+ </button>
166
+ </h2>
167
+ <div id="frameworkBacktrace" class="accordion-collapse collapse" data-bs-parent="#frameworkBacktraceAccordion">
168
+ <div class="accordion-body p-0">
169
+ <div class="code-block p-3" style="max-height: 400px; overflow-y: auto; overflow-x: auto;">
170
+ <pre class="mb-0"><code><% framework_frames.each_with_index do |frame, index| %><span class="<%= frame_bg_class(frame[:category]) %> d-block px-2 py-1"><span class="backtrace-frame-number"><%= index + 1 %></span><span class="<%= frame_color_class(frame[:category]) %>"><%= frame_icon(frame[:category]) %> <%= frame[:short_path] %>:<%= frame[:line_number] %></span> in <span class="backtrace-method-name">`<%= frame[:method_name] %>'</span></span>
171
+ <% end %></code></pre>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ <% end %>
178
+
179
+ <% if app_frames.empty? && framework_frames.empty? %>
180
+ <!-- Fallback: Show raw backtrace if parsing failed -->
181
+ <div class="code-block p-3 rounded" style="max-height: 400px; overflow-y: auto; overflow-x: auto;">
182
+ <pre class="mb-0"><code><%= error.backtrace %></code></pre>
183
+ </div>
184
+ <% end %>
185
+ <% else %>
186
+ <p class="text-muted">No backtrace available</p>
187
+ <% end %>
188
+ </div>
189
+ </div>
190
+ <% end %>
@@ -0,0 +1,139 @@
1
+ <!-- Resolve Error Modal -->
2
+ <div class="modal fade" id="resolveModal" tabindex="-1" aria-labelledby="resolveModalLabel" aria-hidden="true">
3
+ <div class="modal-dialog">
4
+ <div class="modal-content">
5
+ <%= form_with url: resolve_error_path(error), method: :post, class: "modal-content" do |f| %>
6
+ <div class="modal-header">
7
+ <h5 class="modal-title" id="resolveModalLabel">
8
+ <i class="bi bi-check-circle"></i> Mark Error as Resolved
9
+ </h5>
10
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
11
+ </div>
12
+ <div class="modal-body">
13
+ <div class="mb-3">
14
+ <label for="resolved_by_name" class="form-label">Your Name <span class="text-danger">*</span></label>
15
+ <%= text_field_tag :resolved_by_name, nil, class: "form-control", placeholder: "e.g., John Doe", required: true %>
16
+ <small class="text-muted">Who is resolving this error?</small>
17
+ </div>
18
+
19
+ <div class="mb-3">
20
+ <label for="resolution_reference" class="form-label">Reference (Optional)</label>
21
+ <%= text_field_tag :resolution_reference, nil, class: "form-control", placeholder: "e.g., JIRA-123, PR #456, GitHub Issue #789" %>
22
+ <small class="text-muted">Link to ticket, PR, or issue</small>
23
+ </div>
24
+
25
+ <div class="mb-3">
26
+ <label for="resolution_comment" class="form-label">Resolution Notes (Optional)</label>
27
+ <%= text_area_tag :resolution_comment, nil, class: "form-control", rows: 4, placeholder: "Describe what was done to fix this error..." %>
28
+ <small class="text-muted">What was done to resolve this error?</small>
29
+ </div>
30
+ </div>
31
+ <div class="modal-footer">
32
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
33
+ <%= submit_tag "Mark as Resolved", class: "btn btn-success", data: { action: "click->loading#click" } %>
34
+ </div>
35
+ <% end %>
36
+ </div>
37
+ </div>
38
+ </div>
39
+
40
+ <!-- Phase 3: Assignment Modal -->
41
+ <div class="modal fade" id="assignModal" tabindex="-1" aria-labelledby="assignModalLabel" aria-hidden="true">
42
+ <div class="modal-dialog">
43
+ <div class="modal-content">
44
+ <%= form_with url: assign_error_path(error), method: :post do |f| %>
45
+ <div class="modal-header">
46
+ <h5 class="modal-title" id="assignModalLabel">
47
+ <i class="bi bi-person-plus"></i> Assign Error
48
+ </h5>
49
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
50
+ </div>
51
+ <div class="modal-body">
52
+ <div class="mb-3">
53
+ <label for="assigned_to" class="form-label">Assign To <span class="text-danger">*</span></label>
54
+ <%= text_field_tag :assigned_to, nil, class: "form-control", placeholder: "e.g., John Doe", required: true, autofocus: true %>
55
+ <small class="text-muted">Who should investigate this error?</small>
56
+ </div>
57
+ </div>
58
+ <div class="modal-footer">
59
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
60
+ <%= submit_tag "Assign", class: "btn btn-primary", data: { action: "click->loading#click" } %>
61
+ </div>
62
+ <% end %>
63
+ </div>
64
+ </div>
65
+ </div>
66
+
67
+ <!-- Phase 3: Priority Modal -->
68
+ <div class="modal fade" id="priorityModal" tabindex="-1" aria-labelledby="priorityModalLabel" aria-hidden="true">
69
+ <div class="modal-dialog">
70
+ <div class="modal-content">
71
+ <%= form_with url: update_priority_error_path(error), method: :post do |f| %>
72
+ <div class="modal-header">
73
+ <h5 class="modal-title" id="priorityModalLabel">
74
+ <i class="bi bi-flag"></i> Update Priority
75
+ </h5>
76
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
77
+ </div>
78
+ <div class="modal-body">
79
+ <div class="mb-3">
80
+ <label for="priority_level" class="form-label">Priority Level <span class="text-danger">*</span></label>
81
+ <%= select_tag :priority_level,
82
+ options_for_select(
83
+ RailsErrorDashboard::ErrorLog.priority_options,
84
+ error.priority_level
85
+ ),
86
+ class: "form-select", required: true %>
87
+ <small class="text-muted">Current: <%= error.priority_label %></small>
88
+ </div>
89
+ </div>
90
+ <div class="modal-footer">
91
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
92
+ <%= submit_tag "Update Priority", class: "btn btn-warning", data: { action: "click->loading#click" } %>
93
+ </div>
94
+ <% end %>
95
+ </div>
96
+ </div>
97
+ </div>
98
+
99
+ <!-- Phase 3: Snooze Modal -->
100
+ <div class="modal fade" id="snoozeModal" tabindex="-1" aria-labelledby="snoozeModalLabel" aria-hidden="true">
101
+ <div class="modal-dialog">
102
+ <div class="modal-content">
103
+ <%= form_with url: snooze_error_path(error), method: :post do |f| %>
104
+ <div class="modal-header">
105
+ <h5 class="modal-title" id="snoozeModalLabel">
106
+ <i class="bi bi-alarm"></i> Snooze Error
107
+ </h5>
108
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
109
+ </div>
110
+ <div class="modal-body">
111
+ <div class="mb-3">
112
+ <label for="hours" class="form-label">Snooze Duration <span class="text-danger">*</span></label>
113
+ <%= select_tag :hours,
114
+ options_for_select([
115
+ ['1 hour', 1],
116
+ ['4 hours', 4],
117
+ ['8 hours', 8],
118
+ ['24 hours (1 day)', 24],
119
+ ['48 hours (2 days)', 48],
120
+ ['168 hours (1 week)', 168]
121
+ ], 24),
122
+ class: "form-select", required: true %>
123
+ <small class="text-muted">Hide this error from active views</small>
124
+ </div>
125
+
126
+ <div class="mb-3">
127
+ <label for="reason" class="form-label">Reason (Optional)</label>
128
+ <%= text_area_tag :reason, nil, class: "form-control", rows: 3, placeholder: "Why are you snoozing this error?" %>
129
+ <small class="text-muted">Reason will be added as a comment</small>
130
+ </div>
131
+ </div>
132
+ <div class="modal-footer">
133
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
134
+ <%= submit_tag "Snooze", class: "btn btn-warning", data: { action: "click->loading#click" } %>
135
+ </div>
136
+ <% end %>
137
+ </div>
138
+ </div>
139
+ </div>
@@ -6,7 +6,7 @@
6
6
  %>
7
7
 
8
8
  <% if pattern_data.present? && pattern_data[:total_errors] > 0 %>
9
- <div class="card mb-4">
9
+ <div class="card mb-4" id="section-occurrence-patterns">
10
10
  <div class="card-header bg-white">
11
11
  <h5 class="mb-0">
12
12
  <i class="bi bi-graph-up"></i> Occurrence Patterns
@@ -0,0 +1,97 @@
1
+ <!-- Request Context -->
2
+ <% cache [error, 'request_context_v3'] do %>
3
+ <div class="card mb-4" id="section-request-context">
4
+ <div class="card-header bg-white">
5
+ <h5 class="mb-0"><i class="bi bi-globe"></i> Request Context</h5>
6
+ </div>
7
+ <div class="card-body">
8
+ <table class="table table-sm">
9
+ <tr>
10
+ <th width="200">Request URL:</th>
11
+ <td>
12
+ <% if error.respond_to?(:http_method) && error.http_method.present? %>
13
+ <span class="badge bg-primary me-1"><%= error.http_method %></span>
14
+ <% end %>
15
+ <code><%= error.request_url || 'N/A' %></code>
16
+ </td>
17
+ </tr>
18
+ <% if error.respond_to?(:hostname) && error.hostname.present? %>
19
+ <tr>
20
+ <th>Hostname:</th>
21
+ <td><code><%= error.hostname %></code></td>
22
+ </tr>
23
+ <% end %>
24
+ <% if error.respond_to?(:content_type) && error.content_type.present? %>
25
+ <tr>
26
+ <th>Content Type:</th>
27
+ <td><code><%= error.content_type %></code></td>
28
+ </tr>
29
+ <% end %>
30
+ <% if error.respond_to?(:request_duration_ms) && error.request_duration_ms.present? %>
31
+ <tr>
32
+ <th>Request Duration:</th>
33
+ <td>
34
+ <% duration = error.request_duration_ms %>
35
+ <span class="badge bg-<%= duration > 5000 ? 'danger' : duration > 1000 ? 'warning' : 'success' %>">
36
+ <%= duration > 1000 ? "#{(duration / 1000.0).round(1)}s" : "#{duration}ms" %>
37
+ </span>
38
+ </td>
39
+ </tr>
40
+ <% end %>
41
+ <tr>
42
+ <th>Request Params:</th>
43
+ <td>
44
+ <% if error.request_params.present? %>
45
+ <pre class="mb-0"><code><%= JSON.pretty_generate(JSON.parse(error.request_params)) rescue error.request_params %></code></pre>
46
+ <% else %>
47
+ <span class="text-muted">N/A</span>
48
+ <% end %>
49
+ </td>
50
+ </tr>
51
+ <tr>
52
+ <th>User Agent:</th>
53
+ <td>
54
+ <% if error.user_agent.present? %>
55
+ <% ua_info = parse_user_agent(error.user_agent) %>
56
+ <div class="mb-2">
57
+ <%= browser_icon(ua_info) %>
58
+ <strong><%= ua_info[:browser_name] %></strong>
59
+ <% if ua_info[:browser_version].present? %>
60
+ <span class="text-muted"><%= ua_info[:browser_version] %></span>
61
+ <% end %>
62
+ <span class="ms-2"><%= os_icon(ua_info) %> <%= ua_info[:os_name] %></span>
63
+ <span class="ms-2"><%= device_icon(ua_info) %> <%= ua_info[:device_type] %></span>
64
+ </div>
65
+ <details>
66
+ <summary class="text-muted" style="cursor: pointer;">
67
+ <small>Raw User Agent</small>
68
+ </summary>
69
+ <small class="text-muted"><%= error.user_agent %></small>
70
+ </details>
71
+ <% else %>
72
+ <span class="text-muted">N/A</span>
73
+ <% end %>
74
+ </td>
75
+ </tr>
76
+ <tr>
77
+ <th>IP Address:</th>
78
+ <td><code><%= error.ip_address || 'N/A' %></code></td>
79
+ </tr>
80
+ <% curl_cmd = RailsErrorDashboard::Services::CurlGenerator.call(error) %>
81
+ <% if curl_cmd.present? %>
82
+ <tr>
83
+ <th>Replay Request:</th>
84
+ <td>
85
+ <button class="btn btn-sm btn-outline-primary"
86
+ onclick="copyToClipboard(this.dataset.curl, this)"
87
+ data-curl="<%= h curl_cmd %>"
88
+ title="Copy curl command to clipboard">
89
+ <i class="bi bi-terminal"></i> Copy as curl
90
+ </button>
91
+ </td>
92
+ </tr>
93
+ <% end %>
94
+ </table>
95
+ </div>
96
+ </div>
97
+ <% end %>
@@ -0,0 +1,156 @@
1
+ <script>
2
+ function downloadErrorJSON(event) {
3
+ const errorData = {
4
+ id: <%= raw error.id.to_json %>,
5
+ error_type: <%= raw error.error_type.to_json %>,
6
+ message: <%= raw error.message.to_json %>,
7
+ backtrace: <%= raw error.backtrace.to_json %>,
8
+ occurred_at: <%= raw error.occurred_at.to_json %>,
9
+ first_seen_at: <%= raw error.first_seen_at.to_json %>,
10
+ last_seen_at: <%= raw error.last_seen_at.to_json %>,
11
+ occurrence_count: <%= raw error.occurrence_count.to_json %>,
12
+ resolved: <%= raw error.resolved?.to_json %>,
13
+ resolved_at: <%= raw error.resolved_at.to_json %>,
14
+ resolved_by_name: <%= raw error.resolved_by_name.to_json %>,
15
+ platform: <%= raw error.platform.to_json %>,
16
+ user_id: <%= raw error.user_id.to_json %>,
17
+ severity: <%= raw error.severity.to_json %>,
18
+ priority_level: <%= raw (error.priority_level || 0).to_json %>,
19
+ <% if error.respond_to?(:exception_cause) && error.exception_cause.present? %>
20
+ exception_cause: <%= raw error.exception_cause %>,
21
+ <% end %>
22
+ <% if error.respond_to?(:app_version) %>
23
+ app_version: <%= raw error.app_version.to_json %>,
24
+ <% end %>
25
+ <% if error.respond_to?(:git_commit) %>
26
+ git_commit: <%= raw error.git_commit.to_json %>,
27
+ <% end %>
28
+ created_at: <%= raw error.created_at.to_json %>,
29
+ updated_at: <%= raw error.updated_at.to_json %>
30
+ };
31
+
32
+ const jsonString = JSON.stringify(errorData, null, 2);
33
+ const blob = new Blob([jsonString], { type: 'application/json' });
34
+ const url = URL.createObjectURL(blob);
35
+ const link = document.createElement('a');
36
+ link.href = url;
37
+ link.download = `error_${errorData.id}_${errorData.error_type.replace(/[^a-zA-Z0-9]/g, '_')}.json`;
38
+ document.body.appendChild(link);
39
+ link.click();
40
+ document.body.removeChild(link);
41
+ URL.revokeObjectURL(url);
42
+
43
+ // Visual feedback
44
+ const button = event.currentTarget;
45
+ const originalHTML = button.innerHTML;
46
+ button.innerHTML = '<i class="bi bi-check"></i> Downloaded!';
47
+ button.classList.remove('btn-outline-secondary');
48
+ button.classList.add('btn-success');
49
+
50
+ setTimeout(() => {
51
+ button.innerHTML = originalHTML;
52
+ button.classList.remove('btn-success');
53
+ button.classList.add('btn-outline-secondary');
54
+ }, 2000);
55
+ }
56
+
57
+ // Section Navigation — pill bar with scroll spy
58
+ document.addEventListener('DOMContentLoaded', function() {
59
+ var sectionConfig = [
60
+ { id: 'section-error-info', icon: 'bi-bug-fill', label: 'Error' },
61
+ { id: 'section-request-context', icon: 'bi-globe', label: 'Request' },
62
+ { id: 'section-deprecation-warnings', icon: 'bi-exclamation-triangle', label: 'Deprecations' },
63
+ { id: 'section-n-plus-one', icon: 'bi-arrow-repeat', label: 'N+1' },
64
+ { id: 'section-cache-health', icon: 'bi-lightning-charge', label: 'Cache' },
65
+ { id: 'section-breadcrumbs', icon: 'bi-signpost-2', label: 'Breadcrumbs' },
66
+ { id: 'section-similar-errors', icon: 'bi-diagram-3', label: 'Similar' },
67
+ { id: 'section-co-occurring', icon: 'bi-intersect', label: 'Co-occurring' },
68
+ { id: 'section-timeline', icon: 'bi-clock-history', label: 'Timeline' },
69
+ { id: 'section-discussion', icon: 'bi-chat-dots', label: 'Discussion' },
70
+ { id: 'section-error-cascades', icon: 'bi-share', label: 'Cascades' },
71
+ { id: 'section-occurrence-patterns', icon: 'bi-graph-up', label: 'Patterns' }
72
+ ];
73
+
74
+ var scrollContainer = document.querySelector('.section-nav-scroll');
75
+ var wrapper = document.getElementById('section-nav-wrapper');
76
+ if (!scrollContainer || !wrapper) return;
77
+
78
+ // Build pills only for sections that exist in the DOM
79
+ var activeSections = [];
80
+ sectionConfig.forEach(function(cfg) {
81
+ if (document.getElementById(cfg.id)) {
82
+ activeSections.push(cfg);
83
+ var pill = document.createElement('a');
84
+ pill.href = '#' + cfg.id;
85
+ pill.className = 'section-nav-pill';
86
+ pill.dataset.section = cfg.id;
87
+ pill.innerHTML = '<i class="bi ' + cfg.icon + '"></i> <span>' + cfg.label + '</span>';
88
+ pill.addEventListener('click', function(e) {
89
+ e.preventDefault();
90
+ var target = document.getElementById(cfg.id);
91
+ if (target) {
92
+ var navHeight = wrapper.offsetHeight;
93
+ var y = target.getBoundingClientRect().top + window.pageYOffset - navHeight - 12;
94
+ window.scrollTo({ top: y, behavior: 'smooth' });
95
+ }
96
+ });
97
+ scrollContainer.appendChild(pill);
98
+ }
99
+ });
100
+
101
+ // Hide nav if only 2 or fewer sections (not useful)
102
+ if (activeSections.length <= 2) {
103
+ wrapper.style.display = 'none';
104
+ return;
105
+ }
106
+
107
+ // Scroll spy with Intersection Observer
108
+ var currentActive = null;
109
+ var observer = new IntersectionObserver(function(entries) {
110
+ entries.forEach(function(entry) {
111
+ if (entry.isIntersecting) {
112
+ var id = entry.target.id;
113
+ if (currentActive !== id) {
114
+ currentActive = id;
115
+ document.querySelectorAll('.section-nav-pill').forEach(function(p) {
116
+ p.classList.toggle('active', p.dataset.section === id);
117
+ if (p.dataset.section === id) {
118
+ p.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
119
+ }
120
+ });
121
+ }
122
+ }
123
+ });
124
+ }, {
125
+ rootMargin: '-80px 0px -60% 0px',
126
+ threshold: 0
127
+ });
128
+
129
+ activeSections.forEach(function(cfg) {
130
+ var el = document.getElementById(cfg.id);
131
+ if (el) observer.observe(el);
132
+ });
133
+
134
+ // Sticky detection — add shadow when stuck
135
+ var sentinel = document.createElement('div');
136
+ sentinel.style.height = '1px';
137
+ sentinel.style.marginBottom = '-1px';
138
+ wrapper.parentNode.insertBefore(sentinel, wrapper);
139
+
140
+ var stickyObserver = new IntersectionObserver(function(entries) {
141
+ wrapper.classList.toggle('is-stuck', !entries[0].isIntersecting);
142
+ }, { threshold: 0 });
143
+ stickyObserver.observe(sentinel);
144
+
145
+ // Keyboard shortcuts: [ previous section, ] next section
146
+ document.addEventListener('keydown', function(e) {
147
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
148
+ if (e.key === '[' || e.key === ']') {
149
+ var pills = Array.from(document.querySelectorAll('.section-nav-pill'));
150
+ var activeIdx = pills.findIndex(function(p) { return p.classList.contains('active'); });
151
+ var nextIdx = e.key === ']' ? Math.min(activeIdx + 1, pills.length - 1) : Math.max(activeIdx - 1, 0);
152
+ if (nextIdx !== activeIdx) pills[nextIdx].click();
153
+ }
154
+ });
155
+ });
156
+ </script>