rails_error_dashboard 0.5.7 → 0.5.8

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -1
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +57 -4
  4. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +192 -0
  5. data/app/jobs/rails_error_dashboard/add_issue_recurrence_comment_job.rb +71 -0
  6. data/app/jobs/rails_error_dashboard/close_linked_issue_job.rb +43 -0
  7. data/app/jobs/rails_error_dashboard/create_issue_job.rb +68 -0
  8. data/app/jobs/rails_error_dashboard/reopen_linked_issue_job.rb +44 -0
  9. data/app/models/rails_error_dashboard/error_log.rb +2 -1
  10. data/app/views/layouts/rails_error_dashboard.html.erb +19 -6
  11. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +61 -102
  12. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +67 -0
  13. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +148 -0
  14. data/app/views/rails_error_dashboard/errors/show.html.erb +3 -1
  15. data/config/routes.rb +7 -1
  16. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +5 -0
  17. data/db/migrate/20260326000001_add_issue_tracking_to_error_logs.rb +15 -0
  18. data/lib/generators/rails_error_dashboard/install/install_generator.rb +12 -4
  19. data/lib/rails_error_dashboard/commands/create_issue.rb +59 -0
  20. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +65 -0
  21. data/lib/rails_error_dashboard/configuration.rb +99 -0
  22. data/lib/rails_error_dashboard/engine.rb +39 -0
  23. data/lib/rails_error_dashboard/queries/active_storage_summary.rb +101 -0
  24. data/lib/rails_error_dashboard/services/codeberg_issue_client.rb +99 -0
  25. data/lib/rails_error_dashboard/services/github_issue_client.rb +94 -0
  26. data/lib/rails_error_dashboard/services/github_link_generator.rb +19 -1
  27. data/lib/rails_error_dashboard/services/gitlab_issue_client.rb +98 -0
  28. data/lib/rails_error_dashboard/services/issue_body_formatter.rb +132 -0
  29. data/lib/rails_error_dashboard/services/issue_tracker_client.rb +162 -0
  30. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +12 -0
  31. data/lib/rails_error_dashboard/subscribers/active_storage_subscriber.rb +112 -0
  32. data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +71 -0
  33. data/lib/rails_error_dashboard/version.rb +1 -1
  34. data/lib/rails_error_dashboard.rb +11 -1
  35. metadata +20 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0cfea703e2d74b6771503d098e3bc5f048ec781923ee08a1692e34bbf28c9734
4
- data.tar.gz: 4b77fb5c2dd00920d2eaca85b92d35dcbb19282408dee936584deffd2e7126a6
3
+ metadata.gz: dc2ad274b6cf17149a3d60eecb3c6cde4280379e8c74c870be26530641267f8b
4
+ data.tar.gz: b8bb6d9e448631bb294663b73d33379c7da33e0cba5ab97d79949b1860070cc3
5
5
  SHA512:
6
- metadata.gz: 1bcc5f0f868d3e864deccdfbe23a0a3c3e1da03f3cfb49208724f61deceaf7047bba7c1d3f19358a75e1a254336059894a55bec79656f67e55217eec18c34d19
7
- data.tar.gz: 810aa6c9a5db5300d4f6f5964ce92aa06dada34dfc7b513e782a914de8a3f0baf7e35b8b03056b9a1060b018b8dca0d6b51534f528211e779c008a77060fa10d
6
+ metadata.gz: 2f1b87689dc7d3be38a036ead3d2e63236265ebe6a7135503e6cff3231a97f87b5203bc9f6b8da86e23735401f26616688adbe43bf2e225f6f3fce6cd808f0d3
7
+ data.tar.gz: da68e7b4b68b14f9a8dd349f7c5ccd0704844a4da2d77f97556e9cf3ebda1577d59ba54acb844a3d8de59704c16bf494065e6d23d83abfee07b6ca1a28b7afd8
data/README.md CHANGED
@@ -131,15 +131,41 @@ Requires breadcrumbs to be enabled.
131
131
 
