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 +4 -4
- data/CHANGELOG.md +28 -0
- data/app/controllers/error_radar/dashboard_controller.rb +0 -22
- data/app/controllers/error_radar/errors_controller.rb +137 -0
- data/app/views/error_radar/dashboard/index.html.erb +9 -3
- 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 +6 -3
- data/lib/error_radar/engine.rb +23 -0
- data/lib/error_radar/integrations/rake.rb +32 -0
- data/lib/error_radar/version.rb +1 -1
- data/lib/generators/error_radar/install/templates/initializer.rb +8 -10
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 131362a3ccb1bd3de4242f28690bb60059555bee0357ff488fbcce86f9b04f37
|
|
4
|
+
data.tar.gz: 8f0218c89a83e54e514657dd1c7f9e25a28c39d0adfe284424efdb134bc58467
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
<
|
|
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
|
-
|
|
11
|
+
Error overview & 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
|
-
<
|
|
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:
|
|
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,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
|
|
77
|
-
@install_sidekiq
|
|
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 = []
|
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
|
data/lib/error_radar/version.rb
CHANGED
|
@@ -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.
|
|
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
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# Sidekiq
|
|
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.
|
|
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-
|
|
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
|