error_radar 0.2.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2914a57eb370a3b003c69d8ab3e9899826dade79b35f62bffc97e8fed40748bf
4
- data.tar.gz: f0f0772a121489558626b8780c1d10f3d1d08fb1e71b067c0a422c5da5eab709
3
+ metadata.gz: 131362a3ccb1bd3de4242f28690bb60059555bee0357ff488fbcce86f9b04f37
4
+ data.tar.gz: 8f0218c89a83e54e514657dd1c7f9e25a28c39d0adfe284424efdb134bc58467
5
5
  SHA512:
6
- metadata.gz: e9ddda42dc56d8512da27db0fa371639b08925af149200fd4a016904eb2fdc2ee8fc7d4febb5b008db2b1f3e3b689acda1c068c1883d55f38fa833201346cb12
7
- data.tar.gz: e76130935b55369dbb16d839a3d4e29fbe6c2cb28390915ffc43cee00bd8bb9c5ebc1585cecbbd656cfaf2a22d91465eb41e8529ee17ba91fae86023cf89563d
6
+ metadata.gz: 43ade7e6e0d65e66af9cc34bfa3ee6768fdd9cbad0d79772df01ea6f355b7647f1e5c7a8d8be4667a80abfa5e1c383becc4c97651709c21df2e58fb65a2fede0
7
+ data.tar.gz: ec4cf4f0863aa19ffa5639659b6a61642921d72462130caaff18a6c2bb2f9424594d9ebbab96dc65c974fab1e4b00b91db96573c3434949ec769ab7b46ef2918
data/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.0] - 2026-07-03
6
+
7
+ ### Added
8
+ - **Zero-config auto-capture for ActiveJob**: any ActiveJob subclass (regardless
9
+ of queue adapter) now has exceptions captured automatically via
10
+ `ActiveSupport.on_load(:active_job)`. No need to include the module manually.
11
+ Disable with `config.install_active_job = false` (e.g. when using Sidekiq only).
12
+ - **Zero-config auto-capture for Rake tasks**: patches `Rake::Task#execute` so
13
+ every task failure is recorded automatically. Disable with
14
+ `config.install_rake = false`.
15
+ - **Full error list page** at `/errors` with pagination (50 per page),
16
+ full-text search (message / error class / source), filter by status /
17
+ severity / category / date range, sortable columns, and bulk actions
18
+ (resolve / ignore / reopen / delete).
19
+ - **Bulk actions**: select individual rows or all rows, then resolve, ignore,
20
+ reopen, or delete in one click.
21
+ - **Quick-action buttons** on the list (✓ resolve, ✕ ignore) without leaving
22
+ the page.
23
+ - **Improved error detail page**: context and backtrace displayed side-by-side,
24
+ inline resolution note field, delete button with confirmation.
25
+ - **Navigation bar** across all dashboard pages linking Dashboard ↔ All Errors.
26
+
27
+ ### Changed
28
+ - `show` and `update_status` actions moved from `DashboardController` to the
29
+ new `ErrorsController`; route paths (`/errors/:id` etc.) are unchanged.
30
+ - Dashboard index h1 changed from "🛰 Error Radar" to "Dashboard" (branding
31
+ now lives in the shared nav bar).
32
+
5
33
  ## [0.2.0] - 2026-07-02
6
34
 
7
35
  ### Added
@@ -5,7 +5,6 @@ module ErrorRadar
5
5
  # kanban board over ErrorLog statuses.
6
6
  class DashboardController < ApplicationController
7
7
  before_action :authenticate_request!
8
- before_action :set_error, only: %i[show update_status]
9
8
 
10
9
  KANBAN_LIMIT = 100
11
10
  SEVERITY_ORDER = { 'critical' => 0, 'error' => 1, 'warning' => 2, 'info' => 3 }.freeze
@@ -47,29 +46,8 @@ module ErrorRadar
47
46
  @external_links = build_external_links
48
47
  end
49
48
 
50
- def show; end
51
-
52
- def update_status
53
- new_status = params[:status].to_s
54
- unless ErrorLog.statuses.key?(new_status)
55
- return render json: { ok: false, error: 'invalid status' }, status: :unprocessable_entity
56
- end
57
-
58
- if new_status == 'resolved'
59
- @error.resolve!(by: error_radar_current_user)
60
- else
61
- @error.update!(status: new_status, resolved_at: nil)
62
- end
63
-
64
- render json: { ok: true, id: @error.id, status: @error.status }
65
- end
66
-
67
49
  private
68
50
 
69
- def set_error
70
- @error = ErrorLog.find(params[:id])
71
- end
72
-
73
51
  def build_external_links
74
52
  links = {}