132
132
  ![Cache Health](docs/images/cache-health.png)
133
133
 
134
- **ActionCable Health** — Track WebSocket channel actions, transmissions, subscription confirmations, and rejections. Dashboard page at `/errors/actioncable_health_summary` with channel breakdown sorted by rejections. System health snapshot captures live connection count and adapter. No error tracker surfaces this alongside HTTP errors.
134
+ **ActionCable Health** — Track WebSocket channel actions, transmissions, subscription confirmations, and rejections. Dashboard page at `/errors/actioncable_health_summary` with channel breakdown sorted by rejections. System health snapshot captures live connection count and adapter.
135
135
 
136
136
  ```ruby
137
137
  config.enable_actioncable_tracking = true # requires enable_breadcrumbs = true
138
138
  ```
139
139
 
140
+ **ActiveStorage Health** — Track file uploads, downloads, deletes, and existence checks across storage services (Disk, S3, GCS, Azure — any ActiveStorage backend). Dashboard page at `/errors/activestorage_health_summary` with per-service operation counts, average and slowest durations. Helps identify slow storage operations correlating with errors.
141
+
142
+ ```ruby
143
+ config.enable_activestorage_tracking = true # requires enable_breadcrumbs = true
144
+ ```
145
+
140
146
  [Complete documentation →](docs/FEATURES.md#job-health-page)
141
147
  </details>
142
148
 
149
+ <details>
150
+ <summary><strong>Issue Tracking — GitHub, GitLab, Codeberg</strong></summary>
151
+
152
+ Create, link, and manage issues directly from error detail pages. Supports GitHub, GitLab, and Codeberg/Gitea/Forgejo with provider auto-detection.
153
+
154
+ - **Manual:** "Create Issue" button + "Link Existing Issue" URL input
155
+ - **Auto-create:** On first occurrence and/or severity threshold — configurable
156
+ - **Lifecycle sync:** Resolve → close issue, recur → reopen + comment (throttled)
157
+ - **Two-way webhooks:** Issue closed/reopened on platform syncs to dashboard
158
+ - **RED branding:** Issues show "Created by RED (Rails Error Dashboard)"
159
+
160
+ ```ruby
161
+ config.enable_issue_tracking = true
162
+ config.issue_tracker_token = ENV["RED_BOT_TOKEN"]
163
+ # Provider and repo auto-detected from git_repository_url
164
+ ```
165
+
166
+ [Complete documentation →](docs/guides/CONFIGURATION.md)
167
+ </details>
168
+
143
169
  <details>
144
170
  <summary><strong>Source Code Integration + Git Blame</strong></summary>
145
171
 
@@ -75,7 +75,7 @@ module RailsErrorDashboard
75
75
 
76
76
  def show
77
77
  # Eagerly load associations to avoid N+1 queries
78
- # - comments: Used in the comments section (@error.comments.count, @error.comments.recent_first)
78
+ # - comments: Audit trail (workflow comments from snooze/mute/status changes)
79
79
  # - parent_cascade_patterns/child_cascade_patterns: Used if cascade detection is enabled
80
80
  @error = ErrorLog.includes(:comments, :parent_cascade_patterns, :child_cascade_patterns).find(params[:id])
81
81
  @related_errors = @error.related_errors(limit: 5, application_id: @current_application_id)
@@ -139,9 +139,41 @@ module RailsErrorDashboard
139
139
  redirect_to error_path(result[:error])
140
140
  end
141
141
 
142
- def add_comment
143
- @error = Commands::AddErrorComment.call(params[:id], author_name: params[:author_name], body: params[:body])
144
- redirect_to error_path(@error)
142
+ def create_issue
143
+ dashboard_url = error_url(params[:id])
144
+ result = Commands::CreateIssue.call(params[:id], dashboard_url: dashboard_url)
145
+
146
+ if result[:success]
147
+ flash[:notice] = "Issue created: #{result[:issue_url]}"
148
+ else
149
+ flash[:alert] = "Failed to create issue: #{result[:error]}"
150
+ end
151
+ redirect_to error_path(params[:id])
152
+ end
153
+
154
+ def link_issue
155
+ result = Commands::LinkExistingIssue.call(params[:id], issue_url: params[:issue_url])
156
+
157
+ if result[:success]
158
+ flash[:notice] = "Issue linked successfully"
159
+ else
160
+ flash[:alert] = "Failed to link issue: #{result[:error]}"
161
+ end
162
+ redirect_to error_path(params[:id])
163
+ end
164
+
165
+ def unlink_issue
166
+ error = ErrorLog.find(params[:id])
167
+ error.update!(
168
+ external_issue_url: nil,
169
+ external_issue_number: nil,
170
+ external_issue_provider: nil
171
+ )
172
+ flash[:notice] = "Issue unlinked"
173
+ redirect_to error_path(error)
174
+ rescue => e
175
+ flash[:alert] = "Failed to unlink issue: #{e.message}"
176
+ redirect_to error_path(params[:id])
145
177
  end
146
178
 
147
179
  def analytics
@@ -432,6 +464,27 @@ module RailsErrorDashboard
432
464
  @pagy, @channels = pagy(:offset, all_channels, limit: params[:per_page] || 25)
433
465
  end
434
466
 
467
+ def activestorage_health_summary
468
+ unless RailsErrorDashboard.configuration.enable_activestorage_tracking &&
469
+ RailsErrorDashboard.configuration.enable_breadcrumbs
470
+ flash[:alert] = "ActiveStorage tracking is not enabled. Enable enable_activestorage_tracking and enable_breadcrumbs in config/initializers/rails_error_dashboard.rb"
471
+ redirect_to errors_path
472
+ return
473
+ end
474
+
475
+ days = (params[:days] || 30).to_i
476
+ @days = days
477
+ result = Queries::ActiveStorageSummary.call(days, application_id: @current_application_id)
478
+ all_services = result[:services]
479
+
480
+ # Summary stats (computed before pagination)
481
+ @unique_services = all_services.size
482
+ @total_operations = all_services.sum { |s| s[:total_operations] }
483
+ @errors_with_storage = all_services.sum { |s| s[:error_count] }
484
+
485
+ @pagy, @services = pagy(:offset, all_services, limit: params[:per_page] || 25)
486
+ end
487
+
435
488
  def diagnostic_dumps
436
489
  unless RailsErrorDashboard.configuration.enable_diagnostic_dump
437
490
  flash[:alert] = "Diagnostic dumps are not enabled. Enable them in config/initializers/rails_error_dashboard.rb"
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Receives webhooks from GitHub/GitLab/Codeberg for two-way issue sync.
5
+ #
6
+ # When an issue is closed/reopened on the platform, the corresponding
7
+ # error in the dashboard is resolved/reopened to match.
8
+ #
9
+ # Security: HMAC signature verification for each provider.
10
+ # - GitHub: X-Hub-Signature-256 (HMAC-SHA256)
11
+ # - GitLab: X-Gitlab-Token (shared secret)
12
+ # - Codeberg: X-Gitea-Signature (HMAC-SHA256)
13
+ class WebhooksController < ActionController::Base
14
+ skip_before_action :verify_authenticity_token
15
+
16
+ before_action :verify_webhook_enabled
17
+ before_action :verify_signature
18
+
19
+ def receive
20
+ provider = params[:provider]
21
+ payload = parse_payload
22
+
23
+ return head :ok unless payload
24
+
25
+ case provider
26
+ when "github"
27
+ handle_github(payload)
28
+ when "gitlab"
29
+ handle_gitlab(payload)
30
+ when "codeberg"
31
+ handle_codeberg(payload)
32
+ else
33
+ head :not_found
34
+ return
35
+ end
36
+
37
+ head :ok
38
+ rescue => e
39
+ Rails.logger.error("[RailsErrorDashboard] Webhook error: #{e.class}: #{e.message}")
40
+ head :ok # Always return 200 to prevent webhook retries on our errors
41
+ end
42
+
43
+ private
44
+
45
+ def verify_webhook_enabled
46
+ unless RailsErrorDashboard.configuration.enable_issue_webhooks
47
+ head :not_found
48
+ end
49
+ end
50
+
51
+ def verify_signature
52
+ provider = params[:provider]
53
+ secret = RailsErrorDashboard.configuration.issue_webhook_secret
54
+
55
+ unless secret.present?
56
+ Rails.logger.warn("[RailsErrorDashboard] Webhook received but no issue_webhook_secret configured")
57
+ head :unauthorized
58
+ return
59
+ end
60
+
61
+ body = request.body.read
62
+ request.body.rewind
63
+
64
+ verified = case provider
65
+ when "github"
66
+ verify_github_signature(body, secret)
67
+ when "gitlab"
68
+ verify_gitlab_token(secret)
69
+ when "codeberg"
70
+ verify_codeberg_signature(body, secret)
71
+ else
72
+ false
73
+ end
74
+
75
+ head :unauthorized unless verified
76
+ end
77
+
78
+ def verify_github_signature(body, secret)
79
+ signature = request.headers["X-Hub-Signature-256"]
80
+ return false unless signature
81
+
82
+ expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, body)
83
+ ActiveSupport::SecurityUtils.secure_compare(expected, signature)
84
+ end
85
+
86
+ def verify_gitlab_token(secret)
87
+ token = request.headers["X-Gitlab-Token"]
88
+ return false unless token
89
+
90
+ ActiveSupport::SecurityUtils.secure_compare(secret, token)
91
+ end
92
+
93
+ def verify_codeberg_signature(body, secret)
94
+ signature = request.headers["X-Gitea-Signature"]
95
+ return false unless signature
96
+
97
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
98
+ ActiveSupport::SecurityUtils.secure_compare(expected, signature)
99
+ end
100
+
101
+ def parse_payload
102
+ JSON.parse(request.body.read)
103
+ rescue JSON::ParserError
104
+ nil
105
+ ensure
106
+ request.body.rewind
107
+ end
108
+
109
+ # GitHub: issues webhook fires with action: opened/closed/reopened
110
+ def handle_github(payload)
111
+ return unless payload["action"].in?(%w[closed reopened])
112
+
113
+ issue_number = payload.dig("issue", "number")
114
+ return unless issue_number
115
+
116
+ error = find_error_by_issue(issue_number, "github")
117
+ return unless error
118
+
119
+ case payload["action"]
120
+ when "closed"
121
+ resolve_error(error, "Closed on GitHub by #{payload.dig("sender", "login")}")
122
+ when "reopened"
123
+ reopen_error(error)
124
+ end
125
+ end
126
+
127
+ # GitLab: issue webhook fires with object_attributes.action
128
+ def handle_gitlab(payload)
129
+ action = payload.dig("object_attributes", "action")
130
+ return unless action.in?(%w[close reopen])
131
+
132
+ issue_iid = payload.dig("object_attributes", "iid")
133
+ return unless issue_iid
134
+
135
+ error = find_error_by_issue(issue_iid, "gitlab")
136
+ return unless error
137
+
138
+ case action
139
+ when "close"
140
+ resolve_error(error, "Closed on GitLab by #{payload.dig("user", "username")}")
141
+ when "reopen"
142
+ reopen_error(error)
143
+ end
144
+ end
145
+
146
+ # Codeberg/Gitea/Forgejo: issue webhook fires with action
147
+ def handle_codeberg(payload)
148
+ return unless payload["action"].in?(%w[closed reopened])
149
+
150
+ issue_number = payload.dig("issue", "number")
151
+ return unless issue_number
152
+
153
+ error = find_error_by_issue(issue_number, "codeberg")
154
+ return unless error
155
+
156
+ case payload["action"]
157
+ when "closed"
158
+ resolve_error(error, "Closed on Codeberg by #{payload.dig("sender", "login")}")
159
+ when "reopened"
160
+ reopen_error(error)
161
+ end
162
+ end
163
+
164
+ def find_error_by_issue(issue_number, provider)
165
+ ErrorLog.find_by(
166
+ external_issue_number: issue_number,
167
+ external_issue_provider: provider
168
+ )
169
+ end
170
+
171
+ def resolve_error(error, message)
172
+ return if error.resolved?
173
+
174
+ Commands::ResolveError.call(
175
+ error.id,
176
+ resolved_by_name: "Webhook",
177
+ resolution_comment: message
178
+ )
179
+ end
180
+
181
+ def reopen_error(error)
182
+ return unless error.resolved?
183
+
184
+ error.update!(
185
+ resolved: false,
186
+ resolved_at: nil,
187
+ status: "new",
188
+ reopened_at: Time.current
189
+ )
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Background job to add a recurrence comment on a linked issue.
5
+ #
6
+ # Triggered via :on_error_recurred plugin hook.
7
+ # Throttled: max 1 comment per hour per error to prevent spam on
8
+ # high-frequency errors.
9
+ class AddIssueRecurrenceCommentJob < ApplicationJob
10
+ queue_as :error_notifications
11
+
12
+ retry_on StandardError, wait: :polynomially_longer, attempts: 2
13
+ discard_on ActiveRecord::RecordNotFound
14
+
15
+ THROTTLE_INTERVAL = 3600 # 1 hour
16
+
17
+ # Track last comment time per error to throttle
18
+ @@last_comment_at = {}
19
+
20
+ def perform(error_log_id)
21
+ # Throttle: max 1 comment per hour per error
22
+ if throttled?(error_log_id)
23
+ Rails.logger.debug("[RailsErrorDashboard] Skipping recurrence comment for error #{error_log_id} — throttled")
24
+ return
25
+ end
26
+
27
+ error = ErrorLog.find(error_log_id)
28
+ return unless error.external_issue_url.present? && error.external_issue_number.present?
29
+
30
+ client = Services::IssueTrackerClient.from_config
31
+ return unless client
32
+
33
+ comment = "Error occurred again (#{error.occurrence_count} total occurrences).\n\n"
34
+ comment += "- **Last seen:** #{error.last_seen_at&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC")}\n"
35
+ comment += "- **First seen:** #{error.first_seen_at&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC")}"
36
+ comment += "\n\n---\n*[RED](https://github.com/AnjanJ/rails_error_dashboard) (Rails Error Dashboard)*"
37
+
38
+ result = client.add_comment(number: error.external_issue_number, body: comment)
39
+
40
+ if result[:success]
41
+ record_comment(error_log_id)
42
+ Rails.logger.info("[RailsErrorDashboard] Added recurrence comment on issue ##{error.external_issue_number}")
43
+ else
44
+ Rails.logger.error("[RailsErrorDashboard] Failed to add recurrence comment: #{result[:error]}")
45
+ end
46
+ rescue ActiveRecord::RecordNotFound
47
+ # Error was deleted
48
+ rescue => e
49
+ Rails.logger.error("[RailsErrorDashboard] AddIssueRecurrenceCommentJob failed: #{e.class}: #{e.message}")
50
+ raise # retry
51
+ end
52
+
53
+ private
54
+
55
+ def throttled?(error_log_id)
56
+ last = @@last_comment_at[error_log_id]
57
+ last && (Time.current - last) < THROTTLE_INTERVAL
58
+ end
59
+
60
+ def record_comment(error_log_id)
61
+ @@last_comment_at[error_log_id] = Time.current
62
+ # Cleanup old entries to prevent memory growth
63
+ cleanup_stale_entries if @@last_comment_at.size > 1000
64
+ end
65
+
66
+ def cleanup_stale_entries
67
+ cutoff = Time.current - THROTTLE_INTERVAL
68
+ @@last_comment_at.reject! { |_, t| t < cutoff }
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Background job to close a linked issue when an error is resolved.
5
+ #
6
+ # Triggered via :on_error_resolved plugin hook.
7
+ # Adds a comment before closing: "Resolved in Rails Error Dashboard by {name}"
8
+ class CloseLinkedIssueJob < ApplicationJob
9
+ queue_as :error_notifications
10
+
11
+ retry_on StandardError, wait: :polynomially_longer, attempts: 2
12
+ discard_on ActiveRecord::RecordNotFound
13
+
14
+ def perform(error_log_id)
15
+ error = ErrorLog.find(error_log_id)
16
+ return unless error.external_issue_url.present? && error.external_issue_number.present?
17
+
18
+ client = Services::IssueTrackerClient.from_config
19
+ return unless client
20
+
21
+ # Add resolution comment
22
+ resolved_by = error.resolved_by_name.presence || "a team member"
23
+ comment = "**Resolved** by #{resolved_by}."
24
+ comment += "\n\nResolution: #{error.resolution_comment}" if error.resolution_comment.present?
25
+ comment += "\n\n---\n*[RED](https://github.com/AnjanJ/rails_error_dashboard) (Rails Error Dashboard)*"
26
+
27
+ client.add_comment(number: error.external_issue_number, body: comment)
28
+
29
+ # Close the issue
30
+ result = client.close_issue(number: error.external_issue_number)
31
+ if result[:success]
32
+ Rails.logger.info("[RailsErrorDashboard] Closed issue ##{error.external_issue_number} for error #{error_log_id}")
33
+ else
34
+ Rails.logger.error("[RailsErrorDashboard] Failed to close issue ##{error.external_issue_number}: #{result[:error]}")
35
+ end
36
+ rescue ActiveRecord::RecordNotFound
37
+ # Error was deleted
38
+ rescue => e
39
+ Rails.logger.error("[RailsErrorDashboard] CloseLinkedIssueJob failed: #{e.class}: #{e.message}")
40
+ raise # retry
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Background job for creating issues on GitHub/GitLab/Codeberg.
5
+ #
6
+ # Used by auto-create (triggered from plugin hooks) and can be
7
+ # called directly for deferred issue creation.
8
+ #
9
+ # Retry strategy: 3 attempts with exponential backoff.
10
+ # Circuit breaker: skips if >5 failures in the last 10 minutes
11
+ # (tracked via class-level counter, reset on success).
12
+ class CreateIssueJob < ApplicationJob
13
+ queue_as :error_notifications
14
+
15
+ retry_on StandardError, wait: :polynomially_longer, attempts: 3
16
+ discard_on ActiveRecord::RecordNotFound
17
+
18
+ # Simple circuit breaker — class-level failure tracking
19
+ @@recent_failures = []
20
+ CIRCUIT_BREAKER_THRESHOLD = 5
21
+ CIRCUIT_BREAKER_WINDOW = 600 # 10 minutes
22
+
23
+ def perform(error_log_id, dashboard_url: nil)
24
+ if circuit_open?
25
+ Rails.logger.warn("[RailsErrorDashboard] CreateIssueJob circuit breaker open — skipping")
26
+ return
27
+ end
28
+
29
+ result = Commands::CreateIssue.call(error_log_id, dashboard_url: dashboard_url)
30
+
31
+ if result[:success]
32
+ record_success
33
+ Rails.logger.info("[RailsErrorDashboard] Issue created for error #{error_log_id}: #{result[:issue_url]}")
34
+ else
35
+ record_failure
36
+ Rails.logger.error("[RailsErrorDashboard] Failed to create issue for error #{error_log_id}: #{result[:error]}")
37
+ # Don't retry on "already has a linked issue" or "not configured"
38
+ return if result[:error]&.include?("already has") || result[:error]&.include?("not configured")
39
+ raise "Issue creation failed: #{result[:error]}" # triggers retry
40
+ end
41
+ rescue ActiveRecord::RecordNotFound
42
+ # Error was deleted — discard silently
43
+ rescue => e
44
+ record_failure
45
+ raise # re-raise for retry
46
+ end
47
+
48
+ private
49
+
50
+ def circuit_open?
51
+ cleanup_old_failures
52
+ @@recent_failures.size >= CIRCUIT_BREAKER_THRESHOLD
53
+ end
54
+
55
+ def record_failure
56
+ @@recent_failures << Time.current
57
+ end
58
+
59
+ def record_success
60
+ @@recent_failures.clear
61
+ end
62
+
63
+ def cleanup_old_failures
64
+ cutoff = Time.current - CIRCUIT_BREAKER_WINDOW
65
+ @@recent_failures.reject! { |t| t < cutoff }
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Background job to reopen a linked issue when an error recurs after resolution.
5
+ #
6
+ # Triggered via :on_error_reopened plugin hook.
7
+ # Adds a comment: "Error recurred — reopened automatically. Occurrence #{count}"
8
+ class ReopenLinkedIssueJob < ApplicationJob
9
+ queue_as :error_notifications
10
+
11
+ retry_on StandardError, wait: :polynomially_longer, attempts: 2
12
+ discard_on ActiveRecord::RecordNotFound
13
+
14
+ def perform(error_log_id)
15
+ error = ErrorLog.find(error_log_id)
16
+ return unless error.external_issue_url.present? && error.external_issue_number.present?
17
+
18
+ client = Services::IssueTrackerClient.from_config
19
+ return unless client
20
+
21
+ # Reopen the issue
22
+ result = client.reopen_issue(number: error.external_issue_number)
23
+
24
+ # Add recurrence comment
25
+ comment = "**Reopened** — error recurred.\n\n"
26
+ comment += "- **Occurrences:** #{error.occurrence_count}\n"
27
+ comment += "- **Last seen:** #{error.last_seen_at&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC")}"
28
+ comment += "\n\n---\n*[RED](https://github.com/AnjanJ/rails_error_dashboard) (Rails Error Dashboard)*"
29
+
30
+ client.add_comment(number: error.external_issue_number, body: comment)
31
+
32
+ if result[:success]
33
+ Rails.logger.info("[RailsErrorDashboard] Reopened issue ##{error.external_issue_number} for error #{error_log_id}")
34
+ else
35
+ Rails.logger.error("[RailsErrorDashboard] Failed to reopen issue ##{error.external_issue_number}: #{result[:error]}")
36
+ end
37
+ rescue ActiveRecord::RecordNotFound
38
+ # Error was deleted
39
+ rescue => e
40
+ Rails.logger.error("[RailsErrorDashboard] ReopenLinkedIssueJob failed: #{e.class}: #{e.message}")
41
+ raise # retry
42
+ end
43
+ end
44
+ end
@@ -37,7 +37,8 @@ module RailsErrorDashboard
37
37
  # Association for tracking individual error occurrences
