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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1522c6f867cba16786c499703bf76e18d8d7134109f8c925387a628fd0b7f420
4
- data.tar.gz: f9bfc6fe5a5de9e8394b3c81a4b41913b82ed22e5f56ef72980936dc9ecc59f9
3
+ metadata.gz: 01352c602172578657721bce465d06d1db4f3c077cedcecbeb9742eae276764e
4
+ data.tar.gz: e9a7d7c7be46a629b96d65cdbfec77bbb3b3a25d23acc07c8f52b45ea8ba9d0a
5
5
  SHA512:
6
- metadata.gz: 13ae125f05a92939e320a4af5a55521c4f8bddde93fe702b44a2cc0919f2f47c754ed39f440787cb94040676e22d1efaa5a65195e306c955ca2be3cce70e4e8b
7
- data.tar.gz: 782931704d0808b5c527a542ab2019b6f49726d9d8b7321ab63931c0788672edaa65eac04a5790f768a35c12296ae12233a38eb60dcfbb668c91535663c5e1cc
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
- return unless ErrorRadar.config.track_occurrences
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
- begin
30
- @occ_page = [params[:occ_page].to_i, 1].max
31
- occ_per_page = 20
32
- @occ_total = @error.occurrences.count
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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErrorRadar
4
+ class ErrorComment < ApplicationRecord
5
+ belongs_to :error_log
6
+
7
+ validates :body, presence: true
8
+
9
+ scope :chronological, -> { order(created_at: :asc) }
10
+ end
11
+ 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 &amp; 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 &amp;&amp; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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', 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
- delete 'errors/:id', to: 'errors#destroy'
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
- @spike_threshold = 10
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ErrorRadar
4
- VERSION = '0.8.0'
4
+ VERSION = '1.0.0'
5
5
  end
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
@@ -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.8.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: