error_radar 0.2.0 → 0.4.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.
@@ -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,42 @@ 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
37
+
38
+ # Notifications ─────────────────────────────────────────────────────────
39
+ # When to fire: :new_error (first time a fingerprint is seen),
40
+ # :critical (any critical-severity occurrence, throttled),
41
+ # :all (every occurrence, throttled to 1/hour/fingerprint)
42
+ attr_accessor :notify_on
43
+
44
+ # Slack incoming-webhook URL (https://hooks.slack.com/services/...)
45
+ attr_accessor :slack_webhook_url
46
+ # Override target channel. Leave nil to use webhook's default channel.
47
+ attr_accessor :slack_channel
48
+
49
+ # Discord incoming-webhook URL
50
+ attr_accessor :discord_webhook_url
51
+
52
+ # ActionMailer recipients. Requires ActionMailer to be configured in host app.
53
+ attr_accessor :email_recipients
54
+ # From address for notification emails.
55
+ attr_accessor :email_from
56
+
57
+ # One or more plain HTTPS URLs that receive a POST with JSON body on each alert.
58
+ attr_accessor :webhook_urls
59
+
60
+ # App name shown in notification subject/title. Defaults to Rails app name.
61
+ attr_accessor :app_name
62
+ # Base URL used to build deep-links (e.g. "https://myapp.com").
63
+ attr_accessor :app_host
64
+
65
+ # Custom callbacks: ->(error_log) { ... }. Called after built-in channels.
66
+ attr_reader :error_callbacks
67
+
68
+ def on_error(&block)
69
+ @error_callbacks << block
70
+ end
36
71
 
37
72
  # Custom classification rules. Each is a callable `->(exception) { :category | nil }`.
38
73
  # The first rule that returns a non-nil category wins; built-in rules run after.
@@ -73,9 +108,22 @@ module ErrorRadar
73
108
  ActionDispatch::Http::Parameters::ParseError
74
109
  ]
75
110
  @sensitive_params = %w[password password_confirmation token access_token authorization secret]
76
- @install_middleware = true
77
- @install_sidekiq = true
111
+ @install_middleware = true
112
+ @install_sidekiq = true
78
113
  @install_rails_admin = true
114
+ @install_active_job = true
115
+ @install_rake = true
116
+
117
+ @notify_on = [:new_error]
118
+ @slack_webhook_url = nil
119
+ @slack_channel = nil
120
+ @discord_webhook_url = nil
121
+ @email_recipients = []
122
+ @email_from = nil
123
+ @webhook_urls = []
124
+ @app_name = nil
125
+ @app_host = nil
126
+ @error_callbacks = []
79
127
  @categorizers = []
80
128
  @detail_extractors = []
81
129
  @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