38
38
  has_many :error_occurrences, class_name: "RailsErrorDashboard::ErrorOccurrence", dependent: :destroy
39
39
 
40
- # Association for comment threads (Phase 3: Workflow Integration)
40
+ # Comments used as internal audit trail for workflow actions (snooze, mute, status changes).
41
+ # Manual comment form removed in v0.6 — discussion now lives on issue tracker.
41
42
  has_many :comments, class_name: "RailsErrorDashboard::ErrorComment", foreign_key: :error_log_id, dependent: :destroy
42
43
 
43
44
  # Cascade pattern associations
@@ -1,7 +1,7 @@
1
1
  <!DOCTYPE html>
2
2
  <html>
3
3
  <head>
4
- <title><%= content_for?(:page_title) ? "#{content_for(:page_title)} | " : "" %><%= Rails.application.class.module_parent_name %> - Error Dashboard</title>
4
+ <title><%= content_for?(:page_title) ? "#{content_for(:page_title)} | " : "" %><%= Rails.application.class.module_parent_name %> - RED</title>
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <meta name="turbo-visit-control" content="reload">
7
7
  <% if respond_to?(:csrf_meta_tags) %>
@@ -1547,18 +1547,24 @@ body.dark-mode .sidebar-section-body { background: var(--ctp-mantle); }
1547
1547
  end