75
53
  if defined?(::RailsAdmin)
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErrorRadar
4
+ class ErrorsController < ApplicationController
5
+ before_action :authenticate_request!
6
+ before_action :set_error, only: %i[show update_status destroy]
7
+
8
+ rescue_from ActiveRecord::RecordNotFound do
9
+ redirect_to errors_path, alert: 'Error not found.'
10
+ end
11
+
12
+ PER_PAGE = 50
13
+
14
+ def index
15
+ scope = build_scope
16
+ @total_count = scope.count
17
+ @page = [params[:page].to_i, 1].max
18
+ @total_pages = [(@total_count.to_f / PER_PAGE).ceil, 1].max
19
+ @page = [@page, @total_pages].min if @total_pages > 0
20
+ @errors = scope.limit(PER_PAGE).offset((@page - 1) * PER_PAGE)
21
+ @filter_params = active_filter_params
22
+ @pages = pagination_pages(@page, @total_pages)
23
+ end
24
+
25
+ def show; end
26
+
27
+ def update_status
28
+ new_status = params[:status].to_s
29
+ unless ErrorLog.statuses.key?(new_status)
30
+ return render json: { ok: false, error: 'invalid status' }, status: :unprocessable_entity
31
+ end
32
+
33
+ if new_status == 'resolved'
34
+ @error.resolve!(by: error_radar_current_user, note: params[:note].presence)
35
+ else
36
+ @error.update!(status: new_status, resolved_at: nil)
37
+ end
38
+
39
+ render json: { ok: true, id: @error.id, status: @error.status }
40
+ end
41
+
42
+ def destroy
43
+ @error.destroy!
44
+ render json: { ok: true }
45
+ end
46
+
47
+ def bulk
48
+ ids = Array(params[:ids]).map(&:to_i).select(&:positive?)
49
+ action = params[:bulk_action].to_s
50
+
51
+ if ids.empty?
52
+ return render json: { ok: false, error: 'no ids selected' }, status: :unprocessable_entity
53
+ end
54
+ unless %w[resolve ignore reopen delete].include?(action)
55
+ return render json: { ok: false, error: 'unknown action' }, status: :unprocessable_entity
56
+ end
57
+
58
+ count = apply_bulk(ids, action)
59
+ render json: { ok: true, count: count, action: action }
60
+ end
61
+
62
+ private
63
+
64
+ def set_error
65
+ @error = ErrorLog.find(params[:id])
66
+ end
67
+
68
+ def build_scope
69
+ scope = ErrorLog.all
70
+
71
+ if params[:status].present? && ErrorLog.statuses.key?(params[:status])
72
+ scope = scope.where(status: params[:status])
73
+ end
74
+
75
+ if params[:severity].present? && ErrorLog.severities.key?(params[:severity])
76
+ scope = scope.where(severity: params[:severity])
77
+ end
78
+
79
+ if params[:category].present? && ErrorLog.categories.key?(params[:category])
80
+ scope = scope.where(category: params[:category])
81
+ end
82
+
83
+ if params[:q].present?
84
+ q = "%#{params[:q].downcase}%"
85
+ scope = scope.where(
86
+ 'lower(message) LIKE :q OR lower(error_class) LIKE :q OR lower(source) LIKE :q', q: q
87
+ )
88
+ end
89
+
90
+ from_date = params[:from].present? ? (Date.parse(params[:from]) rescue nil) : nil
91
+ to_date = params[:to].present? ? (Date.parse(params[:to]) rescue nil) : nil
92
+ scope = scope.where('last_seen_at >= ?', from_date) if from_date
93
+ scope = scope.where('last_seen_at <= ?', to_date.end_of_day) if to_date
94
+
95
+ sort_col = %w[last_seen_at first_seen_at occurrences].include?(params[:sort]) ? params[:sort] : 'last_seen_at'
96
+ sort_dir = params[:order] == 'asc' ? :asc : :desc
97
+ scope.order(sort_col => sort_dir)
98
+ end
99
+
100
+ def apply_bulk(ids, action)
101
+ case action
102
+ when 'resolve'
103
+ ErrorLog.where(id: ids).update_all(
104
+ status: ErrorLog.statuses[:resolved],
105
+ resolved_at: Time.current,
106
+ resolved_by: error_radar_current_user
107
+ )
108
+ when 'ignore'
109
+ ErrorLog.where(id: ids).update_all(status: ErrorLog.statuses[:ignored])
110
+ when 'reopen'
111
+ ErrorLog.where(id: ids).update_all(status: ErrorLog.statuses[:open], resolved_at: nil)
112
+ when 'delete'
113
+ ErrorLog.where(id: ids).delete_all
114
+ end
115
+ ids.size
116
+ end
117
+
118
+ def active_filter_params
119
+ params.permit(:q, :status, :severity, :category, :from, :to, :sort, :order).to_h
120
+ end
121
+
122
+ def pagination_pages(current, total)
123
+ return [] if total <= 1
124
+ return (1..total).to_a if total <= 9
125
+
126
+ visible = [1, total, *(current - 2..current + 2)]
127
+ .select { |p| p.between?(1, total) }
128
+ .sort.uniq
129
+ result = []
130
+ visible.each_with_index do |p, i|
131
+ result << '...' if i > 0 && p > visible[i - 1] + 1
132
+ result << p
133
+ end
134
+ result
135
+ end
136
+ end
137
+ end
@@ -3,9 +3,12 @@
3
3
  status_labels = { 'open' => 'Open', 'in_progress' => 'In progress', 'resolved' => 'Resolved', 'ignored' => 'Ignored' }
4
4
  %>
5
5
 
6
- <h1>🛰 Error Radar</h1>
6
+ <div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:4px">
7
+ <h1>Dashboard</h1>
8
+ <%= link_to 'View All Errors →', errors_path, style: 'font-size:13px' %>
9
+ </div>
7
10
  <div class="sub">
8
- Track &amp; triage system errors
11
+ Error overview &amp; kanban board
9
12
  <% @external_links.each do |label, href| %>
10
13
  · <%= link_to(label.to_s.humanize, href) %>
11
14
  <% end %>
@@ -70,7 +73,10 @@
70
73
 
71
74
  <div class="grid2">
72
75
  <div class="panel">
73
- <h2>Top 10 errors (unresolved, by occurrences)</h2>
76
+ <div style="display:flex;justify-content:space-between;align-items:baseline">
77
+ <h2 style="margin:0 0 12px">Top 10 errors (unresolved, by occurrences)</h2>
78
+ <%= link_to 'See all →', errors_path(status: 'open'), style: 'font-size:12px' %>
79
+ </div>
74
80
  <table>
75
81
  <thead><tr><th>Severity</th><th>Source</th><th>Message</th><th>Count</th><th>Last seen</th></tr></thead>
76
82
  <tbody>
