rails_error_dashboard 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/app/controllers/rails_error_dashboard/application_controller.rb +8 -2
- data/app/controllers/rails_error_dashboard/errors_controller.rb +26 -15
- data/app/helpers/rails_error_dashboard/application_helper.rb +40 -10
- data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +1 -0
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +2 -2
- data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +1 -0
- data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +1 -0
- data/app/views/rails_error_dashboard/errors/_modals.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +21 -20
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +33 -19
- data/app/views/rails_error_dashboard/errors/_timeline.html.erb +1 -2
- data/app/views/rails_error_dashboard/errors/_user_errors_table.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +8 -4
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +16 -4
- data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +7 -3
- data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/index.html.erb +9 -3
- data/app/views/rails_error_dashboard/errors/overview.html.erb +2 -2
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +4 -2
- data/app/views/rails_error_dashboard/errors/releases.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/settings.html.erb +0 -1
- data/app/views/rails_error_dashboard/errors/show.html.erb +6 -4
- data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +5 -1
- data/app/views/rails_error_dashboard/errors/user_impact.html.erb +3 -1
- data/db/migrate/20260503000001_backfill_resolved_status.rb +36 -0
- data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +6 -4
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +2 -1
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +4 -1
- data/lib/rails_error_dashboard/queries/error_correlation.rb +17 -13
- data/lib/rails_error_dashboard/queries/errors_list.rb +14 -0
- data/lib/rails_error_dashboard/services/cascade_detector.rb +28 -18
- data/lib/rails_error_dashboard/version.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d2151cd1323d1261e27376d1d7d8eb9f47fcb6ec171fcc6c3dbab36fcf52f6a
|
|
4
|
+
data.tar.gz: 68367fa080c8532c9e69f3aecf13eef84ef8638ac91f83a586cce56341f38b1c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fd3167ec31230e80ca89eb723b78f4d97f073a2c68efa4fbc9fe83e85bfa6b1361fdc43f1f822fb1eadd9d00254d12f105e863d09825843bdeb14386a1cef95a
|
|
7
|
+
data.tar.gz: da46299157c3cc47a45e55d006ff6657f4d9728832fefc0c051b8e94c1c919be6320b22663742a94ebfd1f8df8cd93e19ac27e9d8e0f45e578a6a2f6d047712e
|
data/README.md
CHANGED
|
@@ -63,7 +63,7 @@ gem 'rails_error_dashboard'
|
|
|
63
63
|
|
|
64
64
|
### Core (Always Enabled)
|
|
65
65
|
|
|
66
|
-
Error capture from controllers, jobs, and middleware.
|
|
66
|
+
Error capture from controllers, jobs, and middleware. Custom-designed dashboard with dark/light mode, search, filtering, and real-time updates. Analytics with trend charts, severity breakdown, and spike detection. Workflow management with assignment, priority, snooze, mute/unmute (notification suppression), comments, and batch operations. Security via HTTP Basic Auth or custom lambda (Devise, Warden, session-based). Exception cause chains, enriched HTTP context, custom fingerprinting, CurrentAttributes integration, auto-reopen on recurrence, and sensitive data filtering — all built in.
|
|
67
67
|
|
|
68
68
|
### Optional Features
|
|
69
69
|
|
|
@@ -521,7 +521,7 @@ Available as open source under the [MIT License](https://opensource.org/licenses
|
|
|
521
521
|
|
|
522
522
|
## Acknowledgments
|
|
523
523
|
|
|
524
|
-
Built with [Rails](https://rubyonrails.org/) ·
|
|
524
|
+
Built with [Rails](https://rubyonrails.org/) · Custom design tokens with [Bootstrap 5 JS](https://getbootstrap.com/) for tooltips and modals · Charts by [Chart.js](https://www.chartjs.org/) · Pagination by [Pagy](https://github.com/ddnexus/pagy) · Docs theme by [Jekyll VitePress Theme](https://jekyll-vitepress.dev/) by [@crmne](https://github.com/crmne)
|
|
525
525
|
|
|
526
526
|
## Contributors
|
|
527
527
|
|
|
@@ -46,10 +46,16 @@ module RailsErrorDashboard
|
|
|
46
46
|
)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
# Handle Pagy pagination errors — redirect to page 1
|
|
49
|
+
# Handle Pagy pagination errors — redirect to page 1, preserving filters.
|
|
50
|
+
# Drop both :page and :per_page from the preserved query string. Either can
|
|
51
|
+
# trigger the rescue (page out of range, per_page negative or non-numeric);
|
|
52
|
+
# carrying them into the redirect would loop the user right back into the
|
|
53
|
+
# same error.
|
|
50
54
|
rescue_from Pagy::RangeError, Pagy::OptionError do |exception|
|
|
51
55
|
Rails.logger.warn("[RailsErrorDashboard] Pagination error: #{exception.message}")
|
|
52
|
-
|
|
56
|
+
preserved = request.query_parameters.except("page", :page, "per_page", :per_page)
|
|
57
|
+
target = preserved.any? ? "#{request.path}?#{preserved.to_query}" : request.path
|
|
58
|
+
redirect_to target, status: :moved_permanently
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
private
|
|
@@ -22,6 +22,9 @@ module RailsErrorDashboard
|
|
|
22
22
|
hide_snoozed
|
|
23
23
|
hide_muted
|
|
24
24
|
reopened
|
|
25
|
+
user_id
|
|
26
|
+
app_version
|
|
27
|
+
git_sha
|
|
25
28
|
sort_by
|
|
26
29
|
sort_direction
|
|
27
30
|
].freeze
|
|
@@ -171,7 +174,7 @@ module RailsErrorDashboard
|
|
|
171
174
|
end
|
|
172
175
|
|
|
173
176
|
def analytics
|
|
174
|
-
days = (
|
|
177
|
+
days = days_param(default: 30)
|
|
175
178
|
@days = days
|
|
176
179
|
|
|
177
180
|
# Use Query to get analytics data (pass application filter)
|
|
@@ -212,7 +215,7 @@ module RailsErrorDashboard
|
|
|
212
215
|
return
|
|
213
216
|
end
|
|
214
217
|
|
|
215
|
-
days = (
|
|
218
|
+
days = days_param(default: 7)
|
|
216
219
|
@days = days
|
|
217
220
|
|
|
218
221
|
# Use Query to get platform comparison data (pass application filter)
|
|
@@ -240,7 +243,7 @@ module RailsErrorDashboard
|
|
|
240
243
|
resolution_comment: params[:resolution_comment]
|
|
241
244
|
)
|
|
242
245
|
when "mute"
|
|
243
|
-
Commands::BatchMuteErrors.call(error_ids, muted_by: params[:muted_by])
|
|
246
|
+
Commands::BatchMuteErrors.call(error_ids, muted_by: params[:muted_by], reason: params[:reason])
|
|
244
247
|
when "unmute"
|
|
245
248
|
Commands::BatchUnmuteErrors.call(error_ids)
|
|
246
249
|
when "delete"
|
|
@@ -266,7 +269,7 @@ module RailsErrorDashboard
|
|
|
266
269
|
return
|
|
267
270
|
end
|
|
268
271
|
|
|
269
|
-
days = (
|
|
272
|
+
days = days_param(default: 30)
|
|
270
273
|
@days = days
|
|
271
274
|
correlation = Queries::ErrorCorrelation.new(days: days, application_id: @current_application_id)
|
|
272
275
|
|
|
@@ -280,7 +283,7 @@ module RailsErrorDashboard
|
|
|
280
283
|
end
|
|
281
284
|
|
|
282
285
|
def releases
|
|
283
|
-
days = (
|
|
286
|
+
days = days_param(default: 30)
|
|
284
287
|
@days = days
|
|
285
288
|
result = Queries::ReleaseTimeline.call(days, application_id: @current_application_id)
|
|
286
289
|
all_releases = result[:releases]
|
|
@@ -290,7 +293,7 @@ module RailsErrorDashboard
|
|
|
290
293
|
end
|
|
291
294
|
|
|
292
295
|
def user_impact
|
|
293
|
-
days = (
|
|
296
|
+
days = days_param(default: 30)
|
|
294
297
|
@days = days
|
|
295
298
|
result = Queries::UserImpactSummary.call(days, application_id: @current_application_id)
|
|
296
299
|
all_entries = result[:entries]
|
|
@@ -306,7 +309,7 @@ module RailsErrorDashboard
|
|
|
306
309
|
return
|
|
307
310
|
end
|
|
308
311
|
|
|
309
|
-
days = (
|
|
312
|
+
days = days_param(default: 30)
|
|
310
313
|
@days = days
|
|
311
314
|
result = Queries::DeprecationWarnings.call(days, application_id: @current_application_id)
|
|
312
315
|
all_deprecations = result[:deprecations]
|
|
@@ -326,7 +329,7 @@ module RailsErrorDashboard
|
|
|
326
329
|
return
|
|
327
330
|
end
|
|
328
331
|
|
|
329
|
-
days = (
|
|
332
|
+
days = days_param(default: 30)
|
|
330
333
|
@days = days
|
|
331
334
|
result = Queries::NplusOneSummary.call(days, application_id: @current_application_id)
|
|
332
335
|
all_patterns = result[:patterns]
|
|
@@ -346,7 +349,7 @@ module RailsErrorDashboard
|
|
|
346
349
|
return
|
|
347
350
|
end
|
|
348
351
|
|
|
349
|
-
days = (
|
|
352
|
+
days = days_param(default: 30)
|
|
350
353
|
@days = days
|
|
351
354
|
result = Queries::CacheHealthSummary.call(days, application_id: @current_application_id)
|
|
352
355
|
all_entries = result[:entries]
|
|
@@ -367,7 +370,7 @@ module RailsErrorDashboard
|
|
|
367
370
|
return
|
|
368
371
|
end
|
|
369
372
|
|
|
370
|
-
days = (
|
|
373
|
+
days = days_param(default: 30)
|
|
371
374
|
@days = days
|
|
372
375
|
result = Queries::JobHealthSummary.call(days, application_id: @current_application_id)
|
|
373
376
|
all_entries = result[:entries]
|
|
@@ -387,7 +390,7 @@ module RailsErrorDashboard
|
|
|
387
390
|
return
|
|
388
391
|
end
|
|
389
392
|
|
|
390
|
-
days = (
|
|
393
|
+
days = days_param(default: 30)
|
|
391
394
|
@days = days
|
|
392
395
|
|
|
393
396
|
# Live database health (display-time only)
|
|
@@ -423,7 +426,7 @@ module RailsErrorDashboard
|
|
|
423
426
|
return
|
|
424
427
|
end
|
|
425
428
|
|
|
426
|
-
days = (
|
|
429
|
+
days = days_param(default: 30)
|
|
427
430
|
@days = days
|
|
428
431
|
result = Queries::SwallowedExceptionSummary.call(days, application_id: @current_application_id)
|
|
429
432
|
all_entries = result[:entries]
|
|
@@ -444,7 +447,7 @@ module RailsErrorDashboard
|
|
|
444
447
|
return
|
|
445
448
|
end
|
|
446
449
|
|
|
447
|
-
days = (
|
|
450
|
+
days = days_param(default: 30)
|
|
448
451
|
@days = days
|
|
449
452
|
result = Queries::RackAttackSummary.call(days, application_id: @current_application_id)
|
|
450
453
|
all_events = result[:events]
|
|
@@ -465,7 +468,7 @@ module RailsErrorDashboard
|
|
|
465
468
|
return
|
|
466
469
|
end
|
|
467
470
|
|
|
468
|
-
days = (
|
|
471
|
+
days = days_param(default: 30)
|
|
469
472
|
@days = days
|
|
470
473
|
result = Queries::ActionCableSummary.call(days, application_id: @current_application_id)
|
|
471
474
|
all_channels = result[:channels]
|
|
@@ -486,7 +489,7 @@ module RailsErrorDashboard
|
|
|
486
489
|
return
|
|
487
490
|
end
|
|
488
491
|
|
|
489
|
-
days = (
|
|
492
|
+
days = days_param(default: 30)
|
|
490
493
|
@days = days
|
|
491
494
|
result = Queries::ActiveStorageSummary.call(days, application_id: @current_application_id)
|
|
492
495
|
all_services = result[:services]
|
|
@@ -610,6 +613,14 @@ module RailsErrorDashboard
|
|
|
610
613
|
params.permit(*FILTERABLE_PARAMS).to_h.symbolize_keys
|
|
611
614
|
end
|
|
612
615
|
|
|
616
|
+
# Coerce params[:days] into a sane integer in [1, 365]. Without clamping,
|
|
617
|
+
# a request like ?days=99999999 would scan the full table on every health
|
|
618
|
+
# query, defeating index pruning and burning CPU.
|
|
619
|
+
def days_param(default:)
|
|
620
|
+
raw = params[:days].presence || default
|
|
621
|
+
raw.to_i.clamp(1, 365)
|
|
622
|
+
end
|
|
623
|
+
|
|
613
624
|
def set_application_context
|
|
614
625
|
@current_application_id = params[:application_id].presence
|
|
615
626
|
@applications = Application.ordered_by_name.pluck(:name, :id)
|
|
@@ -27,6 +27,16 @@ module RailsErrorDashboard
|
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Serialize a value to JSON safely for inlining inside a <script> block.
|
|
31
|
+
# Ruby's #to_json escapes JSON special chars but does NOT escape "</" — a
|
|
32
|
+
# value containing the literal string "</script>" would break out of the
|
|
33
|
+
# surrounding <script> tag. Replace "</" with "<\/" (semantically equivalent
|
|
34
|
+
# in JSON and in JavaScript string literals) to neutralize the close tag.
|
|
35
|
+
# Returns html_safe for direct interpolation into a script body.
|
|
36
|
+
def js_safe_json(value)
|
|
37
|
+
value.to_json.gsub("</", '<\/').html_safe
|
|
38
|
+
end
|
|
39
|
+
|
|
30
40
|
# Returns Bootstrap color class for error severity
|
|
31
41
|
# Uses Catppuccin Mocha colors in dark theme via CSS variables
|
|
32
42
|
# @param severity [Symbol] The severity level (:critical, :high, :medium, :low, :info)
|
|
@@ -209,6 +219,17 @@ module RailsErrorDashboard
|
|
|
209
219
|
)
|
|
210
220
|
end
|
|
211
221
|
|
|
222
|
+
# Raw connection.select_all returns timestamps as Time in some PG configs
|
|
223
|
+
# and as ISO8601 strings in others — accept both shapes.
|
|
224
|
+
def parse_pg_timestamp(value)
|
|
225
|
+
return nil if value.blank?
|
|
226
|
+
return value if value.is_a?(Time) || value.is_a?(DateTime)
|
|
227
|
+
|
|
228
|
+
Time.parse(value.to_s)
|
|
229
|
+
rescue ArgumentError
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
212
233
|
# Renders a relative time ("3 hours ago") that updates automatically
|
|
213
234
|
# @param time [Time, DateTime, nil] The timestamp to display
|
|
214
235
|
# @param fallback [String] Text to show if time is nil
|
|
@@ -265,6 +286,13 @@ module RailsErrorDashboard
|
|
|
265
286
|
def auto_link_urls(text, error: nil)
|
|
266
287
|
return "" if text.blank?
|
|
267
288
|
|
|
289
|
+
# SECURITY: escape HTML special chars in the input before any further
|
|
290
|
+
# processing. simple_format(..., sanitize: false) at the end means
|
|
291
|
+
# whatever survives this pipeline is rendered as raw HTML; any unescaped
|
|
292
|
+
# `<script>` or `<img onerror=>` in the user's text would XSS.
|
|
293
|
+
# We only intentionally inject our own <a> / <code> tags after this point.
|
|
294
|
+
text = ERB::Util.html_escape(text)
|
|
295
|
+
|
|
268
296
|
# Get repository URL from error's application or global config
|
|
269
297
|
repo_url = if error&.application.respond_to?(:repository_url) && error.application.repository_url.present?
|
|
270
298
|
error.application.repository_url
|
|
@@ -302,32 +330,34 @@ module RailsErrorDashboard
|
|
|
302
330
|
)
|
|
303
331
|
}xi
|
|
304
332
|
|
|
305
|
-
# Replace URLs with clickable links
|
|
333
|
+
# Replace URLs with clickable links. The url, code_content, and file_path
|
|
334
|
+
# values below are slices of `text` which we already escaped at function
|
|
335
|
+
# entry, so we don't re-escape them here (would double-escape `&`,
|
|
336
|
+
# breaking visual rendering). repo_url is from config so we escape it
|
|
337
|
+
# explicitly before interpolating.
|
|
338
|
+
escaped_repo_url = repo_url ? ERB::Util.html_escape(repo_url.chomp("/")) : nil
|
|
339
|
+
|
|
306
340
|
linked_text = text_with_placeholders.gsub(url_regex) do |url|
|
|
307
|
-
# Clean up the URL
|
|
308
341
|
clean_url = url.strip
|
|
309
342
|
|
|
310
|
-
# Add protocol if missing
|
|
311
343
|
href = clean_url.start_with?("http://", "https://") ? clean_url : "https://#{clean_url}"
|
|
312
344
|
|
|
313
|
-
# Truncate display text for very long URLs
|
|
314
345
|
display_text = clean_url.length > 60 ? "#{clean_url[0..57]}..." : clean_url
|
|
315
346
|
|
|
316
|
-
"<a href=\"#{
|
|
347
|
+
"<a href=\"#{href}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-primary text-decoration-underline\">#{display_text}</a>"
|
|
317
348
|
end
|
|
318
349
|
|
|
319
|
-
# Restore file paths with GitHub links
|
|
350
|
+
# Restore file paths with GitHub links
|
|
320
351
|
linked_text.gsub!(/###FILE_PATH_(\d+)###/) do
|
|
321
352
|
file_path = file_paths[Regexp.last_match(1).to_i]
|
|
322
|
-
|
|
323
|
-
"<
|
|
324
|
-
"<code class=\"inline-code-highlight file-path-link\">#{ERB::Util.html_escape(file_path)}</code></a>"
|
|
353
|
+
"<a href=\"#{escaped_repo_url}/blob/main/#{file_path}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-decoration-none\" title=\"View on GitHub\">" \
|
|
354
|
+
"<code class=\"inline-code-highlight file-path-link\">#{file_path}</code></a>"
|
|
325
355
|
end
|
|
326
356
|
|
|
327
357
|
# Restore code blocks with styling
|
|
328
358
|
linked_text.gsub!(/###CODE_BLOCK_(\d+)###/) do
|
|
329
359
|
code_content = code_blocks[Regexp.last_match(1).to_i]
|
|
330
|
-
"<code class=\"inline-code-highlight\">#{
|
|
360
|
+
"<code class=\"inline-code-highlight\">#{code_content}</code>"
|
|
331
361
|
end
|
|
332
362
|
|
|
333
363
|
# Preserve line breaks and return as HTML safe
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<!-- Breadcrumbs (Request Activity Trail) -->
|
|
2
2
|
<% if RailsErrorDashboard.configuration.enable_breadcrumbs && error.respond_to?(:breadcrumbs) && error.breadcrumbs.present? %>
|
|
3
3
|
<% breadcrumbs = JSON.parse(error.breadcrumbs) rescue [] %>
|
|
4
|
+
<% breadcrumbs = [] unless breadcrumbs.is_a?(Array) %>
|
|
4
5
|
<% if breadcrumbs.any? %>
|
|
5
6
|
<% deprecation_crumbs = breadcrumbs.select { |c| c["c"] == "deprecation" } %>
|
|
6
7
|
<% if deprecation_crumbs.any? %>
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
<% end %>
|
|
58
58
|
</td>
|
|
59
59
|
<td style="padding: var(--space-3) var(--space-4); text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text-primary);">
|
|
60
|
-
<%= error.occurrence_count
|
|
60
|
+
<%= number_with_delimiter(error.occurrence_count) %>
|
|
61
61
|
</td>
|
|
62
62
|
<td style="padding: var(--space-3) var(--space-4); text-align: right; font-variant-numeric: tabular-nums; color: var(--text-secondary);">
|
|
63
63
|
<% if error.user_id %>
|
|
64
|
-
<%= error.user_id %>
|
|
64
|
+
<%= link_to error.user_id, errors_path(user_id: error.user_id, unresolved: '0'), style: "color: inherit; text-decoration: none;", title: "View all errors for user #{error.user_id}" %>
|
|
65
65
|
<% else %>
|
|
66
66
|
<span style="color: var(--text-tertiary);">—</span>
|
|
67
67
|
<% end %>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<!-- Instance Variables (captured from receiver object at raise time) -->
|
|
2
2
|
<% if RailsErrorDashboard.configuration.enable_instance_variables && error.class.column_names.include?("instance_variables") && error.read_attribute(:instance_variables).present? %>
|
|
3
3
|
<% instance_vars = JSON.parse(error.read_attribute(:instance_variables)) rescue {} %>
|
|
4
|
+
<% instance_vars = {} unless instance_vars.is_a?(Hash) %>
|
|
4
5
|
<% self_class = instance_vars.delete("_self_class") %>
|
|
5
6
|
<% if instance_vars.any? %>
|
|
6
7
|
<div class="card mb-4" id="section-instance-variables">
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<!-- Local Variables (captured via TracePoint) -->
|
|
2
2
|
<% if RailsErrorDashboard.configuration.enable_local_variables && error.respond_to?(:local_variables) && error.local_variables.present? %>
|
|
3
3
|
<% local_vars = JSON.parse(error.local_variables) rescue {} %>
|
|
4
|
+
<% local_vars = {} unless local_vars.is_a?(Hash) %>
|
|
4
5
|
<% if local_vars.any? %>
|
|
5
6
|
<div class="card mb-4" id="section-local-variables">
|
|
6
7
|
<div class="card-header">
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<div class="modal fade" id="resolveModal" tabindex="-1" aria-labelledby="resolveModalLabel" aria-hidden="true">
|
|
3
3
|
<div class="modal-dialog">
|
|
4
4
|
<div class="modal-content">
|
|
5
|
-
<%= form_with url: resolve_error_path(error), method: :post
|
|
5
|
+
<%= form_with url: resolve_error_path(error), method: :post do |f| %>
|
|
6
6
|
<div class="modal-header">
|
|
7
7
|
<h5 class="modal-title" id="resolveModalLabel">
|
|
8
8
|
<i class="bi bi-check-circle"></i> Mark Error as Resolved
|
|
@@ -1,32 +1,33 @@
|
|
|
1
1
|
<script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
|
|
2
2
|
window.downloadErrorJSON = function(event) {
|
|
3
3
|
const errorData = {
|
|
4
|
-
id: <%=
|
|
5
|
-
error_type: <%=
|
|
6
|
-
message: <%=
|
|
7
|
-
backtrace: <%=
|
|
8
|
-
occurred_at: <%=
|
|
9
|
-
first_seen_at: <%=
|
|
10
|
-
last_seen_at: <%=
|
|
11
|
-
occurrence_count: <%=
|
|
12
|
-
resolved: <%=
|
|
13
|
-
resolved_at: <%=
|
|
14
|
-
resolved_by_name: <%=
|
|
15
|
-
platform: <%=
|
|
16
|
-
user_id: <%=
|
|
17
|
-
severity: <%=
|
|
18
|
-
priority_level: <%=
|
|
4
|
+
id: <%= js_safe_json(error.id) %>,
|
|
5
|
+
error_type: <%= js_safe_json(error.error_type) %>,
|
|
6
|
+
message: <%= js_safe_json(error.message) %>,
|
|
7
|
+
backtrace: <%= js_safe_json(error.backtrace) %>,
|
|
8
|
+
occurred_at: <%= js_safe_json(error.occurred_at) %>,
|
|
9
|
+
first_seen_at: <%= js_safe_json(error.first_seen_at) %>,
|
|
10
|
+
last_seen_at: <%= js_safe_json(error.last_seen_at) %>,
|
|
11
|
+
occurrence_count: <%= js_safe_json(error.occurrence_count) %>,
|
|
12
|
+
resolved: <%= js_safe_json(error.resolved?) %>,
|
|
13
|
+
resolved_at: <%= js_safe_json(error.resolved_at) %>,
|
|
14
|
+
resolved_by_name: <%= js_safe_json(error.resolved_by_name) %>,
|
|
15
|
+
platform: <%= js_safe_json(error.platform) %>,
|
|
16
|
+
user_id: <%= js_safe_json(error.user_id) %>,
|
|
17
|
+
severity: <%= js_safe_json(error.severity) %>,
|
|
18
|
+
priority_level: <%= js_safe_json(error.priority_level || 0) %>,
|
|
19
19
|
<% if error.respond_to?(:exception_cause) && error.exception_cause.present? %>
|
|
20
|
-
|
|
20
|
+
<% parsed_cause = (JSON.parse(error.exception_cause) rescue nil) %>
|
|
21
|
+
exception_cause: <%= js_safe_json(parsed_cause) %>,
|
|
21
22
|
<% end %>
|
|
22
23
|
<% if error.respond_to?(:app_version) %>
|
|
23
|
-
app_version: <%=
|
|
24
|
+
app_version: <%= js_safe_json(error.app_version) %>,
|
|
24
25
|
<% end %>
|
|
25
26
|
<% if error.respond_to?(:git_commit) %>
|
|
26
|
-
git_commit: <%=
|
|
27
|
+
git_commit: <%= js_safe_json(error.git_commit) %>,
|
|
27
28
|
<% end %>
|
|
28
|
-
created_at: <%=
|
|
29
|
-
updated_at: <%=
|
|
29
|
+
created_at: <%= js_safe_json(error.created_at) %>,
|
|
30
|
+
updated_at: <%= js_safe_json(error.updated_at) %>
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
const jsonString = JSON.stringify(errorData, null, 2);
|
|
@@ -35,37 +35,49 @@
|
|
|
35
35
|
<div class="mb-3">
|
|
36
36
|
<small class="metadata-label d-block mb-1">Severity Level</small>
|
|
37
37
|
<% severity = error.severity %>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
<%= link_to errors_path(severity: severity, unresolved: '0'), class: "text-decoration-none", title: "View all #{severity} errors" do %>
|
|
39
|
+
<% if severity == :critical %>
|
|
40
|
+
<span class="badge bg-danger fs-6">CRITICAL</span>
|
|
41
|
+
<% elsif severity == :high %>
|
|
42
|
+
<span class="badge bg-warning text-dark fs-6">HIGH</span>
|
|
43
|
+
<% elsif severity == :medium %>
|
|
44
|
+
<span class="badge bg-info text-dark fs-6">MEDIUM</span>
|
|
45
|
+
<% else %>
|
|
46
|
+
<span class="badge bg-secondary fs-6">LOW</span>
|
|
47
|
+
<% end %>
|
|
46
48
|
<% end %>
|
|
47
49
|
</div>
|
|
48
50
|
|
|
49
51
|
<div class="mb-3">
|
|
50
52
|
<small class="metadata-label d-block mb-1">Platform</small>
|
|
51
|
-
<% if error.platform
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
<% if error.platform.present? %>
|
|
54
|
+
<%= link_to errors_path(platform: error.platform, unresolved: '0'), class: "text-decoration-none", title: "View all #{error.platform} errors" do %>
|
|
55
|
+
<% if error.platform == 'iOS' %>
|
|
56
|
+
<span class="badge badge-ios"><i class="bi bi-apple"></i> iOS</span>
|
|
57
|
+
<% elsif error.platform == 'Android' %>
|
|
58
|
+
<span class="badge badge-android"><i class="bi bi-android2"></i> Android</span>
|
|
59
|
+
<% elsif error.platform == 'Web' %>
|
|
60
|
+
<span class="badge badge-web"><i class="bi bi-globe"></i> Web</span>
|
|
61
|
+
<% else %>
|
|
62
|
+
<span class="badge badge-api"><i class="bi bi-server"></i> <%= error.platform %></span>
|
|
63
|
+
<% end %>
|
|
64
|
+
<% end %>
|
|
57
65
|
<% else %>
|
|
58
|
-
<span class="badge badge-api"><i class="bi bi-server"></i>
|
|
66
|
+
<span class="badge badge-api"><i class="bi bi-server"></i> API</span>
|
|
59
67
|
<% end %>
|
|
60
68
|
</div>
|
|
61
69
|
|
|
62
70
|
<div class="mb-3">
|
|
63
71
|
<small class="metadata-label d-block mb-1">User</small>
|
|
64
72
|
<% if error.respond_to?(:user) && error.user %>
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
<%= link_to errors_path(user_id: error.user_id, unresolved: '0'), class: "text-decoration-none", title: "View all errors for this user" do %>
|
|
74
|
+
<strong><%= error.user.email %></strong><br>
|
|
75
|
+
<small class="text-muted">ID: <%= error.user_id %></small>
|
|
76
|
+
<% end %>
|
|
67
77
|
<% elsif error.user_id %>
|
|
68
|
-
|
|
78
|
+
<%= link_to errors_path(user_id: error.user_id, unresolved: '0'), class: "text-decoration-none text-muted", title: "View all errors for this user" do %>
|
|
79
|
+
User ID: <%= error.user_id %>
|
|
80
|
+
<% end %>
|
|
69
81
|
<% else %>
|
|
70
82
|
<span class="text-muted">Guest / Unauthenticated</span>
|
|
71
83
|
<% end %>
|
|
@@ -249,7 +261,9 @@
|
|
|
249
261
|
<% if error.app_version.present? %>
|
|
250
262
|
<div class="mb-1">
|
|
251
263
|
<small class="text-muted">App Version:</small>
|
|
252
|
-
|
|
264
|
+
<%= link_to errors_path(app_version: error.app_version, unresolved: '0'), class: "ms-1 text-decoration-none", title: "View all errors in #{error.app_version}" do %>
|
|
265
|
+
<code><%= error.app_version %></code>
|
|
266
|
+
<% end %>
|
|
253
267
|
</div>
|
|
254
268
|
<% end %>
|
|
255
269
|
|
|
@@ -78,8 +78,7 @@
|
|
|
78
78
|
<% if error.resolution_comment.present? %>
|
|
79
79
|
data-bs-toggle="tooltip"
|
|
80
80
|
data-bs-placement="top"
|
|
81
|
-
|
|
82
|
-
title="<strong>Resolution Notes:</strong><br><%= error.resolution_comment.truncate(150).gsub('"', '"') %>"
|
|
81
|
+
title="Resolution Notes: <%= error.resolution_comment.truncate(150) %>"
|
|
83
82
|
<% end %>>
|
|
84
83
|
<i class="bi bi-check-circle"></i> Resolved
|
|
85
84
|
</span>
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
</td>
|
|
62
62
|
<% end %>
|
|
63
63
|
<td>
|
|
64
|
-
<%= link_to "View Errors", errors_path(user_id: user_data[:user_id]), class: "btn btn-sm btn-outline-primary" %>
|
|
64
|
+
<%= link_to "View Errors", errors_path(user_id: user_data[:user_id], unresolved: '0'), class: "btn btn-sm btn-outline-primary" %>
|
|
65
65
|
</td>
|
|
66
66
|
</tr>
|
|
67
67
|
<% end %>
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
<div class="d-flex gap-2 flex-wrap">
|
|
149
149
|
<small class="text-muted me-2">Quick Links:</small>
|
|
150
150
|
<% @errors_by_platform.keys.each do |platform| %>
|
|
151
|
-
<%= link_to platform, errors_path(platform: platform), class: "btn btn-sm btn-outline-secondary" %>
|
|
151
|
+
<%= link_to platform, errors_path(platform: platform, unresolved: '0'), class: "btn btn-sm btn-outline-secondary" %>
|
|
152
152
|
<% end %>
|
|
153
153
|
</div>
|
|
154
154
|
</div>
|
|
@@ -304,7 +304,7 @@
|
|
|
304
304
|
</div>
|
|
305
305
|
</td>
|
|
306
306
|
<td>
|
|
307
|
-
<%= link_to "View Errors", errors_path(error_type: error_type), class: "btn btn-sm btn-outline-primary" %>
|
|
307
|
+
<%= link_to "View Errors", errors_path(error_type: error_type, unresolved: '0'), class: "btn btn-sm btn-outline-primary" %>
|
|
308
308
|
</td>
|
|
309
309
|
</tr>
|
|
310
310
|
<% end %>
|
|
@@ -339,7 +339,11 @@
|
|
|
339
339
|
<tbody>
|
|
340
340
|
<% @recurring_data[:high_frequency_errors].first(10).each do |error| %>
|
|
341
341
|
<tr>
|
|
342
|
-
<td
|
|
342
|
+
<td>
|
|
343
|
+
<%= link_to errors_path(error_type: error[:error_type], unresolved: '0'), class: "text-decoration-none" do %>
|
|
344
|
+
<code class="small"><%= error[:error_type] %></code>
|
|
345
|
+
<% end %>
|
|
346
|
+
</td>
|
|
343
347
|
<td><span class="badge bg-danger"><%= error[:total_occurrences] %></span></td>
|
|
344
348
|
<td><%= error[:duration_days] %> days</td>
|
|
345
349
|
<td><small class="text-muted"><%= local_time(error[:first_seen], format: :date_only) %></small></td>
|
|
@@ -587,7 +591,7 @@
|
|
|
587
591
|
</td>
|
|
588
592
|
<td><strong><%= hours %> hours</strong></td>
|
|
589
593
|
<td>
|
|
590
|
-
<%= link_to "View", errors_path(severity: severity), class: "btn btn-sm btn-outline-primary" %>
|
|
594
|
+
<%= link_to "View", errors_path(severity: severity, unresolved: '0'), class: "btn btn-sm btn-outline-primary" %>
|
|
591
595
|
</td>
|
|
592
596
|
</tr>
|
|
593
597
|
<% end %>
|
|
@@ -90,7 +90,11 @@
|
|
|
90
90
|
<tbody>
|
|
91
91
|
<% @problematic_releases.each do |release| %>
|
|
92
92
|
<tr>
|
|
93
|
-
<td
|
|
93
|
+
<td>
|
|
94
|
+
<%= link_to errors_path(app_version: release[:version], unresolved: '0'), class: "text-decoration-none" do %>
|
|
95
|
+
<code><%= release[:version] %></code>
|
|
96
|
+
<% end %>
|
|
97
|
+
</td>
|
|
94
98
|
<td><strong class="text-danger"><%= release[:error_count] %></strong></td>
|
|
95
99
|
<td>
|
|
96
100
|
<span class="badge bg-danger">
|
|
@@ -105,7 +109,7 @@
|
|
|
105
109
|
<% end %>
|
|
106
110
|
</td>
|
|
107
111
|
<td>
|
|
108
|
-
<%= link_to "View", errors_path(
|
|
112
|
+
<%= link_to "View", errors_path(app_version: release[:version], unresolved: '0'), class: "btn btn-sm btn-outline-primary" %>
|
|
109
113
|
</td>
|
|
110
114
|
</tr>
|
|
111
115
|
<% end %>
|
|
@@ -141,7 +145,11 @@
|
|
|
141
145
|
<tbody>
|
|
142
146
|
<% @errors_by_version.sort_by { |_, v| -v[:count] }.first(10).each do |version, data| %>
|
|
143
147
|
<tr>
|
|
144
|
-
<td
|
|
148
|
+
<td>
|
|
149
|
+
<%= link_to errors_path(app_version: version, unresolved: '0'), class: "text-decoration-none" do %>
|
|
150
|
+
<code><%= version %></code>
|
|
151
|
+
<% end %>
|
|
152
|
+
</td>
|
|
145
153
|
<td><%= data[:count] %></td>
|
|
146
154
|
<td><%= data[:error_types] %></td>
|
|
147
155
|
<td>
|
|
@@ -185,7 +193,11 @@
|
|
|
185
193
|
<tbody>
|
|
186
194
|
<% @errors_by_git_sha.sort_by { |_, v| -v[:count] }.first(10).each do |sha, data| %>
|
|
187
195
|
<tr>
|
|
188
|
-
<td
|
|
196
|
+
<td>
|
|
197
|
+
<%= link_to errors_path(git_sha: sha, unresolved: '0'), class: "text-decoration-none" do %>
|
|
198
|
+
<code class="small"><%= sha[0..7] %></code>
|
|
199
|
+
<% end %>
|
|
200
|
+
</td>
|
|
189
201
|
<td><%= data[:count] %></td>
|
|
190
202
|
<td><%= data[:error_types] %></td>
|
|
191
203
|
<td>
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
<%= number_with_delimiter(table[:dead_tuples]) %>
|
|
151
151
|
<% end %>
|
|
152
152
|
</td>
|
|
153
|
-
<td><%= table[:last_autovacuum] ? local_time_ago(
|
|
153
|
+
<td><%= table[:last_autovacuum] ? local_time_ago(parse_pg_timestamp(table[:last_autovacuum])) : "Never" %></td>
|
|
154
154
|
</tr>
|
|
155
155
|
<% end %>
|
|
156
156
|
</tbody>
|
|
@@ -203,7 +203,7 @@
|
|
|
203
203
|
<%= number_with_delimiter(table[:dead_tuples]) %>
|
|
204
204
|
<% end %>
|
|
205
205
|
</td>
|
|
206
|
-
<td><%= table[:last_autovacuum] ? local_time_ago(
|
|
206
|
+
<td><%= table[:last_autovacuum] ? local_time_ago(parse_pg_timestamp(table[:last_autovacuum])) : "Never" %></td>
|
|
207
207
|
</tr>
|
|
208
208
|
<% end %>
|
|
209
209
|
</tbody>
|
|
@@ -338,7 +338,11 @@
|
|
|
338
338
|
<% @entries.each do |entry| %>
|
|
339
339
|
<tr>
|
|
340
340
|
<td><%= link_to "##{entry[:error_id]}", error_path(entry[:error_id]), class: "text-decoration-none" %></td>
|
|
341
|
-
<td
|
|
341
|
+
<td>
|
|
342
|
+
<%= link_to errors_path(error_type: entry[:error_type], unresolved: '0'), class: "text-decoration-none" do %>
|
|
343
|
+
<code><%= truncate(entry[:error_type].to_s, length: 40) %></code>
|
|
344
|
+
<% end %>
|
|
345
|
+
</td>
|
|
342
346
|
<td>
|
|
343
347
|
<% util = entry[:utilization] %>
|
|
344
348
|
<% util_badge = util >= 80 ? "danger" : (util >= 60 ? "warning" : "success") %>
|
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
<small class="text-muted d-block">GC</small>
|
|
140
140
|
<small>
|
|
141
141
|
<% gc_health = health["gc"] || {} %>
|
|
142
|
-
Live: <%= gc_health["heap_live_slots"]
|
|
142
|
+
Live: <%= gc_health["heap_live_slots"] ? number_with_delimiter(gc_health["heap_live_slots"]) : "?" %> /
|
|
143
143
|
Major GC: <%= gc_health["major_gc_count"] || "?" %>
|
|
144
144
|
</small>
|
|
145
145
|
</div>
|
|
@@ -89,8 +89,8 @@
|
|
|
89
89
|
<%= link_to errors_path(app_context.merge(unresolved: '0')), class: "btn filter-pill #{current_status.blank? && !is_unresolved_only ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>All<% end %>
|
|
90
90
|
<%= link_to errors_path(app_context.merge(unresolved: '1')), class: "btn filter-pill #{is_unresolved_only && current_status.blank? ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Unresolved <span style="font-size: 11px; opacity: 0.7;"><%= @stats[:unresolved] %></span><% end %>
|
|
91
91
|
<%= link_to errors_path(app_context.merge(status: 'resolved', unresolved: '0')), class: "btn filter-pill #{current_status == 'resolved' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Resolved<% end %>
|
|
92
|
-
<%= link_to errors_path(app_context.merge(assigned_to: '__assigned__')), class: "btn filter-pill #{params[:assigned_to] == '__assigned__' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Assigned<% end %>
|
|
93
|
-
<%= link_to errors_path(app_context.merge(reopened: 'true')), class: "btn filter-pill #{params[:reopened] == 'true' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Reopened<% end %>
|
|
92
|
+
<%= link_to errors_path(app_context.merge(assigned_to: '__assigned__', unresolved: '0')), class: "btn filter-pill #{params[:assigned_to] == '__assigned__' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Assigned<% end %>
|
|
93
|
+
<%= link_to errors_path(app_context.merge(reopened: 'true', unresolved: '0')), class: "btn filter-pill #{params[:reopened] == 'true' ? 'active btn-primary' : 'btn-outline-primary'}", style: "text-decoration: none;" do %>Reopened<% end %>
|
|
94
94
|
</div>
|
|
95
95
|
|
|
96
96
|
<div style="width: 1px; height: 24px; background: var(--border-primary);"></div>
|
|
@@ -172,7 +172,13 @@
|
|
|
172
172
|
active_filters << { label: "Timeframe: #{params[:timeframe].humanize}", param: :timeframe } if params[:timeframe].present?
|
|
173
173
|
active_filters << { label: "Frequency: #{params[:frequency].humanize}", param: :frequency } if params[:frequency].present?
|
|
174
174
|
active_filters << { label: "Status: #{params[:status].humanize}", param: :status } if params[:status].present?
|
|
175
|
-
|
|
175
|
+
if params[:priority_level].present?
|
|
176
|
+
# priority_level is the integer key (3=Critical/P0, 0=Low/P3); look up the short_label
|
|
177
|
+
# rather than interpolating the raw integer, which inverts the P-number.
|
|
178
|
+
priority_data = RailsErrorDashboard::ErrorLog::PRIORITY_LEVELS[params[:priority_level].to_i]
|
|
179
|
+
priority_short = priority_data ? priority_data[:short_label] : "P?"
|
|
180
|
+
active_filters << { label: "Priority: #{priority_short}", param: :priority_level }
|
|
181
|
+
end
|
|
176
182
|
if params[:assigned_to] == '__unassigned__'
|
|
177
183
|
active_filters << { label: "Unassigned", param: :assigned_to }
|
|
178
184
|
elsif params[:assigned_to] == '__assigned__'
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-bottom: var(--space-6);">
|
|
93
93
|
<!-- Affected Users -->
|
|
94
94
|
<div class="card stat-card" style="padding: var(--space-5) var(--space-6);">
|
|
95
|
-
<div class="stat-label" style="margin-bottom: 4px;">Affected Users</div>
|
|
95
|
+
<div class="stat-label" style="margin-bottom: 4px;">Affected Users (Today)</div>
|
|
96
96
|
<div style="display: flex; align-items: baseline; gap: 8px;">
|
|
97
97
|
<span class="stat-value"><%= @stats[:affected_users_today] %></span>
|
|
98
98
|
<% if @stats[:affected_users_change] != 0 %>
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
<div style="font-size: 12px; color: var(--text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"><%= error[:message]&.truncate(80) %></div>
|
|
150
150
|
</div>
|
|
151
151
|
<div style="text-align: right; flex-shrink: 0;">
|
|
152
|
-
<div style="font-size: 14px; font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums;"><%= error[:occurrence_count]
|
|
152
|
+
<div style="font-size: 14px; font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums;"><%= number_with_delimiter(error[:occurrence_count]) %></div>
|
|
153
153
|
<div style="font-size: 11px; color: var(--text-tertiary);"><%= error[:affected_users] %> users</div>
|
|
154
154
|
</div>
|
|
155
155
|
<% end %>
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
</div>
|
|
89
89
|
</div>
|
|
90
90
|
<div class="card-footer border-top">
|
|
91
|
-
<%= link_to "View #{platform} Errors", errors_path(platform: platform), class: "btn btn-sm btn-outline-primary w-100" %>
|
|
91
|
+
<%= link_to "View #{platform} Errors", errors_path(platform: platform, unresolved: '0'), class: "btn btn-sm btn-outline-primary w-100" %>
|
|
92
92
|
</div>
|
|
93
93
|
</div>
|
|
94
94
|
</div>
|
|
@@ -227,7 +227,9 @@
|
|
|
227
227
|
<% @cross_platform_errors.first(10).each do |error| %>
|
|
228
228
|
<tr>
|
|
229
229
|
<td>
|
|
230
|
-
|
|
230
|
+
<%= link_to errors_path(error_type: error[:error_type], unresolved: '0'), class: "text-decoration-none" do %>
|
|
231
|
+
<code class="small"><%= error[:error_type] %></code>
|
|
232
|
+
<% end %>
|
|
231
233
|
</td>
|
|
232
234
|
<td>
|
|
233
235
|
<% error[:platforms].each do |platform| %>
|
|
@@ -185,7 +185,9 @@
|
|
|
185
185
|
<% @releases.each do |release| %>
|
|
186
186
|
<tr class="<%= 'table-active' if release[:current] %>">
|
|
187
187
|
<td>
|
|
188
|
-
|
|
188
|
+
<%= link_to errors_path(app_version: release[:version], unresolved: '0'), class: "text-decoration-none" do %>
|
|
189
|
+
<strong><%= release[:version] %></strong>
|
|
190
|
+
<% end %>
|
|
189
191
|
<% if release[:current] %>
|
|
190
192
|
<span class="badge bg-primary ms-1">Current</span>
|
|
191
193
|
<% end %>
|
|
@@ -213,7 +213,6 @@
|
|
|
213
213
|
<div class="card" style="margin-bottom: var(--space-4);">
|
|
214
214
|
<div style="padding: var(--space-4) var(--space-6); border-bottom: 1px solid var(--border-primary);">
|
|
215
215
|
<span style="font-size: 14px; font-weight: 600; color: var(--text-primary);"><i class="bi bi-puzzle" style="margin-right: 6px;"></i> Active Plugins</span>
|
|
216
|
-
</h5>
|
|
217
216
|
</div>
|
|
218
217
|
<div class="card-body">
|
|
219
218
|
<% if RailsErrorDashboard::PluginRegistry.any? %>
|
|
@@ -203,16 +203,18 @@
|
|
|
203
203
|
<span style="font-size: 13px; font-weight: 600;">Quick Actions</span>
|
|
204
204
|
</div>
|
|
205
205
|
<div style="padding: var(--space-3) var(--space-5); display: flex; flex-direction: column; gap: var(--space-2);">
|
|
206
|
-
<%= link_to errors_path(app_context.merge(error_type: @error.error_type)), class: "btn btn-sm" do %>
|
|
206
|
+
<%= link_to errors_path(app_context.merge(error_type: @error.error_type, unresolved: '0')), class: "btn btn-sm" do %>
|
|
207
207
|
<i class="bi bi-filter"></i> View Similar Errors
|
|
208
208
|
<% end %>
|
|
209
209
|
<% if @error.user_id %>
|
|
210
|
-
<%= link_to errors_path(app_context.merge(user_id: @error.user_id)), class: "btn btn-sm" do %>
|
|
210
|
+
<%= link_to errors_path(app_context.merge(user_id: @error.user_id, unresolved: '0')), class: "btn btn-sm" do %>
|
|
211
211
|
<i class="bi bi-person"></i> View User's Errors
|
|
212
212
|
<% end %>
|
|
213
213
|
<% end %>
|
|
214
|
-
|
|
215
|
-
|
|
214
|
+
<% if @error.platform.present? %>
|
|
215
|
+
<%= link_to errors_path(app_context.merge(platform: @error.platform, unresolved: '0')), class: "btn btn-sm" do %>
|
|
216
|
+
<i class="bi bi-phone"></i> View <%= @error.platform %> Errors
|
|
217
|
+
<% end %>
|
|
216
218
|
<% end %>
|
|
217
219
|
<%= link_to analytics_errors_path(app_context), class: "btn btn-sm" do %>
|
|
218
220
|
<i class="bi bi-bar-chart-line"></i> Analytics
|
|
@@ -93,7 +93,11 @@
|
|
|
93
93
|
<tbody>
|
|
94
94
|
<% @entries.each do |entry| %>
|
|
95
95
|
<tr>
|
|
96
|
-
<td
|
|
96
|
+
<td>
|
|
97
|
+
<%= link_to errors_path(error_type: entry[:exception_class], unresolved: '0'), class: "text-decoration-none", title: "View any unrescued #{entry[:exception_class]} errors that leaked into error_log" do %>
|
|
98
|
+
<code style="font-size: 0.85em;"><%= entry[:exception_class] %></code>
|
|
99
|
+
<% end %>
|
|
100
|
+
</td>
|
|
97
101
|
<td style="color: var(--text-secondary); font-size: 12px; word-break: break-all;"><%= entry[:raise_location] %></td>
|
|
98
102
|
<td style="color: var(--text-secondary); font-size: 12px; word-break: break-all;"><%= entry[:rescue_location] || "Unknown" %></td>
|
|
99
103
|
<td><span class="badge bg-info text-dark"><%= number_with_delimiter(entry[:raise_count]) %></span></td>
|
|
@@ -107,7 +107,9 @@
|
|
|
107
107
|
<tr>
|
|
108
108
|
<td><strong><%= (@pagy.from || 1) + index %></strong></td>
|
|
109
109
|
<td>
|
|
110
|
-
|
|
110
|
+
<%= link_to errors_path(error_type: entry[:error_type], unresolved: '0'), class: "text-decoration-none" do %>
|
|
111
|
+
<strong><%= entry[:error_type] %></strong>
|
|
112
|
+
<% end %>
|
|
111
113
|
<br><small class="text-muted"><%= entry[:message] %></small>
|
|
112
114
|
</td>
|
|
113
115
|
<td>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Backfill `status` column for errors that were bulk-resolved before v0.6.3.
|
|
4
|
+
#
|
|
5
|
+
# Versions 0.6.0 through 0.6.2 had a bug in BatchResolveErrors that set
|
|
6
|
+
# `resolved: true` and `resolved_at` but skipped the `status` column. The
|
|
7
|
+
# errors-index "Resolved" filter pill queries `where(status: 'resolved')`,
|
|
8
|
+
# so bulk-resolved errors silently disappeared from that view even though
|
|
9
|
+
# they were marked resolved.
|
|
10
|
+
#
|
|
11
|
+
# This migration is idempotent: it only updates rows that are out-of-sync
|
|
12
|
+
# (resolved but not status='resolved'). Running it twice is a no-op.
|
|
13
|
+
#
|
|
14
|
+
# See: https://github.com/AnjanJ/rails_error_dashboard
|
|
15
|
+
class BackfillResolvedStatus < ActiveRecord::Migration[7.0]
|
|
16
|
+
def up
|
|
17
|
+
return unless table_exists?(:rails_error_dashboard_error_logs)
|
|
18
|
+
return unless column_exists?(:rails_error_dashboard_error_logs, :status)
|
|
19
|
+
return unless column_exists?(:rails_error_dashboard_error_logs, :resolved)
|
|
20
|
+
|
|
21
|
+
# Use ActiveRecord update_all so the count is portable across adapters
|
|
22
|
+
# (PostgreSQL, MySQL, SQLite all return the affected row count).
|
|
23
|
+
table = ActiveRecord::Base.connection.quote_table_name("rails_error_dashboard_error_logs")
|
|
24
|
+
klass = Class.new(ActiveRecord::Base) { self.table_name = "rails_error_dashboard_error_logs" }
|
|
25
|
+
updated = klass.where(resolved: true).where("status IS NULL OR status != ?", "resolved")
|
|
26
|
+
.update_all(status: "resolved")
|
|
27
|
+
|
|
28
|
+
say "Backfilled status='resolved' on #{updated} error log(s) that were bulk-resolved on v0.6.0–v0.6.2."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def down
|
|
32
|
+
# No-op: we cannot reliably distinguish errors that were bulk-resolved
|
|
33
|
+
# before the fix from errors that are legitimately resolved now. Leaving
|
|
34
|
+
# status='resolved' is the safe choice on rollback.
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -4,13 +4,14 @@ module RailsErrorDashboard
|
|
|
4
4
|
module Commands
|
|
5
5
|
# Command: Mute multiple errors at once
|
|
6
6
|
class BatchMuteErrors
|
|
7
|
-
def self.call(error_ids, muted_by: nil)
|
|
8
|
-
new(error_ids, muted_by).call
|
|
7
|
+
def self.call(error_ids, muted_by: nil, reason: nil)
|
|
8
|
+
new(error_ids, muted_by, reason).call
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
def initialize(error_ids, muted_by = nil)
|
|
11
|
+
def initialize(error_ids, muted_by = nil, reason = nil)
|
|
12
12
|
@error_ids = Array(error_ids).compact
|
|
13
13
|
@muted_by = muted_by
|
|
14
|
+
@reason = reason
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def call
|
|
@@ -27,7 +28,8 @@ module RailsErrorDashboard
|
|
|
27
28
|
error.update!(
|
|
28
29
|
muted: true,
|
|
29
30
|
muted_at: Time.current,
|
|
30
|
-
muted_by: @muted_by
|
|
31
|
+
muted_by: @muted_by,
|
|
32
|
+
muted_reason: @reason
|
|
31
33
|
)
|
|
32
34
|
muted_count += 1
|
|
33
35
|
muted_errors << error
|
|
@@ -31,7 +31,8 @@ module RailsErrorDashboard
|
|
|
31
31
|
resolved: true,
|
|
32
32
|
resolved_at: Time.current,
|
|
33
33
|
resolved_by_name: @resolved_by_name,
|
|
34
|
-
resolution_comment: @resolution_comment
|
|
34
|
+
resolution_comment: @resolution_comment,
|
|
35
|
+
status: "resolved"
|
|
35
36
|
)
|
|
36
37
|
resolved_count += 1
|
|
37
38
|
resolved_errors << error
|
|
@@ -89,7 +89,10 @@ module RailsErrorDashboard
|
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
def errors_by_hour
|
|
92
|
-
|
|
92
|
+
# group_by_hour_of_day buckets into 0..23 to show diurnal patterns
|
|
93
|
+
# (when in the day errors peak). The chart title says "Errors by Hour
|
|
94
|
+
# of Day" — group_by_hour produced a chronological time series instead.
|
|
95
|
+
base_query.group_by_hour_of_day(:occurred_at).count
|
|
93
96
|
end
|
|
94
97
|
|
|
95
98
|
def top_affected_users
|
|
@@ -37,11 +37,13 @@ module RailsErrorDashboard
|
|
|
37
37
|
versions.each_with_object({}) do |(version, count), result|
|
|
38
38
|
errors = base_query.where(app_version: version)
|
|
39
39
|
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
#
|
|
44
|
-
|
|
40
|
+
# Pluck error_types once; both the unique-types count and the critical
|
|
41
|
+
# count classify off this string array. Avoids loading full ErrorLog
|
|
42
|
+
# records into memory just to compute counts (severity is a Ruby-side
|
|
43
|
+
# method on error_type, not a column).
|
|
44
|
+
types_for_version = errors.pluck(:error_type)
|
|
45
|
+
error_types = types_for_version.uniq.size
|
|
46
|
+
critical_count = types_for_version.count { |t| Services::SeverityClassifier.classify(t) == :critical }
|
|
45
47
|
|
|
46
48
|
# Get platforms for this version
|
|
47
49
|
platforms = errors.distinct.pluck(:platform).compact
|
|
@@ -180,15 +182,17 @@ module RailsErrorDashboard
|
|
|
180
182
|
error_types = base_query.distinct.pluck(:error_type)
|
|
181
183
|
return {} if error_types.count < 2
|
|
182
184
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
185
|
+
# Bucket counts by (error_type, hour-of-day) in a single SQL GROUP BY.
|
|
186
|
+
# Groupdate's group_by_hour_of_day generates database-portable SQL
|
|
187
|
+
# (PG/MySQL/SQLite) so we don't load full ErrorLog records into Ruby.
|
|
188
|
+
# Result shape: { [hour_int, error_type] => count }
|
|
189
|
+
grouped = base_query
|
|
190
|
+
.group_by_hour_of_day(:occurred_at)
|
|
191
|
+
.group(:error_type)
|
|
192
|
+
.count
|
|
189
193
|
|
|
190
|
-
|
|
191
|
-
|
|
194
|
+
hourly_distributions = error_types.each_with_object({}) do |error_type, h|
|
|
195
|
+
h[error_type] = (0..23).map { |hour| grouped[[ hour, error_type ]] || 0 }
|
|
192
196
|
end
|
|
193
197
|
|
|
194
198
|
# Calculate correlation between error type pairs
|
|
@@ -30,6 +30,8 @@ module RailsErrorDashboard
|
|
|
30
30
|
query = filter_by_platform(query)
|
|
31
31
|
query = filter_by_application(query)
|
|
32
32
|
query = filter_by_user_id(query)
|
|
33
|
+
query = filter_by_app_version(query)
|
|
34
|
+
query = filter_by_git_sha(query)
|
|
33
35
|
query = filter_by_search(query)
|
|
34
36
|
query = filter_by_severity(query)
|
|
35
37
|
query = filter_by_timeframe(query)
|
|
@@ -44,6 +46,18 @@ module RailsErrorDashboard
|
|
|
44
46
|
query
|
|
45
47
|
end
|
|
46
48
|
|
|
49
|
+
def filter_by_app_version(query)
|
|
50
|
+
return query unless @filters[:app_version].present?
|
|
51
|
+
return query unless ErrorLog.column_names.include?("app_version")
|
|
52
|
+
query.where(app_version: @filters[:app_version])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def filter_by_git_sha(query)
|
|
56
|
+
return query unless @filters[:git_sha].present?
|
|
57
|
+
return query unless ErrorLog.column_names.include?("git_sha")
|
|
58
|
+
query.where(git_sha: @filters[:git_sha])
|
|
59
|
+
end
|
|
60
|
+
|
|
47
61
|
def filter_by_error_type(query)
|
|
48
62
|
return query unless @filters[:error_type].present?
|
|
49
63
|
|
|
@@ -28,27 +28,37 @@ module RailsErrorDashboard
|
|
|
28
28
|
def detect_cascades
|
|
29
29
|
return { detected: 0, updated: 0 } unless can_detect?
|
|
30
30
|
|
|
31
|
-
#
|
|
31
|
+
# Pluck (error_log_id, occurred_at) for every occurrence in the window
|
|
32
|
+
# ordered chronologically. Using pluck instead of loading full
|
|
33
|
+
# ActiveRecord rows keeps memory bounded to ~16 bytes/row instead of
|
|
34
|
+
# ~5KB/row, which matters because the host app schedules this job and
|
|
35
|
+
# the lookback window may contain a lot of occurrences.
|
|
32
36
|
start_time = @lookback_hours.hours.ago
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
rows = ErrorOccurrence
|
|
38
|
+
.where("occurred_at >= ?", start_time)
|
|
39
|
+
.order(:occurred_at)
|
|
40
|
+
.pluck(:error_log_id, :occurred_at)
|
|
41
|
+
|
|
42
|
+
# Two-pointer sweep: occurrences are time-sorted, so for each parent we
|
|
43
|
+
# only advance the child pointer forward through occurrences within the
|
|
44
|
+
# detection window. O(N + pairs) instead of O(N) inner SQL queries.
|
|
36
45
|
patterns_found = Hash.new { |h, k| h[k] = { delays: [], count: 0 } }
|
|
37
46
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
rows.each_with_index do |(parent_id, parent_time), i|
|
|
48
|
+
window_end = parent_time + DETECTION_WINDOW
|
|
49
|
+
j = i + 1
|
|
50
|
+
while j < rows.length
|
|
51
|
+
child_id, child_time = rows[j]
|
|
52
|
+
break if child_time > window_end
|
|
53
|
+
|
|
54
|
+
# Match the original SQL `occurred_at > parent` — strict, so two
|
|
55
|
+
# occurrences with identical timestamps don't form a cascade pair.
|
|
56
|
+
if child_id != parent_id && child_time > parent_time
|
|
57
|
+
key = [ parent_id, child_id ]
|
|
58
|
+
patterns_found[key][:delays] << (child_time - parent_time).to_f
|
|
59
|
+
patterns_found[key][:count] += 1
|
|
60
|
+
end
|
|
61
|
+
j += 1
|
|
52
62
|
end
|
|
53
63
|
end
|
|
54
64
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_error_dashboard
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anjan Jagirdar
|
|
@@ -355,6 +355,7 @@ files:
|
|
|
355
355
|
- db/migrate/20260323000001_add_muted_to_error_logs.rb
|
|
356
356
|
- db/migrate/20260325000001_fix_swallowed_exceptions_index_for_mysql.rb
|
|
357
357
|
- db/migrate/20260326000001_add_issue_tracking_to_error_logs.rb
|
|
358
|
+
- db/migrate/20260503000001_backfill_resolved_status.rb
|
|
358
359
|
- lib/generators/rails_error_dashboard/install/install_generator.rb
|
|
359
360
|
- lib/generators/rails_error_dashboard/install/templates/README
|
|
360
361
|
- lib/generators/rails_error_dashboard/install/templates/initializer.rb
|
|
@@ -497,7 +498,7 @@ metadata:
|
|
|
497
498
|
funding_uri: https://github.com/sponsors/AnjanJ
|
|
498
499
|
post_install_message: |
|
|
499
500
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
500
|
-
RED (Rails Error Dashboard) v0.6.
|
|
501
|
+
RED (Rails Error Dashboard) v0.6.4
|
|
501
502
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
502
503
|
|
|
503
504
|
First install:
|
|
@@ -533,7 +534,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
533
534
|
- !ruby/object:Gem::Version
|
|
534
535
|
version: '0'
|
|
535
536
|
requirements: []
|
|
536
|
-
rubygems_version:
|
|
537
|
+
rubygems_version: 3.6.9
|
|
537
538
|
specification_version: 4
|
|
538
539
|
summary: Self-hosted error tracking and exception monitoring for Rails. Free, forever.
|
|
539
540
|
test_files: []
|