1548
1548
  %>
1549
1549
  <% if has_root %>
1550
- <a class="navbar-brand" href="<%= root_url %>" title="Back to <%= Rails.application.class.module_parent_name %>">
1550
+ <a class="navbar-brand d-flex align-items-center" href="<%= root_url %>" title="Back to <%= Rails.application.class.module_parent_name %>">
1551
1551
  <i class="bi bi-bug-fill"></i>
1552
1552
  <span class="d-none d-sm-inline"><%= Rails.application.class.module_parent_name %></span>
1553
1553
  <span class="d-none d-md-inline text-white-50 mx-2">|</span>
1554
- <span class="d-none d-md-inline">Error Dashboard</span>
1554
+ <span class="d-none d-md-inline">
1555
+ <strong>RED</strong>
1556
+ <small class="text-white-50 ms-1" style="font-size: 0.65em; vertical-align: middle;">Rails Error Dashboard</small>
1557
+ </span>
1555
1558
  </a>
1556
1559
  <% else %>
1557
- <span class="navbar-brand">
1560
+ <span class="navbar-brand d-flex align-items-center">
1558
1561
  <i class="bi bi-bug-fill"></i>
1559
1562
  <span class="d-none d-sm-inline"><%= Rails.application.class.module_parent_name %></span>