@@ -0,0 +1,297 @@
1
+ <%
2
+ sev_colors = { 'info' => '#17a2b8', 'warning' => '#fd7e14', 'error' => '#dc3545', 'critical' => '#7b001c' }
3
+ status_colors = { 'open' => '#dc3545', 'in_progress' => '#fd7e14', 'resolved' => '#28a745', 'ignored' => '#6c757d' }
4
+ fp = @filter_params
5
+ current_sort = fp['sort'] || 'last_seen_at'
6
+ current_order = fp['order'] || 'desc'
7
+
8
+ sort_url = ->(col) {
9
+ dir = (current_sort == col && current_order != 'asc') ? 'asc' : 'desc'
10
+ errors_path(fp.reject { |k, _| k == 'page' }.merge('sort' => col, 'order' => dir))
11
+ }
12
+ sort_arrow = ->(col) {
13
+ next '' unless current_sort == col
14
+ current_order == 'asc' ? ' ↑' : ' ↓'
15
+ }
16
+
17
+ offset_start = @total_count == 0 ? 0 : ((@page - 1) * 50) + 1
18
+ offset_end = [offset_start + @errors.size - 1, @total_count].min
19
+ filters_on = fp.values.any?(&:present?)
20
+ %>
21
+
22
+ <style>
23
+ .er-list-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
24
+ .er-list-header h1 { margin: 0; }
25
+ .er-filter-bar { margin-bottom: 12px; }
26
+ .er-filter-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
27
+ .er-filter-row input[type=text], .er-filter-row select, .er-filter-row input[type=date] {
28
+ border: 1px solid #d1d5db; border-radius: 7px; padding: 6px 10px; font-size: 13px;
29
+ background: #fff; color: #1f2933; height: 34px;
30
+ }
31
+ .er-filter-row input[type=text] { min-width: 260px; }
32
+ .er-filter-row select { min-width: 130px; }
33
+ .er-filter-row input[type=date] { min-width: 130px; }
34
+ .btn-filter { background: #2563eb; color: #fff; border: 0; border-radius: 7px; padding: 0 16px; height: 34px; font-size: 13px; cursor: pointer; }
35
+ .btn-filter:hover { background: #1d4ed8; }
36
+ .btn-clear { font-size: 13px; color: #6b7280; padding: 0 8px; }
37
+ .btn-clear:hover { color: #1f2933; }
38
+ .er-results-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 10px; min-height: 36px; }
39
+ .er-results-count { font-size: 13px; color: #6b7280; }
40
+ .er-bulk-bar { display: none; align-items: center; gap: 8px; }
41
+ .btn-bulk { border: 0; border-radius: 6px; padding: 5px 12px; font-size: 12px; cursor: pointer; color: #fff; }
42
+ .btn-bulk-resolve { background: #28a745; } .btn-bulk-ignore { background: #6c757d; }
43
+ .btn-bulk-reopen { background: #fd7e14; } .btn-bulk-delete { background: #dc3545; }
44
+ .btn-bulk:hover { opacity: .85; }
45
+ .er-table thead th { position: sticky; top: 0; background: #f9fafb; z-index: 1; white-space: nowrap; }
46
+ .er-table thead th a { color: #6b7280; font-size: 12px; }
47
+ .er-table thead th a:hover { color: #1f2933; }
48
+ .er-table tbody tr:hover { background: #f8fafc; }
49
+ .er-table tbody tr.row-checked { background: #eff6ff; }
50
+ .er-actions a { display: inline-block; padding: 2px 6px; border-radius: 5px; font-size: 12px; margin: 0 2px; }
51
+ .er-actions a:hover { background: #f3f4f6; }
52
+ .er-pagination { display: flex; gap: 4px; align-items: center; margin-top: 16px; justify-content: center; flex-wrap: wrap; }
53
+ .er-page-link { display: inline-block; padding: 5px 10px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; color: #374151; background: #fff; }
54
+ .er-page-link:hover { background: #f3f4f6; border-color: #9ca3af; }
55
+ .er-page-link.active { background: #2563eb; border-color: #2563eb; color: #fff; pointer-events: none; }
56
+ .er-page-ellipsis { padding: 5px 4px; color: #9ca3af; font-size: 13px; }
57
+ .mono { font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; font-size: 12px; }
58
+ </style>
59
+
60
+ <div class="er-list-header">
61
+ <h1>All Errors</h1>
62
+ <span class="sub" style="margin:0"><%= @total_count %> total</span>
63
+ </div>
64
+
65
+ <%# ─── Filter bar ─── %>
66
+ <div class="panel er-filter-bar">
67
+ <form action="<%= errors_path %>" method="GET" data-turbo="false">
68
+ <div class="er-filter-row">
69
+ <input type="text" name="q" value="<%= fp['q'] %>" placeholder="Search error class, message, source…">
70
+
71
+ <select name="status">
72
+ <option value="">All statuses</option>
73
+ <% ErrorLog.statuses.each_key do |s| %>
74
+ <option value="<%= s %>" <%= 'selected' if fp['status'] == s %>><%= s.humanize %></option>
75
+ <% end %>
76
+ </select>
77
+
78
+ <select name="severity">
79
+ <option value="">All severities</option>
80
+ <% ErrorLog.severities.each_key do |s| %>
81
+ <option value="<%= s %>" <%= 'selected' if fp['severity'] == s %>><%= s.capitalize %></option>
82
+ <% end %>
83
+ </select>
84
+
85
+ <select name="category">
86
+ <option value="">All categories</option>
87
+ <% ErrorLog.categories.each_key do |s| %>
88
+ <option value="<%= s %>" <%= 'selected' if fp['category'] == s.to_s %>><%= s.to_s.humanize %></option>
89
+ <% end %>
90
+ </select>
91
+
92
+ <input type="date" name="from" value="<%= fp['from'] %>" title="Last seen from">
93
+ <input type="date" name="to" value="<%= fp['to'] %>" title="Last seen to">
94
+
95
+ <button type="submit" class="btn-filter">Filter</button>
96
+ <% if filters_on %>
97
+ <a href="<%= errors_path %>" class="btn-clear">Clear</a>
98
+ <% end %>
99
+ </div>
100
+ </form>
101
+ </div>
102
+
103
+ <%# ─── Results bar + bulk actions ─── %>
104
+ <div class="er-results-bar">
105
+ <span class="er-results-count">
106
+ <% if @total_count == 0 %>
107
+ No errors found<% if filters_on %> matching your filters<% end %>
108
+ <% elsif @total_count <= 50 %>
109
+ <%= @total_count %> errors
110
+ <% else %>
111
+ <%= offset_start %>–<%= offset_end %> of <%= @total_count %> errors
112
+ <% end %>
113
+ </span>
114
+
115
+ <div class="er-bulk-bar" id="bulk-bar">
116
+ <span id="bulk-count" style="font-size:13px;color:#374151;font-weight:600"></span>
117
+ <button class="btn-bulk btn-bulk-resolve" onclick="bulkAction('resolve')">Resolve</button>
118
+ <button class="btn-bulk btn-bulk-ignore" onclick="bulkAction('ignore')">Ignore</button>
119
+ <button class="btn-bulk btn-bulk-reopen" onclick="bulkAction('reopen')">Reopen</button>
120
+ <button class="btn-bulk btn-bulk-delete" onclick="bulkAction('delete')">Delete</button>
121
+ </div>
122
+ </div>
123
+
124
+ <%# ─── Table ─── %>
125
+ <div class="panel" style="padding:0;overflow:hidden">
126
+ <% if @errors.empty? %>
127
+ <div style="padding:48px;text-align:center;color:#9ca3af;font-size:14px">
128
+ <div style="font-size:32px;margin-bottom:12px">✓</div>
129
+ No errors<% if filters_on %> match your filters<% else %> recorded<% end %>.
130
+ </div>
131
+ <% else %>
132
+ <div style="overflow-x:auto">
133
+ <table class="er-table">
134
+ <thead>
135
+ <tr>
136
+ <th style="width:36px;padding:10px 8px">
137
+ <input type="checkbox" id="select-all" onchange="toggleAll(this)" title="Select all">
138
+ </th>
139
+ <th><a href="<%= sort_url.call('severity') %>">Severity<%= sort_arrow.call('severity') %></a></th>
140
+ <th>Status</th>
141
+ <th>Error Class</th>
142
+ <th>Source</th>
143
+ <th style="min-width:240px">Message</th>
144
+ <th><a href="<%= sort_url.call('occurrences') %>">Count<%= sort_arrow.call('occurrences') %></a></th>
145
+ <th><a href="<%= sort_url.call('last_seen_at') %>">Last Seen<%= sort_arrow.call('last_seen_at') %></a></th>
146
+ <th style="width:80px">Actions</th>
147
+ </tr>
148
+ </thead>
149
+ <tbody>
150
+ <% @errors.each do |e| %>
151
+ <tr class="error-row" data-id="<%= e.id %>">
152
+ <td style="padding:8px"><input type="checkbox" class="row-cb" value="<%= e.id %>" onchange="onRowCheck(this)"></td>
153
+ <td><span class="badge" style="background:<%= sev_colors[e.severity] %>"><%= e.severity %></span></td>
154
+ <td><span class="badge" style="background:<%= status_colors[e.status] %>"><%= e.status.humanize %></span></td>
155
+ <td class="mono"><%= link_to e.error_class.to_s.truncate(40), error_path(e), title: e.error_class %></td>
156
+ <td style="font-size:12px;color:#6b7280;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="<%= e.source %>"><%= e.source %></td>
157
+ <td style="font-size:12px;color:#374151"><%= e.short_message %></td>
158
+ <td style="font-weight:600;color:<%= e.occurrences > 100 ? '#dc3545' : '#374151' %>"><%= e.occurrences %></td>
159
+ <td style="font-size:12px;color:#6b7280;white-space:nowrap"><%= e.last_seen_at&.strftime('%m/%d %H:%M') %></td>
160
+ <td class="er-actions" style="white-space:nowrap">
161
+ <% unless e.status_resolved? %>
162
+ <a href="#" class="quick-resolve" data-id="<%= e.id %>" data-url="<%= error_status_path(e) %>" title="Resolve">✓</a>
163
+ <% end %>
164
+ <% unless e.status_ignored? %>
165
+ <a href="#" class="quick-ignore" data-id="<%= e.id %>" data-url="<%= error_status_path(e) %>" title="Ignore">✕</a>
166
+ <% end %>
167
+ <%= link_to '→', error_path(e), title: 'View detail', style: 'color:#2563eb' %>
168
+ </td>
169
+ </tr>
170
+ <% end %>
171
+ </tbody>
172
+ </table>
173
+ </div>
174
+ <% end %>
175
+ </div>
176
+
177
+ <%# ─── Pagination ─── %>
178
+ <% if @pages.any? %>
179
+ <div class="er-pagination">
180
+ <% if @page > 1 %>
181
+ <%= link_to '← Prev', errors_path(fp.merge('page' => @page - 1)), class: 'er-page-link' %>
182
+ <% end %>
183
+
184
+ <% @pages.each do |p| %>
185
+ <% if p == '...' %>
186
+ <span class="er-page-ellipsis">…</span>
187
+ <% else %>
188
+ <%= link_to p, errors_path(fp.merge('page' => p)), class: "er-page-link #{p == @page ? 'active' : ''}" %>
189
+ <% end %>
190
+ <% end %>
191
+
192
+ <% if @page < @total_pages %>
193
+ <%= link_to 'Next →', errors_path(fp.merge('page' => @page + 1)), class: 'er-page-link' %>
194
+ <% end %>
195
+ </div>
196
+ <% end %>
197
+
198
+ <script>
199
+ (function () {
200
+ var BULK_URL = '<%= errors_bulk_path %>';
201
+ var CSRF = document.querySelector('meta[name="csrf-token"]').content;
202
+
203
+ // ── checkbox state ──
204
+ function selectedIds() {
205
+ return Array.from(document.querySelectorAll('.row-cb:checked')).map(function (c) { return c.value; });
206
+ }
207
+
208
+ window.onRowCheck = function (cb) {
209
+ var row = cb.closest('tr');
210
+ row.classList.toggle('row-checked', cb.checked);
211
+ syncBulkBar();
212
+ };
213
+
214
+ window.toggleAll = function (masterCb) {
215
+ document.querySelectorAll('.row-cb').forEach(function (c) {
216
+ c.checked = masterCb.checked;
217
+ c.closest('tr').classList.toggle('row-checked', masterCb.checked);
218
+ });
219
+ syncBulkBar();
220
+ };
221
+
222
+ function syncBulkBar() {
223
+ var ids = selectedIds();
224
+ var bar = document.getElementById('bulk-bar');
225
+ var cnt = document.getElementById('bulk-count');
226
+ if (ids.length > 0) {
227
+ bar.style.display = 'flex';
228
+ cnt.textContent = ids.length + ' selected';
229
+ } else {
230
+ bar.style.display = 'none';
231
+ }
232
+ }
233
+
234
+ // ── bulk actions ──
235
+ window.bulkAction = function (action) {
236
+ var ids = selectedIds();
237
+ if (!ids.length) return;
238
+ if (action === 'delete' && !confirm('Permanently delete ' + ids.length + ' error(s)? This cannot be undone.')) return;
239
+
240
+ fetch(BULK_URL, {
241
+ method: 'POST',
242
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
243
+ body: JSON.stringify({ ids: ids, bulk_action: action })
244
+ })
245
+ .then(function (r) { return r.json(); })
246
+ .then(function (d) {
247
+ if (d.ok) {
248
+ toast(d.count + ' errors ' + action + 'd', true);
249
+ setTimeout(function () { location.reload(); }, 700);
250
+ } else {
251
+ toast('Error: ' + (d.error || 'failed'), false);
252
+ }
253
+ })
254
+ .catch(function () { toast('Network error', false); });
255
+ };
256
+
257
+ // ── quick row actions ──
258
+ document.querySelectorAll('.quick-resolve').forEach(function (a) {
259
+ a.addEventListener('click', function (e) {
260
+ e.preventDefault();
261
+ quickStatus(a.dataset.url, a.dataset.id, 'resolved');
262
+ });
263
+ });
264
+ document.querySelectorAll('.quick-ignore').forEach(function (a) {
265
+ a.addEventListener('click', function (e) {
266
+ e.preventDefault();
267
+ quickStatus(a.dataset.url, a.dataset.id, 'ignored');
268
+ });
269
+ });
270
+
271
+ function quickStatus(url, id, status) {
272
+ fetch(url, {
273
+ method: 'PATCH',
274
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
275
+ body: JSON.stringify({ status: status })
276
+ })
277
+ .then(function (r) { return r.json(); })
278
+ .then(function (d) {
279
+ if (d.ok) {
280
+ toast('#' + id + ' → ' + d.status, true);
281
+ setTimeout(function () { location.reload(); }, 500);
282
+ } else {
283
+ toast('Failed: ' + (d.error || ''), false);
284
+ }
285
+ });
286
+ }
287
+
288
+ // ── toast ──
289
+ function toast(msg, ok) {
290
+ var t = document.getElementById('toast');
291
+ t.textContent = msg;
292
+ t.style.background = ok ? '#16a34a' : '#dc2626';
293
+ t.classList.add('show');
294
+ setTimeout(function () { t.classList.remove('show'); }, 2200);
295
+ }
296
+ })();
297
+ </script>
@@ -0,0 +1,165 @@
1
+ <%
2
+ sev_colors = { 'info' => '#17a2b8', 'warning' => '#fd7e14', 'error' => '#dc3545', 'critical' => '#7b001c' }
3
+ status_colors = { 'open' => '#dc3545', 'in_progress' => '#fd7e14', 'resolved' => '#28a745', 'ignored' => '#6c757d' }
4
+ %>
5
+
6
+ <style>
7
+ .er-show-header { margin-bottom: 20px; }
8
+ .er-show-header h1 { font-size: 16px; margin: 0 0 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
9
+ .er-show-meta { font-size: 13px; color: #6b7280; }
10
+ .er-show-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
11
+ .er-detail-table th { width: 150px; color: #6b7280; font-weight: 500; vertical-align: top; }
12
+ .er-detail-table td { word-break: break-word; }
13
+ .er-pre { white-space: pre-wrap; background: #f8fafc; padding: 12px; border-radius: 8px;
14
+ max-height: 360px; overflow: auto; font-size: 11px; font-family: ui-monospace, monospace; margin: 0; }
15
+ .er-status-actions { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; }
16
+ .er-btn { padding: 8px 16px; border: 0; border-radius: 8px; color: #fff; cursor: pointer; font-size: 13px; }
17
+ .er-btn:hover { opacity: .85; }
18
+ .er-btn-danger { background: #1f2933; }
19
+ .resolve-inline { margin-top: 10px; display: none; }
20
+ .resolve-inline textarea { width: 320px; padding: 7px 10px; border: 1px solid #d1d5db; border-radius: 7px;
21
+ font-size: 13px; resize: vertical; }
22
+ .resolve-inline button { margin-top: 6px; padding: 6px 14px; background: #28a745; color: #fff;
23
+ border: 0; border-radius: 6px; cursor: pointer; font-size: 13px; }
24
+ @media (max-width: 768px) { .er-show-grid { grid-template-columns: 1fr; } }
25
+ </style>
26
+
27
+ <div class="sub"><%= link_to '← All Errors', errors_path %></div>
28
+
29
+ <div class="er-show-header">
30
+ <h1>
31
+ <span class="badge" style="background:<%= status_colors[@error.status] %>"><%= @error.status.humanize %></span>
32
+ <span class="badge" style="background:<%= sev_colors[@error.severity] %>"><%= @error.severity %></span>
33
+ <span style="font-family:ui-monospace,monospace"><%= @error.error_class %></span>
34
+ </h1>
35
+ <div class="er-show-meta">
36
+ <%= @error.category.to_s.humanize %> ·
37
+ <strong>×<%= @error.occurrences %></strong> occurrences ·
38
+ last seen <strong><%= @error.last_seen_at&.strftime('%Y-%m-%d %H:%M') %></strong>
39
+ </div>
40
+ </div>
41
+
42
+ <%# ── Details ── %>
43
+ <div class="panel">
44
+ <h2>Details</h2>
45
+ <table class="er-detail-table">
46
+ <tr><th>Error class</th><td style="font-family:monospace"><%= @error.error_class %></td></tr>
47
+ <tr><th>Source</th><td><%= @error.source %></td></tr>
48
+ <tr><th>Category</th><td><%= @error.category.to_s.humanize %></td></tr>
49
+ <tr><th>Message</th><td style="white-space:pre-wrap"><%= @error.message %></td></tr>
50
+ <tr><th>Occurrences</th><td><strong><%= @error.occurrences %></strong></td></tr>
51
+ <tr><th>First seen</th><td><%= @error.first_seen_at&.strftime('%Y-%m-%d %H:%M:%S %Z') %></td></tr>
52
+ <tr><th>Last seen</th><td><%= @error.last_seen_at&.strftime('%Y-%m-%d %H:%M:%S %Z') %></td></tr>
53
+ <% if @error.http_status.present? || @error.api_code.present? %>
54
+ <tr><th>HTTP status</th><td><%= @error.http_status %></td></tr>
55
+ <% if @error.api_code.present? %>
56
+ <tr><th>API code / subcode</th><td><%= @error.api_code %> / <%= @error.api_subcode %></td></tr>
57
+ <% end %>
58
+ <% if @error.request_url.present? %>
59
+ <tr><th>Request URL</th><td style="word-break:break-all"><%= @error.request_url %></td></tr>
60
+ <% end %>
61
+ <% end %>
62
+ <% if @error.resolved_at.present? %>
63
+ <tr><th>Resolved at</th><td><%= @error.resolved_at&.strftime('%Y-%m-%d %H:%M:%S') %> by <%= @error.resolved_by %></td></tr>
64
+ <% if @error.resolution_note.present? %>
65
+ <tr><th>Resolution note</th><td><%= @error.resolution_note %></td></tr>
66
+ <% end %>
67
+ <% end %>
68
+ </table>
69
+ </div>
70
+
71
+ <%# ── Context + Backtrace side by side ── %>
72
+ <div class="er-show-grid">
73
+ <div class="panel">
74
+ <h2>Context</h2>
75
+ <pre class="er-pre"><%= JSON.pretty_generate(@error.context || {}) %></pre>
76
+ </div>
77
+ <div class="panel">
78
+ <h2>Backtrace</h2>
79
+ <pre class="er-pre"><%= @error.backtrace %></pre>
80
+ </div>
81
+ </div>
82
+
83
+ <%# ── Status change ── %>
84
+ <div class="panel" style="margin-top:16px">
85
+ <h2>Change status</h2>
86
+ <div class="er-status-actions">
87
+ <% %w[open in_progress ignored].each do |s| %>
88
+ <button class="er-btn status-btn" data-status="<%= s %>"
89
+ style="background:<%= status_colors[s] %>">
90
+ <%= s.humanize %>
91
+ </button>
92
+ <% end %>
93
+
94
+ <div>
95
+ <button class="er-btn" style="background:#28a745" onclick="toggleResolveBox(event)">Resolve</button>
96
+ <div class="resolve-inline" id="resolve-box">
97
+ <textarea id="resolve-note" rows="3" placeholder="Resolution note (optional)…"></textarea><br>
98
+ <button onclick="doResolve()">Confirm resolve</button>
99
+ </div>
100
+ </div>
101
+
102
+ <div style="flex:1"></div>
103
+ <button class="er-btn er-btn-danger" onclick="doDelete()">Delete</button>
104
+ </div>
105
+ </div>
106
+
107
+ <script>
108
+ (function () {
109
+ var CSRF = document.querySelector('meta[name="csrf-token"]').content;
110
+ var STATUS_URL = '<%= error_status_path(@error) %>';
111
+ var DELETE_URL = '<%= error_path(@error) %>';
112
+
113
+ document.querySelectorAll('.status-btn').forEach(function (btn) {
114
+ btn.addEventListener('click', function () { changeStatus(btn.dataset.status, null); });
115
+ });
116
+
117
+ window.toggleResolveBox = function (e) {
118
+ e.preventDefault();
119
+ var box = document.getElementById('resolve-box');
120
+ box.style.display = box.style.display === 'none' ? 'block' : 'none';
121
+ };
122
+
123
+ window.doResolve = function () {
124
+ changeStatus('resolved', document.getElementById('resolve-note').value.trim() || null);
125
+ };
126
+
127
+ function changeStatus(status, note) {
128
+ var body = { status: status };
129
+ if (note) body.note = note;
130
+ fetch(STATUS_URL, {
131
+ method: 'PATCH',
132
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
133
+ body: JSON.stringify(body)
134
+ })
135
+ .then(function (r) { return r.json(); })
136
+ .then(function (d) {
137
+ if (d.ok) {
138
+ showToast('→ ' + d.status, true);
139
+ setTimeout(function () { location.reload(); }, 700);
140
+ } else {
141
+ showToast('Error: ' + (d.error || 'failed'), false);
142
+ }
143
+ });
144
+ }
145
+
146
+ window.doDelete = function () {
147
+ if (!confirm('Permanently delete this error? This cannot be undone.')) return;
148
+ fetch(DELETE_URL, {
149
+ method: 'DELETE',
150
+ headers: { 'X-CSRF-Token': CSRF }
151
+ })
152
+ .then(function (r) {
153
+ if (r.ok) { window.location.href = '<%= errors_path %>'; }
154
+ });
155
+ };
156
+
157
+ function showToast(msg, ok) {
158
+ var t = document.getElementById('toast');
159
+ t.textContent = msg;
160
+ t.style.background = ok ? '#16a34a' : '#dc2626';
161
+ t.classList.add('show');
162
+ setTimeout(function () { t.classList.remove('show'); }, 2000);
163
+ }
164
+ })();
165
+ </script>
@@ -10,8 +10,8 @@
10
10
  <style>
11
11
  * { box-sizing: border-box; }
12
12
  body { margin: 0; font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f4f6f8; color: #1f2933; }
13
- .wrap { max-width: 1360px; margin: 0 auto; padding: 20px; }
14
- h1 { font-size: 22px; margin: 0 0 4px; }
13
+ .wrap { max-width: 1400px; margin: 0 auto; padding: 20px; }
14
+ h1 { font-size: 20px; margin: 0 0 4px; }
15
15
  .sub { color: #6b7280; font-size: 13px; margin-bottom: 18px; }
16
16
  a { color: #2563eb; text-decoration: none; }
17
17
  .cards { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 20px; }
@@ -25,6 +25,7 @@
25
25
  .panel h2 { font-size: 14px; margin: 0 0 12px; color: #374151; }
26
26
  table { width: 100%; border-collapse: collapse; font-size: 13px; }
27
27
  th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #eef0f2; }
28
+ th { font-size: 12px; color: #6b7280; font-weight: 600; background: #f9fafb; }
28
29
  .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; color: #fff; font-size: 11px; }
29
30
  .kanban { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
30
31
  .col { background: #eceff1; border-radius: 10px; padding: 10px; min-height: 200px; }
@@ -37,11 +38,26 @@
37
38
  .ticket .msg { font-size: 12px; color: #4b5563; margin: 4px 0; max-height: 48px; overflow: hidden; }
38
39
  .ticket .meta { font-size: 11px; color: #9ca3af; display: flex; gap: 8px; flex-wrap: wrap; }
39
40
  .ticket .occ { background: #fee2e2; color: #b91c1c; border-radius: 8px; padding: 0 6px; }
40
- .toast { position: fixed; bottom: 20px; right: 20px; background: #1f2933; color: #fff; padding: 10px 16px; border-radius: 8px; opacity: 0; transition: opacity .2s; }
41
+ .toast { position: fixed; bottom: 20px; right: 20px; background: #1f2933; color: #fff; padding: 10px 16px; border-radius: 8px; opacity: 0; transition: opacity .2s; z-index: 9999; }
41
42
  .toast.show { opacity: 1; }
43
+ /* Nav */
44
+ .er-nav { background: #1f2933; }
45
+ .er-nav .wrap { display: flex; align-items: center; gap: 4px; padding-top: 0; padding-bottom: 0; }
46
+ .er-nav .nav-logo { color: #f9fafb; font-weight: 700; font-size: 14px; padding: 14px 16px 14px 0; border-right: 1px solid #374151; margin-right: 8px; }
47
+ .er-nav .nav-logo:hover { color: #fff; }
48
+ .er-nav .nav-link { color: #9ca3af; font-size: 13px; padding: 14px 12px; display: inline-block; }
49
+ .er-nav .nav-link:hover { color: #f9fafb; }
50
+ .er-nav .nav-link.active { color: #fff; border-bottom: 2px solid #3b82f6; }
42
51
  </style>
43
52
  </head>
44
53
  <body>
54
+ <nav class="er-nav">
55
+ <div class="wrap">
56
+ <a href="<%= root_path %>" class="nav-logo">🛰 Error Radar</a>
57
+ <a href="<%= root_path %>" class="nav-link <%= 'active' if controller_name == 'dashboard' %>">Dashboard</a>
58
+ <a href="<%= errors_path %>" class="nav-link <%= 'active' if controller_name == 'errors' %>">All Errors</a>
59
+ </div>
60
+ </nav>
45
61
  <div class="wrap"><%= yield %></div>
46
62
  <div id="toast" class="toast"></div>
47
63
  </body>
data/config/routes.rb CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ErrorRadar::Engine.routes.draw do
4
4
  root to: 'dashboard#index'
5
- get 'errors/:id', to: 'dashboard#show', as: :error
6
- patch 'errors/:id/status', to: 'dashboard#update_status', as: :error_status
5
+
6
+ get 'errors', to: 'errors#index', as: :errors
7
+ post 'errors/bulk', to: 'errors#bulk', as: :errors_bulk
8
+ get 'errors/:id', to: 'errors#show', as: :error
9
+ patch 'errors/:id/status', to: 'errors#update_status', as: :error_status
10
+ delete 'errors/:id', to: 'errors#destroy'
7
11
  end
@@ -32,7 +32,8 @@ module ErrorRadar
32
32
  attr_accessor :sensitive_params
33
33
 
34
34
  # Integration toggles.
35
- attr_accessor :install_middleware, :install_sidekiq, :install_rails_admin
35
+ attr_accessor :install_middleware, :install_sidekiq, :install_rails_admin,
36
+ :install_active_job, :install_rake
36
37
 
37
38
  # Custom classification rules. Each is a callable `->(exception) { :category | nil }`.
38
39
  # The first rule that returns a non-nil category wins; built-in rules run after.
@@ -73,9 +74,11 @@ module ErrorRadar
73
74
  ActionDispatch::Http::Parameters::ParseError
74
75
  ]
75
76
  @sensitive_params = %w[password password_confirmation token access_token authorization secret]
76
- @install_middleware = true
77
- @install_sidekiq = true
77
+ @install_middleware = true
78
+ @install_sidekiq = true
78
79
  @install_rails_admin = true
80
+ @install_active_job = true
81
+ @install_rake = true
79
82
  @categorizers = []
80
83
  @detail_extractors = []
81
84
  @expected_servers = []
@@ -22,6 +22,29 @@ module ErrorRadar
22
22
  end
23
23
  end
24
24
 
25
+ # Auto-inject into every ActiveJob subclass (any queue adapter, not just Sidekiq).
26
+ # Disabled when only Sidekiq is in use to avoid double-counting.
27
+ initializer 'error_radar.active_job', after: :load_config_initializers do
28
+ if ErrorRadar.config.install_active_job && defined?(::ActiveJob)
29
+ require 'error_radar/integrations/active_job'
30
+ ActiveSupport.on_load(:active_job) do
31
+ include ErrorRadar::Integrations::ActiveJob
32
+ end
33
+ end
34
+ end
35
+
36
+ # Wrap every Rake task execution so failures are captured automatically.
37
+ initializer 'error_radar.rake', after: :load_config_initializers do |_app|
38
+ if ErrorRadar.config.install_rake
39
+ config.after_initialize do
40
+ if defined?(::Rake::Task)
41
+ require 'error_radar/integrations/rake'
42
+ ErrorRadar::Integrations::Rake.install!
43
+ end
44
+ end
45
+ end
46
+ end
47
+
25
48
  # Register the ErrorLog board + custom actions in RailsAdmin, if present.
26
49
  config.after_initialize do
27
50
  if ErrorRadar.config.install_rails_admin && defined?(::RailsAdmin)
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErrorRadar
4
+ module Integrations
5
+ # Patches Rake::Task#execute so any exception raised inside a rake task is
6
+ # automatically captured as an ErrorLog, then re-raised so rake's normal
7
+ # failure handling (exit code, output) is unaffected.
8
+ module Rake
9
+ def self.install!
10
+ return if @installed
11
+
12
+ @installed = true
13
+
14
+ ::Rake::Task.class_eval do
15
+ alias_method :error_radar_original_execute, :execute
16
+
17
+ def execute(args = nil)
18
+ error_radar_original_execute(args)
19
+ rescue Exception => e # rubocop:disable Lint/RescueException
20
+ ErrorRadar.capture(
21
+ e,
22
+ source: "rake:#{name}",
23
+ category: :background_job,
24
+ context: { task: name }
25
+ )
26
+ raise
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ErrorRadar
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -8,9 +8,11 @@ ErrorRadar.configure do |config|
8
8
  config.enabled = !Rails.env.test?
9
9
 
10
10
  # --- Integrations (auto-detected; flip off if you don't want them) ---
11
- config.install_middleware = true # Rack: capture web-layer exceptions
12
- config.install_sidekiq = true # capture Sidekiq job failures (if Sidekiq present)
13
- config.install_rails_admin = true # register the ErrorLog board (if RailsAdmin present)
11
+ config.install_middleware = true # Rack: capture web-layer exceptions (auto)
12
+ config.install_sidekiq = true # capture Sidekiq job failures (auto, if Sidekiq present)
13
+ config.install_active_job = true # capture ActiveJob failures for any adapter (auto)
14
+ config.install_rake = true # capture Rake task failures (auto)
15
+ config.install_rails_admin = true # register the ErrorLog board (auto, if RailsAdmin present)
14
16
 
15
17
  # --- Dashboard access control ---
16
18
  # Run as a before_action; raise/redirect inside to deny. Example with Devise:
@@ -45,10 +47,6 @@ ErrorRadar.configure do |config|
45
47
  # ]
46
48
  end
47
49
 
48
- # --- Optional: capture ActiveJob failures across ALL queue adapters ---
49
- # Add to app/jobs/application_job.rb (skip if you only use Sidekiq, since the
50
- # Sidekiq integration above already covers it):
51
- #
52
- # class ApplicationJob < ActiveJob::Base
53
- # include ErrorRadar::Integrations::ActiveJob
54
- # end
50
+ # ActiveJob is now captured automatically via install_active_job = true above.
51
+ # No changes needed in ApplicationJob. Set install_active_job = false if you
52
+ # only use Sidekiq and want to avoid double-counting.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: error_radar
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - chienbn9x
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-01 00:00:00.000000000 Z
11
+ date: 2026-07-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -59,10 +59,13 @@ files:
59
59
  - Rakefile
60
60
  - app/controllers/error_radar/application_controller.rb
61
61
  - app/controllers/error_radar/dashboard_controller.rb
62
+ - app/controllers/error_radar/errors_controller.rb
62
63
  - app/models/error_radar/application_record.rb
63
64
  - app/models/error_radar/error_log.rb
64
65
  - app/views/error_radar/dashboard/index.html.erb
65
66
  - app/views/error_radar/dashboard/show.html.erb
67
+ - app/views/error_radar/errors/index.html.erb
68
+ - app/views/error_radar/errors/show.html.erb
66
69
  - app/views/layouts/error_radar/application.html.erb
67
70
  - config/routes.rb
68
71
  - lib/error_radar.rb
@@ -70,6 +73,7 @@ files:
70
73
  - lib/error_radar/engine.rb
71
74
  - lib/error_radar/integrations/active_job.rb
72
75
  - lib/error_radar/integrations/rails_admin.rb
76
+ - lib/error_radar/integrations/rake.rb
73
77
  - lib/error_radar/integrations/sidekiq.rb
74
78
  - lib/error_radar/middleware.rb
75
79
  - lib/error_radar/server_monitor.rb