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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/app/controllers/error_radar/dashboard_controller.rb +0 -22
- data/app/controllers/error_radar/errors_controller.rb +137 -0
- data/app/mailers/error_radar/error_mailer.rb +32 -0
- data/app/models/error_radar/error_log.rb +7 -1
- data/app/views/error_radar/dashboard/index.html.erb +9 -3
- data/app/views/error_radar/error_mailer/new_error.html.erb +82 -0
- data/app/views/error_radar/error_mailer/new_error.text.erb +21 -0
- data/app/views/error_radar/errors/index.html.erb +297 -0
- data/app/views/error_radar/errors/show.html.erb +165 -0
- data/app/views/layouts/error_radar/application.html.erb +19 -3
- data/config/routes.rb +6 -2
- data/lib/error_radar/configuration.rb +51 -3
- data/lib/error_radar/engine.rb +23 -0
- data/lib/error_radar/integrations/rake.rb +32 -0
- data/lib/error_radar/notifications/discord.rb +61 -0
- data/lib/error_radar/notifications/email.rb +18 -0
- data/lib/error_radar/notifications/slack.rb +76 -0
- data/lib/error_radar/notifications/webhook.rb +48 -0
- data/lib/error_radar/notifier.rb +91 -0
- data/lib/error_radar/tracking.rb +6 -2
- data/lib/error_radar/version.rb +1 -1
- data/lib/error_radar.rb +1 -0
- data/lib/generators/error_radar/install/templates/initializer.rb +35 -10
- metadata +14 -2
|
@@ -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:
|
|
14
|
-
h1 { font-size:
|
|
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
|
-
|
|
6
|
-
|
|
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
|
|
77
|
-
@install_sidekiq
|
|
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 = []
|
data/lib/error_radar/engine.rb
CHANGED
|
@@ -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
|