1560
1563
  <span class="d-none d-md-inline text-white-50 mx-2">|</span>
1561
- <span class="d-none d-md-inline">Error Dashboard</span>
1564
+ <span class="d-none d-md-inline">
1565
+ <strong>RED</strong>
1566
+ <small class="text-white-50 ms-1" style="font-size: 0.65em; vertical-align: middle;">Rails Error Dashboard</small>
1567
+ </span>
1562
1568
  </span>
1563
1569
  <% end %>
1564
1570
  </div>
@@ -1681,6 +1687,13 @@ body.dark-mode .sidebar-section-body { background: var(--ctp-mantle); }
1681
1687
  <% end %>
1682
1688
  </li>
1683
1689
  <% end %>
1690
+ <% if RailsErrorDashboard.configuration.enable_activestorage_tracking && RailsErrorDashboard.configuration.enable_breadcrumbs %>
1691
+ <li class="nav-item">
1692
+ <%= link_to activestorage_health_summary_errors_path(nav_params), class: "nav-link #{request.path == activestorage_health_summary_errors_path ? 'active' : ''}" do %>
1693
+ <i class="bi bi-cloud-arrow-up"></i> ActiveStorage
1694
+ <% end %>
1695
+ </li>
1696
+ <% end %>
1684
1697
  <% if RailsErrorDashboard.configuration.enable_system_health %>
1685
1698
  <li class="nav-item">
1686
1699
  <%= link_to job_health_summary_errors_path(nav_params), class: "nav-link #{request.path == job_health_summary_errors_path ? 'active' : ''}" do %>
@@ -1820,7 +1833,7 @@ body.dark-mode .sidebar-section-body { background: var(--ctp-mantle); }
1820
1833
  <div class="container">
1821
1834
  <p class="text-muted mb-1">
1822
1835
  <i class="bi bi-bug-fill text-primary"></i>
1823
- Built with <a href="https://github.com/AnjanJ/rails_error_dashboard" target="_blank" class="text-decoration-none">Rails Error Dashboard</a>
1836
+ Built with <a href="https://github.com/AnjanJ/rails_error_dashboard" target="_blank" class="text-decoration-none"><strong>RED</strong> — Rails Error Dashboard</a>
1824
1837
  </p>
1825
1838
  <p class="text-muted small mb-0">
1826
1839
  Created by <a href="https://www.anjan.dev/" target="_blank" class="text-decoration-none">Anjan Jagirdar</a>