error_radar 0.8.0 → 1.0.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 +58 -0
- data/app/controllers/error_radar/errors_controller.rb +87 -13
- data/app/mailers/error_radar/digest_mailer.rb +31 -0
- data/app/models/error_radar/error_activity.rb +25 -0
- data/app/models/error_radar/error_comment.rb +11 -0
- data/app/models/error_radar/error_log.rb +8 -0
- data/app/views/error_radar/digest_mailer/digest.html.erb +168 -0
- data/app/views/error_radar/digest_mailer/digest.text.erb +45 -0
- data/app/views/error_radar/errors/show.html.erb +224 -0
- data/config/routes.rb +9 -6
- data/lib/error_radar/configuration.rb +10 -1
- data/lib/error_radar/digest.rb +87 -0
- data/lib/error_radar/version.rb +1 -1
- data/lib/error_radar.rb +1 -0
- data/lib/generators/error_radar/install/templates/initializer.rb +14 -0
- data/lib/generators/error_radar/upgrade_v100/templates/upgrade_to_v100.rb.tt +31 -0
- data/lib/generators/error_radar/upgrade_v100/upgrade_v100_generator.rb +20 -0
- data/lib/tasks/error_radar.rake +19 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 01352c602172578657721bce465d06d1db4f3c077cedcecbeb9742eae276764e
|
|
4
|
+
data.tar.gz: e9a7d7c7be46a629b96d65cdbfec77bbb3b3a25d23acc07c8f52b45ea8ba9d0a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bfcf06383fe9f3fa619b7ed10e958db142c2f2eee52ce68e01b5ebc0d7af92ce7a706cb1bb851247da1590be49d77e6558ccd89f456985eb8dc67b5bf2dc6f99
|
|
7
|
+
data.tar.gz: 858acdf7ce7ae9ca9be1ac55f13d4120b7a9ba5460f605bad529c46ef235920faa31a43bdbb0a088ac41ba0a09a342fa4298601f1b53249a72d1b142e4601c28
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.0.0] - 2026-07-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Error assignment**: assign any error to a team member from the detail page.
|
|
9
|
+
Stored in the new `assigned_to` column on `error_radar_error_logs`.
|
|
10
|
+
- **Comment thread**: add, view, and delete comments on each error. Comments
|
|
11
|
+
are stored in the new `error_radar_comments` table with `author` and `body`.
|
|
12
|
+
- **Activity log**: every status change, assignment, and comment is recorded in
|
|
13
|
+
`error_radar_activities` (columns: `actor`, `action`, `detail`, `created_at`).
|
|
14
|
+
The timeline is shown on the error detail page (last 50 events).
|
|
15
|
+
- **`error_radar:upgrade_v100` generator**: one migration that adds `assigned_to`
|
|
16
|
+
to `error_radar_error_logs` and creates both `error_radar_comments` and
|
|
17
|
+
`error_radar_activities` tables.
|
|
18
|
+
- **`PATCH /errors/:id/assign`**: JSON endpoint to assign/unassign an error.
|
|
19
|
+
- **`POST /errors/:id/comments`**: JSON endpoint to add a comment.
|
|
20
|
+
- **`DELETE /errors/:id/comments/:cid`**: JSON endpoint to remove a comment.
|
|
21
|
+
- **`ErrorRadar::ErrorActivity`** model with `icon` helper mapping action names
|
|
22
|
+
to display glyphs (✓ resolved, ↩ reopened, 💬 commented, → assigned, …).
|
|
23
|
+
- **`ErrorRadar::ErrorComment`** model with `chronological` scope.
|
|
24
|
+
- All new features are **backward-compatible** — existing apps without the
|
|
25
|
+
migration see a panel with the migration command to run.
|
|
26
|
+
|
|
27
|
+
### Notes
|
|
28
|
+
- Activity logging is best-effort: if the activities table does not exist yet,
|
|
29
|
+
the action still succeeds and the log write is silently skipped.
|
|
30
|
+
- The `current_user` proc (`config.current_user`) is used as the default actor;
|
|
31
|
+
clients can also pass `author` in the comment POST body.
|
|
32
|
+
|
|
33
|
+
## [0.9.0] - 2026-07-03
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- **Digest email**: a periodic summary email (HTML + plain text) with:
|
|
37
|
+
- Summary row: new errors, open + in progress, resolved this period, total in DB
|
|
38
|
+
- Unresolved-by-severity bar chart (inline table)
|
|
39
|
+
- Top 10 unresolved errors (linked to detail page when `app_host` is set)
|
|
40
|
+
- New errors first seen in the period
|
|
41
|
+
- Unresolved-by-category breakdown
|
|
42
|
+
- "Open Error Radar →" button
|
|
43
|
+
- **`config.digest_enabled`** (default: `false`) — gates delivery; set `true`
|
|
44
|
+
then schedule the rake task.
|
|
45
|
+
- **`config.digest_recipients`** — separate recipient list for digests; falls
|
|
46
|
+
back to `config.email_recipients` if empty.
|
|
47
|
+
- **`ErrorRadar::Digest.deliver`** — programmatic delivery:
|
|
48
|
+
`ErrorRadar::Digest.deliver(since: 24.hours.ago, period: :daily)`
|
|
49
|
+
- **Rake tasks**:
|
|
50
|
+
- `rake error_radar:digest` — last 24 hours (daily digest)
|
|
51
|
+
- `rake error_radar:digest:weekly` — last 7 days (weekly digest)
|
|
52
|
+
- Both accept `SINCE="2024-01-01 08:00"` env override for custom windows
|
|
53
|
+
- **`DigestMailer`** (`app/mailers/error_radar/digest_mailer.rb`) —
|
|
54
|
+
ActionMailer class with HTML + text templates.
|
|
55
|
+
|
|
56
|
+
### Notes
|
|
57
|
+
- Requires ActionMailer configured in the host app (same requirement as the
|
|
58
|
+
existing `new_error` notification email).
|
|
59
|
+
- Scheduling is left to the host app's cron/scheduler — no in-process scheduler
|
|
60
|
+
dependency. Heroku Scheduler, whenever, clockwork, solid-cron all work.
|
|
61
|
+
- Subject line: `[App] Daily Digest — N new · N open · N resolved`
|
|
62
|
+
|
|
5
63
|
## [0.8.0] - 2026-07-03
|
|
6
64
|
|
|
7
65
|
### Added
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module ErrorRadar
|
|
4
4
|
class ErrorsController < ApplicationController
|
|
5
5
|
before_action :authenticate_request!
|
|
6
|
-
before_action :set_error, only: %i[show update_status destroy create_github_issue]
|
|
6
|
+
before_action :set_error, only: %i[show update_status destroy create_github_issue assign comment destroy_comment]
|
|
7
7
|
|
|
8
8
|
rescue_from ActiveRecord::RecordNotFound do
|
|
9
9
|
redirect_to errors_path, alert: 'Error not found.'
|
|
@@ -24,20 +24,81 @@ module ErrorRadar
|
|
|
24
24
|
|
|
25
25
|
def show
|
|
26
26
|
@occurrences = []
|
|
27
|
-
|
|
27
|
+
if ErrorRadar.config.track_occurrences
|
|
28
|
+
begin
|
|
29
|
+
@occ_page = [params[:occ_page].to_i, 1].max
|
|
30
|
+
occ_per_page = 20
|
|
31
|
+
@occ_total = @error.occurrences.count
|
|
32
|
+
@occ_total_pages = [(@occ_total.to_f / occ_per_page).ceil, 1].max
|
|
33
|
+
@occ_page = [@occ_page, @occ_total_pages].min
|
|
34
|
+
@occurrences = @error.occurrences.recent
|
|
35
|
+
.limit(occ_per_page)
|
|
36
|
+
.offset((@occ_page - 1) * occ_per_page)
|
|
37
|
+
rescue ActiveRecord::StatementInvalid
|
|
38
|
+
@occurrences = []
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@has_v100 = @error.class.column_names.include?('assigned_to')
|
|
43
|
+
@comments = []
|
|
44
|
+
@activities = []
|
|
45
|
+
if @has_v100
|
|
46
|
+
begin
|
|
47
|
+
@comments = @error.comments.chronological
|
|
48
|
+
@activities = @error.activities.recent.limit(50)
|
|
49
|
+
rescue ActiveRecord::StatementInvalid
|
|
50
|
+
@has_v100 = false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def assign
|
|
56
|
+
unless @error.class.column_names.include?('assigned_to')
|
|
57
|
+
return render json: { ok: false, error: 'Run upgrade_v100 migration first' }, status: :unprocessable_entity
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
assignee = params[:assigned_to].to_s.strip.truncate(200)
|
|
61
|
+
@error.update!(assigned_to: assignee.presence)
|
|
62
|
+
|
|
63
|
+
action_label = assignee.present? ? "assigned → #{assignee}" : 'unassigned'
|
|
64
|
+
log_activity(@error, action: action_label)
|
|
65
|
+
|
|
66
|
+
render json: { ok: true, assigned_to: @error.assigned_to }
|
|
67
|
+
end
|
|
28
68
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@occ_total_pages = [(@occ_total.to_f / occ_per_page).ceil, 1].max
|
|
34
|
-
@occ_page = [@occ_page, @occ_total_pages].min
|
|
35
|
-
@occurrences = @error.occurrences.recent
|
|
36
|
-
.limit(occ_per_page)
|
|
37
|
-
.offset((@occ_page - 1) * occ_per_page)
|
|
38
|
-
rescue ActiveRecord::StatementInvalid
|
|
39
|
-
@occurrences = []
|
|
69
|
+
def comment
|
|
70
|
+
body = params[:body].to_s.strip
|
|
71
|
+
if body.blank?
|
|
72
|
+
return render json: { ok: false, error: 'Comment cannot be blank' }, status: :unprocessable_entity
|
|
40
73
|
end
|
|
74
|
+
|
|
75
|
+
author = (error_radar_current_user.presence || params[:author].to_s.strip.presence || 'Anonymous').truncate(200)
|
|
76
|
+
cmt = ErrorComment.create!(error_log: @error, author: author, body: body)
|
|
77
|
+
|
|
78
|
+
log_activity(@error, action: 'commented', actor: author, detail: body.truncate(120))
|
|
79
|
+
|
|
80
|
+
render json: {
|
|
81
|
+
ok: true,
|
|
82
|
+
id: cmt.id,
|
|
83
|
+
author: cmt.author,
|
|
84
|
+
body: cmt.body,
|
|
85
|
+
created_at: cmt.created_at.strftime('%Y-%m-%d %H:%M')
|
|
86
|
+
}
|
|
87
|
+
rescue ActiveRecord::StatementInvalid
|
|
88
|
+
render json: { ok: false, error: 'Run upgrade_v100 migration first' }, status: :unprocessable_entity
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def destroy_comment
|
|
92
|
+
cmt = ErrorComment.find(params[:cid])
|
|
93
|
+
return head :not_found unless cmt.error_log_id == @error.id
|
|
94
|
+
|
|
95
|
+
cmt.destroy!
|
|
96
|
+
log_activity(@error, action: 'comment_deleted')
|
|
97
|
+
render json: { ok: true }
|
|
98
|
+
rescue ActiveRecord::RecordNotFound
|
|
99
|
+
head :not_found
|
|
100
|
+
rescue ActiveRecord::StatementInvalid
|
|
101
|
+
head :unprocessable_entity
|
|
41
102
|
end
|
|
42
103
|
|
|
43
104
|
def update_status
|
|
@@ -52,6 +113,7 @@ module ErrorRadar
|
|
|
52
113
|
@error.update!(status: new_status, resolved_at: nil)
|
|
53
114
|
end
|
|
54
115
|
|
|
116
|
+
log_activity(@error, action: new_status, detail: params[:note].presence)
|
|
55
117
|
render json: { ok: true, id: @error.id, status: @error.status }
|
|
56
118
|
end
|
|
57
119
|
|
|
@@ -164,6 +226,18 @@ module ErrorRadar
|
|
|
164
226
|
ErrorRadar.config.github_token.present? && ErrorRadar.config.github_repo.present?
|
|
165
227
|
end
|
|
166
228
|
|
|
229
|
+
def log_activity(error_log, action:, actor: nil, detail: nil)
|
|
230
|
+
return unless defined?(ErrorRadar::ErrorActivity)
|
|
231
|
+
ErrorRadar::ErrorActivity.create!(
|
|
232
|
+
error_log: error_log,
|
|
233
|
+
actor: (actor || error_radar_current_user).to_s.presence,
|
|
234
|
+
action: action.to_s,
|
|
235
|
+
detail: detail.to_s.truncate(500).presence
|
|
236
|
+
)
|
|
237
|
+
rescue StandardError
|
|
238
|
+
# silently skip if table not yet created or any other error
|
|
239
|
+
end
|
|
240
|
+
|
|
167
241
|
def active_filter_params
|
|
168
242
|
params.permit(:q, :status, :severity, :category, :from, :to, :sort, :order).to_h
|
|
169
243
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
class DigestMailer < ActionMailer::Base
|
|
5
|
+
layout false
|
|
6
|
+
|
|
7
|
+
def digest(data, period: :daily)
|
|
8
|
+
@data = data
|
|
9
|
+
@period = period
|
|
10
|
+
@app_name = ErrorRadar.config.app_name ||
|
|
11
|
+
(defined?(Rails) && Rails.application ? Rails.application.class.module_parent_name : 'App')
|
|
12
|
+
@app_host = ErrorRadar.config.app_host.to_s.chomp('/')
|
|
13
|
+
|
|
14
|
+
period_label = period == :weekly ? 'Weekly' : 'Daily'
|
|
15
|
+
subject = "[#{@app_name}] #{period_label} Digest — " \
|
|
16
|
+
"#{data[:new_this_period]} new · " \
|
|
17
|
+
"#{data[:unresolved]} open · " \
|
|
18
|
+
"#{data[:resolved_this_period]} resolved"
|
|
19
|
+
|
|
20
|
+
recipients = ErrorRadar.config.digest_recipients.any? ?
|
|
21
|
+
ErrorRadar.config.digest_recipients :
|
|
22
|
+
ErrorRadar.config.email_recipients
|
|
23
|
+
|
|
24
|
+
mail(
|
|
25
|
+
to: recipients,
|
|
26
|
+
from: ErrorRadar.config.email_from || 'noreply@localhost',
|
|
27
|
+
subject: subject
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
class ErrorActivity < ApplicationRecord
|
|
5
|
+
belongs_to :error_log
|
|
6
|
+
|
|
7
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
8
|
+
|
|
9
|
+
ACTION_ICONS = {
|
|
10
|
+
'resolved' => '✓',
|
|
11
|
+
'reopened' => '↩',
|
|
12
|
+
'in_progress' => '▶',
|
|
13
|
+
'open' => '○',
|
|
14
|
+
'ignored' => '–',
|
|
15
|
+
'assigned' => '→',
|
|
16
|
+
'unassigned' => '×',
|
|
17
|
+
'commented' => '💬',
|
|
18
|
+
'comment_deleted'=> '🗑'
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def icon
|
|
22
|
+
ACTION_ICONS.find { |k, _| action.to_s.start_with?(k) }&.last || '·'
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -20,6 +20,14 @@ module ErrorRadar
|
|
|
20
20
|
foreign_key: :error_log_id,
|
|
21
21
|
dependent: :delete_all
|
|
22
22
|
|
|
23
|
+
has_many :comments, class_name: 'ErrorRadar::ErrorComment',
|
|
24
|
+
foreign_key: :error_log_id,
|
|
25
|
+
dependent: :delete_all
|
|
26
|
+
|
|
27
|
+
has_many :activities, class_name: 'ErrorRadar::ErrorActivity',
|
|
28
|
+
foreign_key: :error_log_id,
|
|
29
|
+
dependent: :delete_all
|
|
30
|
+
|
|
23
31
|
validates :fingerprint, presence: true, uniqueness: true
|
|
24
32
|
validates :first_seen_at, :last_seen_at, presence: true
|
|
25
33
|
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<%
|
|
7
|
+
sev_colors = { 'critical' => '#7b001c', 'error' => '#dc3545', 'warning' => '#fd7e14', 'info' => '#17a2b8' }
|
|
8
|
+
period_label = @period == :weekly ? 'Weekly' : 'Daily'
|
|
9
|
+
fmt = ->(t) { t&.strftime('%Y-%m-%d %H:%M') }
|
|
10
|
+
%>
|
|
11
|
+
</head>
|
|
12
|
+
<body style="margin:0;padding:24px 8px;background:#f3f4f6;font-family:-apple-system,'Segoe UI',Roboto,Arial,sans-serif;color:#1f2937">
|
|
13
|
+
<div style="max-width:640px;margin:0 auto">
|
|
14
|
+
|
|
15
|
+
<%# ── Header ── %>
|
|
16
|
+
<div style="background:#1f2937;color:#fff;padding:20px 24px;border-radius:10px 10px 0 0">
|
|
17
|
+
<h1 style="margin:0 0 4px;font-size:18px">📊 <%= @app_name %> — <%= period_label %> Error Digest</h1>
|
|
18
|
+
<div style="font-size:12px;color:#9ca3af">
|
|
19
|
+
<%= fmt.call(@data[:period_start]) %> → <%= fmt.call(@data[:period_end]) %>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<%# ── Summary cards ── %>
|
|
24
|
+
<div style="background:#fff;padding:20px 24px">
|
|
25
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px">
|
|
26
|
+
<tr>
|
|
27
|
+
<td width="25%" style="padding:4px">
|
|
28
|
+
<div style="background:#fef3c7;border-radius:8px;padding:14px 10px;text-align:center">
|
|
29
|
+
<div style="font-size:26px;font-weight:700;color:#92400e"><%= @data[:new_this_period] %></div>
|
|
30
|
+
<div style="font-size:11px;color:#78350f;margin-top:2px">New errors</div>
|
|
31
|
+
</div>
|
|
32
|
+
</td>
|
|
33
|
+
<td width="25%" style="padding:4px">
|
|
34
|
+
<div style="background:#fee2e2;border-radius:8px;padding:14px 10px;text-align:center">
|
|
35
|
+
<div style="font-size:26px;font-weight:700;color:#991b1b"><%= @data[:unresolved] %></div>
|
|
36
|
+
<div style="font-size:11px;color:#7f1d1d;margin-top:2px">Open + in progress</div>
|
|
37
|
+
</div>
|
|
38
|
+
</td>
|
|
39
|
+
<td width="25%" style="padding:4px">
|
|
40
|
+
<div style="background:#d1fae5;border-radius:8px;padding:14px 10px;text-align:center">
|
|
41
|
+
<div style="font-size:26px;font-weight:700;color:#065f46"><%= @data[:resolved_this_period] %></div>
|
|
42
|
+
<div style="font-size:11px;color:#064e3b;margin-top:2px">Resolved</div>
|
|
43
|
+
</div>
|
|
44
|
+
</td>
|
|
45
|
+
<td width="25%" style="padding:4px">
|
|
46
|
+
<div style="background:#f3f4f6;border-radius:8px;padding:14px 10px;text-align:center">
|
|
47
|
+
<div style="font-size:26px;font-weight:700;color:#374151"><%= @data[:total] %></div>
|
|
48
|
+
<div style="font-size:11px;color:#6b7280;margin-top:2px">Total in DB</div>
|
|
49
|
+
</div>
|
|
50
|
+
</td>
|
|
51
|
+
</tr>
|
|
52
|
+
</table>
|
|
53
|
+
|
|
54
|
+
<%# ── Severity breakdown ── %>
|
|
55
|
+
<% if @data[:by_severity].any? %>
|
|
56
|
+
<div style="margin-bottom:24px">
|
|
57
|
+
<h2 style="margin:0 0 10px;font-size:14px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:.05em">Unresolved by severity</h2>
|
|
58
|
+
<table width="100%" cellpadding="0" cellspacing="0">
|
|
59
|
+
<% %w[critical error warning info].each do |sev| %>
|
|
60
|
+
<% count = @data[:by_severity][sev].to_i %>
|
|
61
|
+
<% next if count.zero? %>
|
|
62
|
+
<% pct = (@data[:unresolved] > 0 ? count.to_f / @data[:unresolved] * 100 : 0).round %>
|
|
63
|
+
<tr style="margin-bottom:6px">
|
|
64
|
+
<td width="80" style="padding:3px 0">
|
|
65
|
+
<span style="display:inline-block;padding:2px 10px;border-radius:10px;color:#fff;font-size:11px;font-weight:600;background:<%= sev_colors[sev] || '#6c757d' %>"><%= sev %></span>
|
|
66
|
+
</td>
|
|
67
|
+
<td style="padding:3px 8px">
|
|
68
|
+
<div style="background:#e5e7eb;border-radius:4px;height:10px;overflow:hidden">
|
|
69
|
+
<div style="background:<%= sev_colors[sev] || '#6c757d' %>;height:10px;width:<%= pct %>%"></div>
|
|
70
|
+
</div>
|
|
71
|
+
</td>
|
|
72
|
+
<td width="40" style="padding:3px 0;text-align:right;font-size:13px;font-weight:600"><%= count %></td>
|
|
73
|
+
</tr>
|
|
74
|
+
<% end %>
|
|
75
|
+
</table>
|
|
76
|
+
</div>
|
|
77
|
+
<% end %>
|
|
78
|
+
|
|
79
|
+
<%# ── Top unresolved ── %>
|
|
80
|
+
<% if @data[:top_unresolved].any? %>
|
|
81
|
+
<h2 style="margin:0 0 10px;font-size:14px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:.05em">Top 10 unresolved (by occurrences)</h2>
|
|
82
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;margin-bottom:24px">
|
|
83
|
+
<thead>
|
|
84
|
+
<tr style="background:#f9fafb;border-bottom:2px solid #e5e7eb">
|
|
85
|
+
<th style="text-align:left;padding:7px 8px;font-size:11px;color:#6b7280;font-weight:600">Error</th>
|
|
86
|
+
<th style="text-align:left;padding:7px 8px;font-size:11px;color:#6b7280;font-weight:600;width:70px">Severity</th>
|
|
87
|
+
<th style="text-align:right;padding:7px 8px;font-size:11px;color:#6b7280;font-weight:600;width:60px">Hits</th>
|
|
88
|
+
<th style="text-align:left;padding:7px 8px;font-size:11px;color:#6b7280;font-weight:600;width:100px">Last seen</th>
|
|
89
|
+
</tr>
|
|
90
|
+
</thead>
|
|
91
|
+
<tbody>
|
|
92
|
+
<% @data[:top_unresolved].each do |e| %>
|
|
93
|
+
<% url = @app_host.present? ? "#{@app_host}/error_radar/errors/#{e.id}" : nil %>
|
|
94
|
+
<tr style="border-bottom:1px solid #f3f4f6">
|
|
95
|
+
<td style="padding:8px 8px">
|
|
96
|
+
<div style="font-size:12px;font-family:monospace;font-weight:600;color:#1f2937">
|
|
97
|
+
<% if url %><a href="<%= url %>" style="color:#2563eb;text-decoration:none"><%= e.error_class %></a><% else %><%= e.error_class %><% end %>
|
|
98
|
+
</div>
|
|
99
|
+
<div style="font-size:11px;color:#6b7280;margin-top:2px"><%= e.short_message.truncate(80) %></div>
|
|
100
|
+
</td>
|
|
101
|
+
<td style="padding:8px 8px">
|
|
102
|
+
<span style="display:inline-block;padding:2px 8px;border-radius:8px;color:#fff;font-size:10px;font-weight:600;background:<%= sev_colors[e.severity] || '#6c757d' %>"><%= e.severity %></span>
|
|
103
|
+
</td>
|
|
104
|
+
<td style="padding:8px 8px;text-align:right;font-size:13px;font-weight:700;color:#1f2937"><%= e.occurrences %></td>
|
|
105
|
+
<td style="padding:8px 8px;font-size:11px;color:#6b7280"><%= e.last_seen_at&.strftime('%m/%d %H:%M') %></td>
|
|
106
|
+
</tr>
|
|
107
|
+
<% end %>
|
|
108
|
+
</tbody>
|
|
109
|
+
</table>
|
|
110
|
+
<% end %>
|
|
111
|
+
|
|
112
|
+
<%# ── New errors this period ── %>
|
|
113
|
+
<% if @data[:recent_new].any? %>
|
|
114
|
+
<h2 style="margin:0 0 10px;font-size:14px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:.05em">🆕 New this period (<%= @data[:new_this_period] %> total)</h2>
|
|
115
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;margin-bottom:24px">
|
|
116
|
+
<% @data[:recent_new].each do |e| %>
|
|
117
|
+
<% url = @app_host.present? ? "#{@app_host}/error_radar/errors/#{e.id}" : nil %>
|
|
118
|
+
<tr style="border-bottom:1px solid #f3f4f6">
|
|
119
|
+
<td style="padding:7px 8px">
|
|
120
|
+
<span style="display:inline-block;padding:2px 8px;border-radius:8px;color:#fff;font-size:10px;font-weight:600;background:<%= sev_colors[e.severity] || '#6c757d' %>"><%= e.severity %></span>
|
|
121
|
+
</td>
|
|
122
|
+
<td style="padding:7px 8px">
|
|
123
|
+
<div style="font-size:12px;font-family:monospace;font-weight:600">
|
|
124
|
+
<% if url %><a href="<%= url %>" style="color:#2563eb;text-decoration:none"><%= e.error_class %></a><% else %><%= e.error_class %><% end %>
|
|
125
|
+
</div>
|
|
126
|
+
<div style="font-size:11px;color:#6b7280"><%= e.short_message.truncate(90) %></div>
|
|
127
|
+
</td>
|
|
128
|
+
<td style="padding:7px 8px;font-size:11px;color:#6b7280;white-space:nowrap"><%= fmt.call(e.first_seen_at) %></td>
|
|
129
|
+
</tr>
|
|
130
|
+
<% end %>
|
|
131
|
+
</table>
|
|
132
|
+
<% end %>
|
|
133
|
+
|
|
134
|
+
<%# ── Category breakdown ── %>
|
|
135
|
+
<% if @data[:by_category].any? %>
|
|
136
|
+
<h2 style="margin:0 0 10px;font-size:14px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:.05em">Unresolved by category</h2>
|
|
137
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:20px">
|
|
138
|
+
<% @data[:by_category].sort_by { |_, v| -v }.each do |cat, count| %>
|
|
139
|
+
<tr>
|
|
140
|
+
<td width="140" style="padding:3px 0;font-size:12px;color:#374151"><%= cat.to_s.humanize %></td>
|
|
141
|
+
<td style="padding:3px 8px">
|
|
142
|
+
<div style="background:#e5e7eb;border-radius:4px;height:8px;overflow:hidden">
|
|
143
|
+
<% pct = (@data[:unresolved] > 0 ? count.to_f / @data[:unresolved] * 100 : 0).round %>
|
|
144
|
+
<div style="background:#2563eb;height:8px;width:<%= pct %>%"></div>
|
|
145
|
+
</div>
|
|
146
|
+
</td>
|
|
147
|
+
<td width="36" style="padding:3px 0;text-align:right;font-size:12px;font-weight:600;color:#374151"><%= count %></td>
|
|
148
|
+
</tr>
|
|
149
|
+
<% end %>
|
|
150
|
+
</table>
|
|
151
|
+
<% end %>
|
|
152
|
+
|
|
153
|
+
<% if @app_host.present? %>
|
|
154
|
+
<div style="text-align:center;margin-top:16px">
|
|
155
|
+
<a href="<%= @app_host %>/error_radar" style="display:inline-block;background:#2563eb;color:#fff;padding:11px 24px;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">Open Error Radar →</a>
|
|
156
|
+
</div>
|
|
157
|
+
<% end %>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<%# ── Footer ── %>
|
|
161
|
+
<div style="text-align:center;padding:16px 0;font-size:11px;color:#9ca3af">
|
|
162
|
+
Sent by <strong>Error Radar</strong> · <%= @app_name %> ·
|
|
163
|
+
Add <code>rake error_radar:digest</code> to your cron schedule.
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
</div>
|
|
167
|
+
</body>
|
|
168
|
+
</html>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<%= @period == :weekly ? 'Weekly' : 'Daily' %> Error Digest — <%= @app_name %>
|
|
2
|
+
<%= '=' * 60 %>
|
|
3
|
+
Period: <%= @data[:period_start]&.strftime('%Y-%m-%d %H:%M') %> → <%= @data[:period_end]&.strftime('%Y-%m-%d %H:%M') %>
|
|
4
|
+
|
|
5
|
+
SUMMARY
|
|
6
|
+
New errors this period : <%= @data[:new_this_period] %>
|
|
7
|
+
Resolved this period : <%= @data[:resolved_this_period] %>
|
|
8
|
+
Open + in progress : <%= @data[:unresolved] %>
|
|
9
|
+
Total in DB : <%= @data[:total] %>
|
|
10
|
+
|
|
11
|
+
UNRESOLVED BY SEVERITY
|
|
12
|
+
<% %w[critical error warning info].each do |sev| %>
|
|
13
|
+
<% count = @data[:by_severity][sev].to_i %><% next if count.zero? %>
|
|
14
|
+
<%= sev.ljust(10) %> <%= count %>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
TOP 10 UNRESOLVED (by occurrences)
|
|
18
|
+
<% @data[:top_unresolved].each_with_index do |e, i| %>
|
|
19
|
+
<%= (i + 1).to_s.rjust(2) %>. [<%= e.severity.upcase %>] <%= e.error_class %> (×<%= e.occurrences %>)
|
|
20
|
+
<%= e.short_message.truncate(100) %>
|
|
21
|
+
Last seen: <%= e.last_seen_at&.strftime('%Y-%m-%d %H:%M') %>
|
|
22
|
+
<% if @app_host.present? %>
|
|
23
|
+
<%= @app_host %>/error_radar/errors/<%= e.id %>
|
|
24
|
+
<% end %>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<% if @data[:recent_new].any? %>
|
|
28
|
+
NEW ERRORS THIS PERIOD (<%= @data[:new_this_period] %> total, showing <%= [@data[:recent_new].size, 10].min %>)
|
|
29
|
+
<% @data[:recent_new].each do |e| %>
|
|
30
|
+
· [<%= e.severity.upcase %>] <%= e.error_class %> — <%= e.first_seen_at&.strftime('%Y-%m-%d %H:%M') %>
|
|
31
|
+
<%= e.short_message.truncate(100) %>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% end %>
|
|
34
|
+
|
|
35
|
+
UNRESOLVED BY CATEGORY
|
|
36
|
+
<% @data[:by_category].sort_by { |_, v| -v }.each do |cat, count| %>
|
|
37
|
+
<%= cat.to_s.humanize.ljust(20) %> <%= count %>
|
|
38
|
+
<% end %>
|
|
39
|
+
|
|
40
|
+
<% if @app_host.present? %>
|
|
41
|
+
Open dashboard: <%= @app_host %>/error_radar
|
|
42
|
+
<% end %>
|
|
43
|
+
|
|
44
|
+
--
|
|
45
|
+
Sent by Error Radar · Add `rake error_radar:digest` to your cron schedule.
|
|
@@ -220,6 +220,113 @@
|
|
|
220
220
|
</div>
|
|
221
221
|
<% end %>
|
|
222
222
|
|
|
223
|
+
<%# ── Assignment, Comments & Activity ── %>
|
|
224
|
+
<% if @has_v100 %>
|
|
225
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
|
|
226
|
+
|
|
227
|
+
<%# Assignment %>
|
|
228
|
+
<div class="panel" id="assignment-panel">
|
|
229
|
+
<h2>Assignment</h2>
|
|
230
|
+
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
|
231
|
+
<div id="assignee-display" style="font-size:14px;color:#374151">
|
|
232
|
+
<% if @error.assigned_to.present? %>
|
|
233
|
+
Assigned to <strong><%= @error.assigned_to %></strong>
|
|
234
|
+
<% else %>
|
|
235
|
+
<span style="color:#9ca3af">Unassigned</span>
|
|
236
|
+
<% end %>
|
|
237
|
+
</div>
|
|
238
|
+
<button onclick="toggleAssignForm()"
|
|
239
|
+
style="padding:4px 12px;font-size:12px;border:1px solid #d1d5db;border-radius:6px;background:#f9fafb;cursor:pointer">
|
|
240
|
+
Change
|
|
241
|
+
</button>
|
|
242
|
+
</div>
|
|
243
|
+
<div id="assign-form" style="display:none;margin-top:10px">
|
|
244
|
+
<input id="assignee-input" type="text" value="<%= @error.assigned_to %>"
|
|
245
|
+
placeholder="Name or email…"
|
|
246
|
+
style="padding:6px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;width:200px">
|
|
247
|
+
<button onclick="doAssign()"
|
|
248
|
+
style="margin-left:6px;padding:6px 14px;background:#2563eb;color:#fff;border:0;border-radius:6px;font-size:13px;cursor:pointer">
|
|
249
|
+
Save
|
|
250
|
+
</button>
|
|
251
|
+
<button onclick="toggleAssignForm()"
|
|
252
|
+
style="margin-left:4px;padding:6px 10px;background:#f3f4f6;color:#374151;border:1px solid #d1d5db;border-radius:6px;font-size:13px;cursor:pointer">
|
|
253
|
+
Cancel
|
|
254
|
+
</button>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<%# Activity timeline %>
|
|
259
|
+
<div class="panel" id="activity-panel">
|
|
260
|
+
<h2>Activity <span style="font-size:12px;font-weight:400;color:#9ca3af">(<%= @activities.size %> events)</span></h2>
|
|
261
|
+
<% if @activities.empty? %>
|
|
262
|
+
<p style="font-size:13px;color:#9ca3af;margin:0">No activity yet.</p>
|
|
263
|
+
<% else %>
|
|
264
|
+
<div style="max-height:280px;overflow-y:auto">
|
|
265
|
+
<% @activities.each do |act| %>
|
|
266
|
+
<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid #f3f4f6;font-size:12px">
|
|
267
|
+
<span style="font-size:14px;min-width:20px;text-align:center"><%= act.icon %></span>
|
|
268
|
+
<div style="flex:1">
|
|
269
|
+
<span style="font-weight:600;color:#374151"><%= act.action %></span>
|
|
270
|
+
<% if act.detail.present? %>
|
|
271
|
+
<span style="color:#6b7280"> — <%= act.detail.truncate(80) %></span>
|
|
272
|
+
<% end %>
|
|
273
|
+
<div style="color:#9ca3af;font-size:11px;margin-top:1px">
|
|
274
|
+
<% if act.actor.present? %><%= act.actor %> · <% end %>
|
|
275
|
+
<%= act.created_at&.strftime('%Y-%m-%d %H:%M') %>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
<% end %>
|
|
280
|
+
</div>
|
|
281
|
+
<% end %>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<%# Comments %>
|
|
286
|
+
<div class="panel" style="margin-top:16px" id="comments-panel">
|
|
287
|
+
<h2>Comments <span style="font-size:12px;font-weight:400;color:#9ca3af">(<%= @comments.size %>)</span></h2>
|
|
288
|
+
|
|
289
|
+
<div id="comments-list">
|
|
290
|
+
<% @comments.each do |cmt| %>
|
|
291
|
+
<div class="er-comment" id="comment-<%= cmt.id %>"
|
|
292
|
+
style="border:1px solid #e5e7eb;border-radius:8px;padding:12px 14px;margin-bottom:10px">
|
|
293
|
+
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px">
|
|
294
|
+
<div style="font-size:12px;font-weight:600;color:#374151"><%= cmt.author.presence || 'Anonymous' %></div>
|
|
295
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
296
|
+
<span style="font-size:11px;color:#9ca3af"><%= cmt.created_at&.strftime('%Y-%m-%d %H:%M') %></span>
|
|
297
|
+
<button onclick="deleteComment(<%= cmt.id %>)"
|
|
298
|
+
style="font-size:11px;color:#dc3545;background:none;border:0;cursor:pointer;padding:0">✕</button>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
<div style="font-size:13px;color:#1f2937;white-space:pre-wrap"><%= cmt.body %></div>
|
|
302
|
+
</div>
|
|
303
|
+
<% end %>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<div style="margin-top:12px">
|
|
307
|
+
<textarea id="comment-body" rows="3" placeholder="Add a comment…"
|
|
308
|
+
style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:13px;resize:vertical;box-sizing:border-box"></textarea>
|
|
309
|
+
<div style="margin-top:6px;display:flex;justify-content:flex-end">
|
|
310
|
+
<button onclick="submitComment()"
|
|
311
|
+
style="padding:7px 18px;background:#2563eb;color:#fff;border:0;border-radius:8px;font-size:13px;cursor:pointer">
|
|
312
|
+
Add comment
|
|
313
|
+
</button>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
<% else %>
|
|
319
|
+
<div class="panel" style="margin-top:16px">
|
|
320
|
+
<h2>Assignment, Comments & Activity (v1.0.0)</h2>
|
|
321
|
+
<p style="font-size:13px;color:#6b7280;margin:0">
|
|
322
|
+
Run the upgrade migration to enable these features:
|
|
323
|
+
</p>
|
|
324
|
+
<code style="display:block;margin-top:8px;background:#f3f4f6;padding:10px 14px;border-radius:8px;font-size:12px">
|
|
325
|
+
bin/rails generate error_radar:upgrade_v100 && bin/rails db:migrate
|
|
326
|
+
</code>
|
|
327
|
+
</div>
|
|
328
|
+
<% end %>
|
|
329
|
+
|
|
223
330
|
<script>
|
|
224
331
|
window.toggleOcc = function (id) {
|
|
225
332
|
var el = document.getElementById(id);
|
|
@@ -276,6 +383,123 @@
|
|
|
276
383
|
});
|
|
277
384
|
};
|
|
278
385
|
|
|
386
|
+
// ── Assignment ──
|
|
387
|
+
var ASSIGN_URL = '<%= respond_to?(:error_assign_path) ? error_assign_path(@error) : "" %>';
|
|
388
|
+
var COMMENT_URL = '<%= respond_to?(:error_comments_path) ? error_comments_path(@error) : "" %>';
|
|
389
|
+
|
|
390
|
+
window.toggleAssignForm = function () {
|
|
391
|
+
var f = document.getElementById('assign-form');
|
|
392
|
+
if (f) f.style.display = f.style.display === 'none' ? 'block' : 'none';
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
window.doAssign = function () {
|
|
396
|
+
var val = document.getElementById('assignee-input').value.trim();
|
|
397
|
+
fetch(ASSIGN_URL, {
|
|
398
|
+
method: 'PATCH',
|
|
399
|
+
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
|
|
400
|
+
body: JSON.stringify({ assigned_to: val })
|
|
401
|
+
}).then(function (r) { return r.json(); })
|
|
402
|
+
.then(function (d) {
|
|
403
|
+
if (d.ok) {
|
|
404
|
+
var disp = document.getElementById('assignee-display');
|
|
405
|
+
disp.innerHTML = d.assigned_to
|
|
406
|
+
? 'Assigned to <strong>' + d.assigned_to + '</strong>'
|
|
407
|
+
: '<span style="color:#9ca3af">Unassigned</span>';
|
|
408
|
+
toggleAssignForm();
|
|
409
|
+
appendActivity({ icon: '→', action: d.assigned_to ? 'assigned → ' + d.assigned_to : 'unassigned' });
|
|
410
|
+
showToast(d.assigned_to ? 'Assigned to ' + d.assigned_to : 'Unassigned', true);
|
|
411
|
+
} else {
|
|
412
|
+
showToast(d.error || 'Failed', false);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// ── Comments ──
|
|
418
|
+
window.submitComment = function () {
|
|
419
|
+
var body = document.getElementById('comment-body').value.trim();
|
|
420
|
+
if (!body) { showToast('Comment cannot be blank', false); return; }
|
|
421
|
+
|
|
422
|
+
fetch(COMMENT_URL, {
|
|
423
|
+
method: 'POST',
|
|
424
|
+
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
|
|
425
|
+
body: JSON.stringify({ body: body })
|
|
426
|
+
}).then(function (r) { return r.json(); })
|
|
427
|
+
.then(function (d) {
|
|
428
|
+
if (d.ok) {
|
|
429
|
+
document.getElementById('comment-body').value = '';
|
|
430
|
+
var list = document.getElementById('comments-list');
|
|
431
|
+
list.insertAdjacentHTML('beforeend',
|
|
432
|
+
'<div class="er-comment" id="comment-' + d.id + '" style="border:1px solid #e5e7eb;border-radius:8px;padding:12px 14px;margin-bottom:10px">' +
|
|
433
|
+
'<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px">' +
|
|
434
|
+
'<div style="font-size:12px;font-weight:600;color:#374151">' + (d.author || 'Anonymous') + '</div>' +
|
|
435
|
+
'<div style="display:flex;gap:8px;align-items:center">' +
|
|
436
|
+
'<span style="font-size:11px;color:#9ca3af">' + d.created_at + '</span>' +
|
|
437
|
+
'<button onclick="deleteComment(' + d.id + ')" style="font-size:11px;color:#dc3545;background:none;border:0;cursor:pointer;padding:0">✕</button>' +
|
|
438
|
+
'</div></div>' +
|
|
439
|
+
'<div style="font-size:13px;color:#1f2937;white-space:pre-wrap">' + escHtml(d.body) + '</div>' +
|
|
440
|
+
'</div>'
|
|
441
|
+
);
|
|
442
|
+
appendActivity({ icon: '💬', action: 'commented', detail: d.body.substring(0, 80) });
|
|
443
|
+
updateCommentCount(1);
|
|
444
|
+
showToast('Comment added', true);
|
|
445
|
+
} else {
|
|
446
|
+
showToast(d.error || 'Failed', false);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
window.deleteComment = function (id) {
|
|
452
|
+
if (!confirm('Delete this comment?')) return;
|
|
453
|
+
fetch('<%= respond_to?(:error_comment_path) ? error_path(@error) : "" %>/comments/' + id, {
|
|
454
|
+
method: 'DELETE',
|
|
455
|
+
headers: { 'X-CSRF-Token': CSRF }
|
|
456
|
+
}).then(function (r) {
|
|
457
|
+
if (r.ok) {
|
|
458
|
+
var el = document.getElementById('comment-' + id);
|
|
459
|
+
if (el) el.remove();
|
|
460
|
+
appendActivity({ icon: '🗑', action: 'comment_deleted' });
|
|
461
|
+
updateCommentCount(-1);
|
|
462
|
+
showToast('Comment deleted', true);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
function updateCommentCount(delta) {
|
|
468
|
+
var h2 = document.querySelector('#comments-panel h2');
|
|
469
|
+
if (!h2) return;
|
|
470
|
+
var match = h2.textContent.match(/(\d+)/);
|
|
471
|
+
if (match) {
|
|
472
|
+
var n = parseInt(match[1], 10) + delta;
|
|
473
|
+
h2.innerHTML = 'Comments <span style="font-size:12px;font-weight:400;color:#9ca3af">(' + n + ')</span>';
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function appendActivity(act) {
|
|
478
|
+
var list = document.querySelector('#activity-panel [style*="max-height"]');
|
|
479
|
+
var empty = document.querySelector('#activity-panel p');
|
|
480
|
+
if (empty) { empty.remove(); }
|
|
481
|
+
if (!list) {
|
|
482
|
+
var panel = document.querySelector('#activity-panel');
|
|
483
|
+
if (!panel) return;
|
|
484
|
+
list = document.createElement('div');
|
|
485
|
+
list.style.cssText = 'max-height:280px;overflow-y:auto';
|
|
486
|
+
panel.appendChild(list);
|
|
487
|
+
}
|
|
488
|
+
var now = new Date();
|
|
489
|
+
var ts = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0') + ' ' + String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0');
|
|
490
|
+
list.insertAdjacentHTML('afterbegin',
|
|
491
|
+
'<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid #f3f4f6;font-size:12px">' +
|
|
492
|
+
'<span style="font-size:14px;min-width:20px;text-align:center">' + (act.icon || '·') + '</span>' +
|
|
493
|
+
'<div style="flex:1"><span style="font-weight:600;color:#374151">' + act.action + '</span>' +
|
|
494
|
+
(act.detail ? '<span style="color:#6b7280"> — ' + escHtml(act.detail) + '</span>' : '') +
|
|
495
|
+
'<div style="color:#9ca3af;font-size:11px;margin-top:1px">' + ts + '</div></div></div>'
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function escHtml(str) {
|
|
500
|
+
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
501
|
+
}
|
|
502
|
+
|
|
279
503
|
window.createGithubIssue = function () {
|
|
280
504
|
var btn = document.getElementById('github-btn');
|
|
281
505
|
var status = document.getElementById('github-status');
|
data/config/routes.rb
CHANGED
|
@@ -10,12 +10,15 @@ ErrorRadar::Engine.routes.draw do
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
# Web UI
|
|
13
|
-
get 'errors',
|
|
14
|
-
post 'errors/bulk',
|
|
15
|
-
post 'errors/:id/github_issue',
|
|
16
|
-
get 'errors/:id',
|
|
17
|
-
patch 'errors/:id/status',
|
|
18
|
-
|
|
13
|
+
get 'errors', to: 'errors#index', as: :errors
|
|
14
|
+
post 'errors/bulk', to: 'errors#bulk', as: :errors_bulk
|
|
15
|
+
post 'errors/:id/github_issue', to: 'errors#create_github_issue', as: :error_github_issue
|
|
16
|
+
get 'errors/:id', to: 'errors#show', as: :error
|
|
17
|
+
patch 'errors/:id/status', to: 'errors#update_status', as: :error_status
|
|
18
|
+
patch 'errors/:id/assign', to: 'errors#assign', as: :error_assign
|
|
19
|
+
post 'errors/:id/comments', to: 'errors#comment', as: :error_comments
|
|
20
|
+
delete 'errors/:id/comments/:cid', to: 'errors#destroy_comment', as: :error_comment
|
|
21
|
+
delete 'errors/:id', to: 'errors#destroy'
|
|
19
22
|
|
|
20
23
|
post 'maintenance/purge', to: 'dashboard#purge', as: :maintenance_purge
|
|
21
24
|
end
|
|
@@ -69,6 +69,12 @@ module ErrorRadar
|
|
|
69
69
|
@error_callbacks << block
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# Digest email ────────────────────────────────────────────────────────────
|
|
73
|
+
# Set to true to enable digest delivery (rake error_radar:digest).
|
|
74
|
+
attr_accessor :digest_enabled
|
|
75
|
+
# Separate recipient list for digests. Falls back to email_recipients if empty.
|
|
76
|
+
attr_accessor :digest_recipients
|
|
77
|
+
|
|
72
78
|
# Spike detection ─────────────────────────────────────────────────────────
|
|
73
79
|
# Number of occurrences within spike_window_minutes that triggers a spike alert.
|
|
74
80
|
# Add :spike to notify_on to enable: config.notify_on = [:new_error, :spike]
|
|
@@ -163,7 +169,10 @@ module ErrorRadar
|
|
|
163
169
|
@app_host = nil
|
|
164
170
|
@error_callbacks = []
|
|
165
171
|
|
|
166
|
-
@
|
|
172
|
+
@digest_enabled = false
|
|
173
|
+
@digest_recipients = []
|
|
174
|
+
|
|
175
|
+
@spike_threshold = 10
|
|
167
176
|
@spike_window_minutes = 5
|
|
168
177
|
|
|
169
178
|
@track_occurrences = false
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
# Gathers error statistics for a time window and delivers a digest email.
|
|
5
|
+
# Intended to be called from a cron job or scheduled task:
|
|
6
|
+
#
|
|
7
|
+
# # Daily (runs every morning via cron / Heroku Scheduler):
|
|
8
|
+
# rake error_radar:digest
|
|
9
|
+
#
|
|
10
|
+
# # Weekly:
|
|
11
|
+
# rake error_radar:digest:weekly
|
|
12
|
+
#
|
|
13
|
+
# # From code:
|
|
14
|
+
# ErrorRadar::Digest.deliver(since: 24.hours.ago, period: :daily)
|
|
15
|
+
module Digest
|
|
16
|
+
def self.deliver(since: nil, period: :daily)
|
|
17
|
+
return unless ErrorRadar.config.digest_enabled
|
|
18
|
+
|
|
19
|
+
since ||= period == :weekly ? 7.days.ago : 24.hours.ago
|
|
20
|
+
data = build(since: since)
|
|
21
|
+
|
|
22
|
+
require 'error_radar/mailers/digest_mailer'
|
|
23
|
+
DigestMailer.digest(data, period: period).deliver_now
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
ErrorRadar::Tracking.warn_internal("Digest.deliver failed: #{e.class}: #{e.message}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.build(since:)
|
|
29
|
+
now = Time.current
|
|
30
|
+
counts = ErrorLog.group(:status).count
|
|
31
|
+
inv = ErrorLog.statuses.invert
|
|
32
|
+
|
|
33
|
+
open_count = counts[ErrorLog.statuses['open']] || 0
|
|
34
|
+
in_prog_count = counts[ErrorLog.statuses['in_progress']] || 0
|
|
35
|
+
resolved = counts[ErrorLog.statuses['resolved']] || 0
|
|
36
|
+
ignored = counts[ErrorLog.statuses['ignored']] || 0
|
|
37
|
+
total = counts.values.sum
|
|
38
|
+
unresolved = open_count + in_prog_count
|
|
39
|
+
|
|
40
|
+
new_this_period = ErrorLog.where('first_seen_at >= ?', since).count
|
|
41
|
+
resolved_this_period = ErrorLog.where('resolved_at >= ?', since).count
|
|
42
|
+
reopened_this_period = ErrorLog.unresolved.where('resolved_at < ?', since)
|
|
43
|
+
.where('last_seen_at >= ?', since).count
|
|
44
|
+
|
|
45
|
+
top_unresolved = ErrorLog.unresolved.order(occurrences: :desc).limit(10).to_a
|
|
46
|
+
recent_new = ErrorLog.where('first_seen_at >= ?', since)
|
|
47
|
+
.order(first_seen_at: :desc).limit(10).to_a
|
|
48
|
+
|
|
49
|
+
by_severity = ErrorLog.unresolved.group(:severity).count
|
|
50
|
+
.transform_keys { |k| ErrorLog.severities.invert[k] || k.to_s }
|
|
51
|
+
by_category = ErrorLog.unresolved.group(:category).count
|
|
52
|
+
.transform_keys { |k| ErrorLog.categories.invert[k] || k.to_s }
|
|
53
|
+
|
|
54
|
+
trend = build_trend(since, now)
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
period_start: since,
|
|
58
|
+
period_end: now,
|
|
59
|
+
total: total,
|
|
60
|
+
open: open_count,
|
|
61
|
+
in_progress: in_prog_count,
|
|
62
|
+
unresolved: unresolved,
|
|
63
|
+
resolved_total: resolved,
|
|
64
|
+
ignored_total: ignored,
|
|
65
|
+
new_this_period: new_this_period,
|
|
66
|
+
resolved_this_period: resolved_this_period,
|
|
67
|
+
reopened_this_period: reopened_this_period,
|
|
68
|
+
top_unresolved: top_unresolved,
|
|
69
|
+
recent_new: recent_new,
|
|
70
|
+
by_severity: by_severity,
|
|
71
|
+
by_category: by_category,
|
|
72
|
+
trend: trend
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.build_trend(since, now)
|
|
77
|
+
days = [((now - since) / 86_400).ceil, 30].min
|
|
78
|
+
ErrorLog.where(last_seen_at: days.days.ago..)
|
|
79
|
+
.pluck(:last_seen_at)
|
|
80
|
+
.group_by { |t| t.to_date }
|
|
81
|
+
.transform_values(&:size)
|
|
82
|
+
.sort.to_h
|
|
83
|
+
.transform_keys { |d| d.strftime('%m/%d') }
|
|
84
|
+
end
|
|
85
|
+
private_class_method :build_trend
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/error_radar/version.rb
CHANGED
data/lib/error_radar.rb
CHANGED
|
@@ -6,6 +6,7 @@ require 'error_radar/tracking'
|
|
|
6
6
|
require 'error_radar/notifier'
|
|
7
7
|
require 'error_radar/cleanup'
|
|
8
8
|
require 'error_radar/spike_detector'
|
|
9
|
+
require 'error_radar/digest'
|
|
9
10
|
require 'error_radar/middleware'
|
|
10
11
|
require 'error_radar/server_monitor'
|
|
11
12
|
require 'error_radar/engine'
|
|
@@ -93,6 +93,20 @@ ErrorRadar.configure do |config|
|
|
|
93
93
|
# config.github_token = ENV['GITHUB_TOKEN'] # PAT with repo scope
|
|
94
94
|
# config.github_repo = 'myorg/myapp' # "owner/repo" format
|
|
95
95
|
|
|
96
|
+
# --- Assignment & Comments (v1.0.0) ---
|
|
97
|
+
# Requires running: bin/rails generate error_radar:upgrade_v100 && bin/rails db:migrate
|
|
98
|
+
# Adds assigned_to column, error_radar_comments and error_radar_activities tables.
|
|
99
|
+
# No config needed — use the UI to assign errors and add comments.
|
|
100
|
+
|
|
101
|
+
# --- Digest Email ---
|
|
102
|
+
# Sends a periodic summary of errors via email. Requires ActionMailer.
|
|
103
|
+
# Run via cron / Heroku Scheduler:
|
|
104
|
+
# rake error_radar:digest # last 24 hours (daily)
|
|
105
|
+
# rake error_radar:digest:weekly # last 7 days
|
|
106
|
+
# SINCE="2024-01-01 08:00" rake error_radar:digest # custom window
|
|
107
|
+
# config.digest_enabled = true
|
|
108
|
+
# config.digest_recipients = ['team@myapp.com'] # falls back to email_recipients
|
|
109
|
+
|
|
96
110
|
# --- Occurrence History ---
|
|
97
111
|
# Store each individual error hit so you can see context/backtrace per occurrence.
|
|
98
112
|
# Requires running: bin/rails generate error_radar:upgrade_v060 && bin/rails db:migrate
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class UpgradeErrorRadarToV100 < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
# Assignment field on the error log
|
|
6
|
+
add_column :error_radar_error_logs, :assigned_to, :string
|
|
7
|
+
|
|
8
|
+
# Comment thread per error
|
|
9
|
+
create_table :error_radar_comments do |t|
|
|
10
|
+
t.references :error_log, null: false,
|
|
11
|
+
foreign_key: { to_table: :error_radar_error_logs }
|
|
12
|
+
t.string :author
|
|
13
|
+
t.text :body, null: false
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Activity / audit log per error
|
|
18
|
+
create_table :error_radar_activities do |t|
|
|
19
|
+
t.references :error_log, null: false,
|
|
20
|
+
foreign_key: { to_table: :error_radar_error_logs },
|
|
21
|
+
index: false
|
|
22
|
+
t.string :actor # who performed the action
|
|
23
|
+
t.string :action, null: false # "resolved", "assigned → alice", "commented", ...
|
|
24
|
+
t.string :detail, limit: 500 # extra context (note preview, old→new value)
|
|
25
|
+
t.timestamps
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
add_index :error_radar_activities, %i[error_log_id created_at],
|
|
29
|
+
name: 'idx_er_activities_log_time'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators/active_record'
|
|
4
|
+
|
|
5
|
+
module ErrorRadar
|
|
6
|
+
module Generators
|
|
7
|
+
class UpgradeV100Generator < ActiveRecord::Generators::Base
|
|
8
|
+
source_root File.expand_path('templates', __dir__)
|
|
9
|
+
|
|
10
|
+
argument :name, type: :string, default: 'upgrade_v100'
|
|
11
|
+
|
|
12
|
+
desc 'Creates tables for assignment, comments and activity log (v1.0.0).'
|
|
13
|
+
|
|
14
|
+
def create_migration
|
|
15
|
+
migration_template 'upgrade_to_v100.rb.tt',
|
|
16
|
+
'db/migrate/upgrade_error_radar_to_v100.rb'
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/tasks/error_radar.rake
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
namespace :error_radar do
|
|
4
|
+
desc 'Send a daily digest email summarising the last 24 hours (requires config.digest_enabled = true)'
|
|
5
|
+
task digest: :environment do
|
|
6
|
+
require 'error_radar/digest'
|
|
7
|
+
since = ENV['SINCE'].presence ? Time.parse(ENV['SINCE']) : 24.hours.ago
|
|
8
|
+
ErrorRadar::Digest.deliver(since: since, period: :daily)
|
|
9
|
+
puts '[ErrorRadar] Daily digest sent.'
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
namespace :digest do
|
|
13
|
+
desc 'Send a weekly digest email summarising the last 7 days (requires config.digest_enabled = true)'
|
|
14
|
+
task weekly: :environment do
|
|
15
|
+
require 'error_radar/digest'
|
|
16
|
+
since = ENV['SINCE'].presence ? Time.parse(ENV['SINCE']) : 7.days.ago
|
|
17
|
+
ErrorRadar::Digest.deliver(since: since, period: :weekly)
|
|
18
|
+
puts '[ErrorRadar] Weekly digest sent.'
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
|
|
4
23
|
desc 'Delete old resolved/ignored ErrorLogs per config.retention_days and config.max_records'
|
|
5
24
|
task cleanup: :environment do
|
|
6
25
|
result = ErrorRadar::Cleanup.run
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: error_radar
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- chienbn9x
|
|
@@ -63,12 +63,17 @@ files:
|
|
|
63
63
|
- app/controllers/error_radar/application_controller.rb
|
|
64
64
|
- app/controllers/error_radar/dashboard_controller.rb
|
|
65
65
|
- app/controllers/error_radar/errors_controller.rb
|
|
66
|
+
- app/mailers/error_radar/digest_mailer.rb
|
|
66
67
|
- app/mailers/error_radar/error_mailer.rb
|
|
67
68
|
- app/models/error_radar/application_record.rb
|
|
69
|
+
- app/models/error_radar/error_activity.rb
|
|
70
|
+
- app/models/error_radar/error_comment.rb
|
|
68
71
|
- app/models/error_radar/error_log.rb
|
|
69
72
|
- app/models/error_radar/error_occurrence.rb
|
|
70
73
|
- app/views/error_radar/dashboard/index.html.erb
|
|
71
74
|
- app/views/error_radar/dashboard/show.html.erb
|
|
75
|
+
- app/views/error_radar/digest_mailer/digest.html.erb
|
|
76
|
+
- app/views/error_radar/digest_mailer/digest.text.erb
|
|
72
77
|
- app/views/error_radar/error_mailer/new_error.html.erb
|
|
73
78
|
- app/views/error_radar/error_mailer/new_error.text.erb
|
|
74
79
|
- app/views/error_radar/errors/index.html.erb
|
|
@@ -79,6 +84,7 @@ files:
|
|
|
79
84
|
- lib/error_radar/capture_job.rb
|
|
80
85
|
- lib/error_radar/cleanup.rb
|
|
81
86
|
- lib/error_radar/configuration.rb
|
|
87
|
+
- lib/error_radar/digest.rb
|
|
82
88
|
- lib/error_radar/engine.rb
|
|
83
89
|
- lib/error_radar/integrations/active_job.rb
|
|
84
90
|
- lib/error_radar/integrations/github.rb
|
|
@@ -102,6 +108,8 @@ files:
|
|
|
102
108
|
- lib/generators/error_radar/upgrade_v050/upgrade_v050_generator.rb
|
|
103
109
|
- lib/generators/error_radar/upgrade_v060/templates/create_error_radar_occurrences.rb.tt
|
|
104
110
|
- lib/generators/error_radar/upgrade_v060/upgrade_v060_generator.rb
|
|
111
|
+
- lib/generators/error_radar/upgrade_v100/templates/upgrade_to_v100.rb.tt
|
|
112
|
+
- lib/generators/error_radar/upgrade_v100/upgrade_v100_generator.rb
|
|
105
113
|
- lib/tasks/error_radar.rake
|
|
106
114
|
homepage: https://github.com/chienbn9x/error_radar
|
|
107
115
|
licenses:
|