error_radar 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 017c3eb241a062270368e74bf4a38e52caaf303ddfdc1848c3e1f88d0ea8219a
4
+ data.tar.gz: '08d6b0982d7cfae71bdde4c466948b0091e1c000091030a8c6c5507139c32136'
5
+ SHA512:
6
+ metadata.gz: 887a6d150b9e3bbcade9f208f80b0ef89755b229716cf1d2798bd72fb1feb4b13a8feb480178fda95bf36e9b291426895066e5043ea22a48ac830f86887d8de6
7
+ data.tar.gz: 67e779feaef2bafc575a3aa6b52c1d8af20e907d22edc1b4e769a5cab6a88969984503b74c9a1f6df70b51367d98ed5d7cb254b2ea405741bef4c09600057179
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.0] - 2026-06-18
6
+
7
+ ### Added
8
+ - Initial release.
9
+ - Captures unhandled exceptions from controllers, Rack, Sidekiq and ActiveJob.
10
+ - Deduplicates errors by fingerprint.
11
+ - Kanban dashboard (and optional RailsAdmin board) to triage errors as tasks.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright chienbn9x
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # Error Radar
2
+
3
+ Drop-in error tracking & task board for Rails apps. Captures unhandled
4
+ exceptions from **controllers, Rack, Sidekiq and ActiveJob**, deduplicates them
5
+ by fingerprint (a flood of the same failure stays one task with an occurrence
6
+ count), and ships a kanban dashboard — plus an optional RailsAdmin board — to
7
+ triage them as tasks (`open → in_progress → resolved → ignored`).
8
+
9
+ It is built as a **mountable Rails Engine**, so the model, middleware, routes,
10
+ dashboard and migration all come from the gem. Everything app-specific
11
+ (authentication, custom exception types, server expectations) is injected via a
12
+ small config block.
13
+
14
+ ## Requirements
15
+
16
+ - Rails `>= 7.0`, Ruby `>= 3.0`
17
+ - Optional: Sidekiq (job capture + server panel), RailsAdmin (admin board)
18
+
19
+ > Rails 8 note: the model uses the classic `enum name: {...}` form. On Rails 8
20
+ > switch it to `enum :name, {...}` (see `app/models/error_radar/error_log.rb`).
21
+
22
+ ## Install
23
+
24
+ ```ruby
25
+ # Gemfile
26
+ gem 'error_radar', git: 'https://github.com/chienbn9x/error_radar.git'
27
+ # or, while developing locally:
28
+ gem 'error_radar', path: '../error_radar'
29
+ ```
30
+
31
+ ```bash
32
+ bundle install
33
+ bin/rails generate error_radar:install # creates initializer + migration
34
+ bin/rails db:migrate
35
+ ```
36
+
37
+ Mount the dashboard:
38
+
39
+ ```ruby
40
+ # config/routes.rb
41
+ authenticate :admin do # your own guard, optional
42
+ mount ErrorRadar::Engine, at: '/monitoring'
43
+ end
44
+ ```
45
+
46
+ Visit `/monitoring`.
47
+
48
+ ## Configure
49
+
50
+ `config/initializers/error_radar.rb` (generated):
51
+
52
+ ```ruby
53
+ ErrorRadar.configure do |config|
54
+ config.enabled = !Rails.env.test?
55
+
56
+ # Dashboard auth
57
+ config.authenticate = ->(controller) { controller.send(:authenticate_admin!) }
58
+ config.current_user = ->(controller) { controller.current_admin&.email }
59
+
60
+ # Teach it about your own API error type
61
+ config.categorize { |e| :external_api if e.is_a?(MyApi::Error) }
62
+ config.extract_details do |e|
63
+ { http_status: e.status, request_url: e.url, api_code: e.code } if e.is_a?(MyApi::Error)
64
+ end
65
+ end
66
+ ```
67
+
68
+ ## Use
69
+
70
+ ```ruby
71
+ # Capture an exception with context
72
+ ErrorRadar.capture(e, source: 'HealthController#index', context: { check: :redis })
73
+
74
+ # Log a problem without an exception
75
+ ErrorRadar.notify('Webhook signature mismatch', category: :external_api, severity: :warning)
76
+
77
+ # Wrap a boundary the middleware/Sidekiq hooks don't cover (rake tasks, cron)
78
+ ErrorRadar.monitor('NightlyReindex', context: { batch: 1 }) { do_work }
79
+ ```
80
+
81
+ Web requests and Sidekiq jobs are captured automatically once installed. For
82
+ non-Sidekiq ActiveJob adapters, include the concern:
83
+
84
+ ```ruby
85
+ class ApplicationJob < ActiveJob::Base
86
+ include ErrorRadar::Integrations::ActiveJob
87
+ end
88
+ ```
89
+
90
+ ## What gets stored
91
+
92
+ `ErrorRadar::ErrorLog` (`error_radar_error_logs`): category, severity, status,
93
+ error_class, source, message, backtrace, JSON context, HTTP/API fields,
94
+ occurrence count, first/last seen, and resolution metadata. Identical errors
95
+ roll up by a SHA1 fingerprint of `(category, error_class, source,
96
+ normalized_message)`.
97
+
98
+ ## Design notes
99
+
100
+ - **Never raises.** Every capture path rescues internally and logs to
101
+ `Rails.logger` — error tracking must not break the code it watches.
102
+ - **Decoupled.** No host model, controller or constant is referenced directly;
103
+ app-specifics arrive through `ErrorRadar.config`.
104
+ - **Self-contained dashboard.** Charts use Chart.js from a CDN (no `chartkick`
105
+ dependency); the kanban uses SortableJS.
106
+ ```
data/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+
5
+ task default: :build
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErrorRadar
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+ layout 'error_radar/application'
7
+
8
+ private
9
+
10
+ # Delegates to the host-configured auth proc. nil => open access.
11
+ def authenticate_request!
12
+ auth = ErrorRadar.config.authenticate
13
+ return if auth.nil?
14
+
15
+ auth.call(self)
16
+ end
17
+
18
+ def error_radar_current_user
19
+ cu = ErrorRadar.config.current_user
20
+ cu.respond_to?(:call) ? cu.call(self) : nil
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErrorRadar
4
+ # Error-monitoring dashboard: summary stats, simple charts and a drag-and-drop
5
+ # kanban board over ErrorLog statuses.
6
+ class DashboardController < ApplicationController
7
+ before_action :authenticate_request!
8
+ before_action :set_error, only: %i[show update_status]
9
+
10
+ KANBAN_LIMIT = 100
11
+ SEVERITY_ORDER = { 'critical' => 0, 'error' => 1, 'warning' => 2, 'info' => 3 }.freeze
12
+
13
+ def index
14
+ @total = ErrorLog.count
15
+ @open_count = ErrorLog.status_open.count
16
+ @in_progress = ErrorLog.status_in_progress.count
17
+ @resolved_count = ErrorLog.status_resolved.count
18
+ @ignored_count = ErrorLog.status_ignored.count
19
+ @unresolved = @open_count + @in_progress
20
+
21
+ @by_category = ErrorLog.unresolved.group(:category).count
22
+ @by_severity = ErrorLog.unresolved.group(:severity).count
23
+
24
+ # Distinct error-tasks last seen per day (last 30 days), grouped in Ruby to
25
+ # avoid relying on DB named-timezone tables.
26
+ @trend = ErrorLog.where(last_seen_at: 30.days.ago..)
27
+ .pluck(:last_seen_at)
28
+ .group_by { |t| t.in_time_zone.to_date }
29
+ .transform_values(&:size)
30
+ .sort.to_h
31
+ .transform_keys { |d| d.strftime('%m/%d') }
32
+
33
+ @top = ErrorLog.unresolved.order(occurrences: :desc).limit(10)
34
+
35
+ monitor = ErrorRadar::ServerMonitor.new
36
+ @servers = monitor.statuses
37
+ @unexpected = monitor.unexpected_processes
38
+
39
+ @columns = ErrorLog.statuses.keys.index_with do |status|
40
+ ErrorLog.where(status: ErrorLog.statuses[status])
41
+ .order(last_seen_at: :desc)
42
+ .limit(KANBAN_LIMIT)
43
+ .to_a
44
+ .sort_by { |e| [SEVERITY_ORDER.fetch(e.severity, 9), -e.last_seen_at.to_i] }
45
+ end
46
+
47
+ @external_links = build_external_links
48
+ end
49
+
50
+ def show; end
51
+
52
+ def update_status
53
+ new_status = params[:status].to_s
54
+ unless ErrorLog.statuses.key?(new_status)
55
+ return render json: { ok: false, error: 'invalid status' }, status: :unprocessable_entity
56
+ end
57
+
58
+ if new_status == 'resolved'
59
+ @error.resolve!(by: error_radar_current_user)
60
+ else
61
+ @error.update!(status: new_status, resolved_at: nil)
62
+ end
63
+
64
+ render json: { ok: true, id: @error.id, status: @error.status }
65
+ end
66
+
67
+ private
68
+
69
+ def set_error
70
+ @error = ErrorLog.find(params[:id])
71
+ end
72
+
73
+ def build_external_links
74
+ links = {}
75
+ if defined?(::RailsAdmin)
76
+ links[:rails_admin] = (rails_admin.index_path(model_name: 'error_radar~error_log') rescue nil)
77
+ end
78
+ links[:sidekiq] = '/sidekiq' if defined?(::Sidekiq::Web)
79
+ links.compact
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErrorRadar
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module ErrorRadar
6
+ # One row per distinct failure (collapsed by fingerprint). Doubles as a task
7
+ # on the triage board. Table: error_radar_error_logs.
8
+ class ErrorLog < ApplicationRecord
9
+ enum category: {
10
+ application: 0, # generic Ruby/Rails runtime error
11
+ external_api: 1, # any 3rd-party API error
12
+ background_job: 2, # uncategorised background-job failure
13
+ syntax: 3, # SyntaxError / NameError / NoMethodError / ArgumentError / TypeError
14
+ database: 4, # ActiveRecord / DB level
15
+ network: 5 # timeouts, connection resets, DNS, ...
16
+ }, _prefix: :category
17
+
18
+ enum severity: { info: 0, warning: 1, error: 2, critical: 3 }, _prefix: :severity
19
+
20
+ enum status: { open: 0, in_progress: 1, resolved: 2, ignored: 3 }, _prefix: :status
21
+
22
+ validates :fingerprint, presence: true, uniqueness: true
23
+ validates :first_seen_at, :last_seen_at, presence: true
24
+
25
+ scope :open, -> { where(status: statuses[:open]) }
26
+ scope :in_progress, -> { where(status: statuses[:in_progress]) }
27
+ scope :resolved, -> { where(status: statuses[:resolved]) }
28
+ scope :ignored, -> { where(status: statuses[:ignored]) }
29
+ scope :unresolved, -> { where(status: [statuses[:open], statuses[:in_progress]]) }
30
+ scope :recent, -> { order(last_seen_at: :desc) }
31
+
32
+ # Record (or roll-up) an error. Idempotent per fingerprint: identical errors
33
+ # increment `occurrences` and bump `last_seen_at` instead of creating a new
34
+ # row. NEVER raises — logging must not break the calling code path.
35
+ def self.record(category:, message:, severity: :error, error_class: nil, source: nil,
36
+ backtrace: nil, context: {}, http_status: nil, request_url: nil,
37
+ api_code: nil, api_subcode: nil, fingerprint: nil)
38
+ now = Time.current
39
+ fp = presence(fingerprint) || build_fingerprint(category: category, error_class: error_class, source: source, message: message)
40
+
41
+ log = find_or_initialize_by(fingerprint: fp)
42
+
43
+ if log.persisted?
44
+ log.occurrences += 1
45
+ log.status = :open if log.status_resolved? || log.status_ignored?
46
+ else
47
+ log.assign_attributes(
48
+ category: category, severity: severity, error_class: error_class, source: source,
49
+ http_status: http_status, request_url: request_url, api_code: api_code, api_subcode: api_subcode,
50
+ first_seen_at: now, status: :open
51
+ )
52
+ end
53
+
54
+ log.message = message.to_s.truncate(ErrorRadar.config.max_message_length)
55
+ log.backtrace = presence(Array(backtrace).join("\n")) || log.backtrace
56
+ log.context = (log.context || {}).merge(context.presence || {}).deep_stringify_keys if context.present? || log.context
57
+ log.severity = severity if log.new_record? || severity_rank(severity) > severity_rank(log.severity)
58
+ log.last_seen_at = now
59
+ log.save!
60
+ log
61
+ rescue StandardError => e
62
+ ErrorRadar::Tracking.warn_internal("ErrorLog.record failed: #{e.class}: #{e.message}")
63
+ nil
64
+ end
65
+
66
+ def self.build_fingerprint(category:, error_class:, source:, message:)
67
+ normalized = message.to_s
68
+ .gsub(/\d+/, '#') # ids, counts, timestamps
69
+ .gsub(/0x[0-9a-f]+/i, '0x#') # object addresses
70
+ .gsub(/[0-9a-f]{8}-[0-9a-f-]{27}/i, '#') # uuids
71
+ .strip
72
+ Digest::SHA1.hexdigest([category, error_class, source, normalized].join('|'))
73
+ end
74
+
75
+ def self.severity_rank(value)
76
+ severities[value.to_s] || 0
77
+ end
78
+
79
+ def self.presence(value)
80
+ value.respond_to?(:empty?) ? (value.empty? ? nil : value) : value
81
+ end
82
+
83
+ def short_message
84
+ message.to_s.truncate(120)
85
+ end
86
+
87
+ def resolve!(by: nil, note: nil)
88
+ update!(status: :resolved, resolved_at: Time.current, resolved_by: by, resolution_note: note.presence || resolution_note)
89
+ end
90
+
91
+ def reopen!
92
+ update!(status: :open, resolved_at: nil)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,185 @@
1
+ <%
2
+ sev_colors = { 'info' => '#17a2b8', 'warning' => '#fd7e14', 'error' => '#dc3545', 'critical' => '#7b001c' }
3
+ status_labels = { 'open' => 'Open', 'in_progress' => 'In progress', 'resolved' => 'Resolved', 'ignored' => 'Ignored' }
4
+ %>
5
+
6
+ <h1>🛰 Error Radar</h1>
7
+ <div class="sub">
8
+ Track &amp; triage system errors
9
+ <% @external_links.each do |label, href| %>
10
+ · <%= link_to(label.to_s.humanize, href) %>
11
+ <% end %>
12
+ </div>
13
+
14
+ <div class="cards">
15
+ <div class="card tot"><div class="n"><%= @total %></div><div class="l">Total</div></div>
16
+ <div class="card open"><div class="n"><%= @open_count %></div><div class="l">Open</div></div>
17
+ <div class="card prog"><div class="n"><%= @in_progress %></div><div class="l">In progress</div></div>
18
+ <div class="card res"><div class="n"><%= @resolved_count %></div><div class="l">Resolved</div></div>
19
+ <div class="card ign"><div class="n"><%= @ignored_count %></div><div class="l">Ignored</div></div>
20
+ </div>
21
+
22
+ <% if (@servers || []).any? %>
23
+ <div class="panel" style="margin-bottom:20px">
24
+ <h2>Job Servers (Sidekiq · realtime from Redis)</h2>
25
+ <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px">
26
+ <% @servers.each do |s| %>
27
+ <div style="border:1px solid #e5e7eb;border-radius:10px;padding:12px;border-left:5px solid <%= s.up ? '#28a745' : '#dc3545' %>">
28
+ <div style="display:flex;justify-content:space-between;align-items:center">
29
+ <strong style="font-size:13px"><%= s.name %></strong>
30
+ <span class="badge" style="background: <%= s.up ? '#28a745' : '#dc3545' %>"><%= s.up ? 'UP' : 'DOWN' %></span>
31
+ </div>
32
+ <% if s.processes.any? %>
33
+ <% p = s.processes.first %>
34
+ <div style="font-size:11px;color:#6b7280;margin-top:6px">
35
+ host: <%= p[:hostname] %> · tag: <%= p[:tag] %><br>
36
+ busy <%= p[:busy] %>/<%= p[:concurrency] %> · RSS <%= p[:rss] %>KB<br>
37
+ heartbeat: <%= s.last_beat_ago %>s ago<%= ' · QUIET' if p[:quiet] %><br>
38
+ queues: <%= p[:queues].join(', ') %>
39
+ </div>
40
+ <% else %>
41
+ <div style="font-size:11px;color:#dc3545;margin-top:6px">No live process found.</div>
42
+ <% end %>
43
+ </div>
44
+ <% end %>
45
+ </div>
46
+ <% if (@unexpected || []).any? %>
47
+ <div style="font-size:11px;color:#6b7280;margin-top:10px">
48
+ Unregistered processes (<%= @unexpected.size %>):
49
+ <%= @unexpected.map { |p| "#{p[:hostname]}/#{p[:tag]} [#{p[:queues].join(',')}]" }.join(' · ') %>
50
+ </div>
51
+ <% end %>
52
+ </div>
53
+ <% end %>
54
+
55
+ <div class="grid2">
56
+ <div class="panel">
57
+ <h2>Errors per day (30 days · by occurrences)</h2>
58
+ <canvas id="chart-trend" height="120"
59
+ data-type="line"
60
+ data-values="<%= @trend.to_json %>"
61
+ data-color="#dc3545"></canvas>
62
+ </div>
63
+ <div class="panel">
64
+ <h2>Unresolved — by category</h2>
65
+ <canvas id="chart-category" height="120"
66
+ data-type="doughnut"
67
+ data-values="<%= @by_category.to_json %>"></canvas>
68
+ </div>
69
+ </div>
70
+
71
+ <div class="grid2">
72
+ <div class="panel">
73
+ <h2>Top 10 errors (unresolved, by occurrences)</h2>
74
+ <table>
75
+ <thead><tr><th>Severity</th><th>Source</th><th>Message</th><th>Count</th><th>Last seen</th></tr></thead>
76
+ <tbody>
77
+ <% @top.each do |e| %>
78
+ <tr>
79
+ <td><span class="badge" style="background: <%= sev_colors[e.severity] %>"><%= e.severity %></span></td>
80
+ <td><%= link_to e.source.presence || e.error_class, error_path(e) %></td>
81
+ <td><%= e.short_message %></td>
82
+ <td><%= e.occurrences %></td>
83
+ <td><%= e.last_seen_at&.strftime('%m/%d %H:%M') %></td>
84
+ </tr>
85
+ <% end %>
86
+ </tbody>
87
+ </table>
88
+ </div>
89
+ <div class="panel">
90
+ <h2>Unresolved — by severity</h2>
91
+ <canvas id="chart-severity" height="120"
92
+ data-type="bar"
93
+ data-values="<%= @by_severity.to_json %>"
94
+ data-color="#fd7e14"></canvas>
95
+ </div>
96
+ </div>
97
+
98
+ <div class="panel" style="margin-bottom:20px">
99
+ <h2>Kanban — drag a card to change status</h2>
100
+ <div class="kanban">
101
+ <% %w[open in_progress resolved ignored].each do |status| %>
102
+ <% items = @columns[status] || [] %>
103
+ <div class="col">
104
+ <h3><%= status_labels[status] %> <span class="count"><%= items.size %></span></h3>
105
+ <div class="col-list" data-status="<%= status %>">
106
+ <% items.each do |e| %>
107
+ <div class="ticket sev-<%= e.severity %>" data-id="<%= e.id %>" data-url="<%= error_status_path(e) %>">
108
+ <div class="src"><%= link_to (e.source.presence || e.error_class || 'unknown'), error_path(e) %></div>
109
+ <div class="msg"><%= e.short_message %></div>
110
+ <div class="meta">
111
+ <span><%= e.category %></span>
112
+ <span class="occ">×<%= e.occurrences %></span>
113
+ <span><%= e.last_seen_at&.strftime('%m/%d %H:%M') %></span>
114
+ </div>
115
+ </div>
116
+ <% end %>
117
+ </div>
118
+ </div>
119
+ <% end %>
120
+ </div>
121
+ </div>
122
+
123
+ <script>
124
+ (function () {
125
+ var token = document.querySelector('meta[name="csrf-token"]').content;
126
+
127
+ // --- charts (Chart.js) ---
128
+ var palette = ['#dc3545', '#fd7e14', '#17a2b8', '#7b001c', '#28a745', '#6c757d', '#2563eb'];
129
+ document.querySelectorAll('canvas[data-values]').forEach(function (el) {
130
+ var values = JSON.parse(el.dataset.values || '{}');
131
+ var labels = Object.keys(values);
132
+ var data = labels.map(function (k) { return values[k]; });
133
+ var type = el.dataset.type || 'bar';
134
+ var single = el.dataset.color || '#2563eb';
135
+ new Chart(el, {
136
+ type: type,
137
+ data: {
138
+ labels: labels,
139
+ datasets: [{
140
+ data: data,
141
+ backgroundColor: (type === 'doughnut') ? palette : single,
142
+ borderColor: single,
143
+ fill: false,
144
+ tension: 0.3
145
+ }]
146
+ },
147
+ options: {
148
+ plugins: { legend: { display: type === 'doughnut' } },
149
+ scales: (type === 'doughnut') ? {} : { y: { beginAtZero: true } }
150
+ }
151
+ });
152
+ });
153
+
154
+ // --- kanban ---
155
+ function toast(msg, ok) {
156
+ var t = document.getElementById('toast');
157
+ t.textContent = msg; t.style.background = ok ? '#16a34a' : '#dc2626';
158
+ t.classList.add('show'); setTimeout(function () { t.classList.remove('show'); }, 2000);
159
+ }
160
+ document.querySelectorAll('.col-list').forEach(function (list) {
161
+ Sortable.create(list, {
162
+ group: 'kanban', animation: 150,
163
+ onAdd: function (evt) {
164
+ var url = evt.item.dataset.url;
165
+ var status = evt.to.dataset.status;
166
+ fetch(url, {
167
+ method: 'PATCH',
168
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
169
+ body: JSON.stringify({ status: status })
170
+ }).then(function (r) { return r.json(); })
171
+ .then(function (d) {
172
+ if (d.ok) { toast('Moved #' + d.id + ' → ' + d.status, true); updateCounts(); }
173
+ else { toast('Error: ' + (d.error || 'update failed'), false); }
174
+ })
175
+ .catch(function () { toast('Network error on update', false); });
176
+ }
177
+ });
178
+ });
179
+ function updateCounts() {
180
+ document.querySelectorAll('.col').forEach(function (col) {
181
+ col.querySelector('.count').textContent = col.querySelectorAll('.ticket').length;
182
+ });
183
+ }
184
+ })();
185
+ </script>
@@ -0,0 +1,72 @@
1
+ <%
2
+ sev_colors = { 'info' => '#17a2b8', 'warning' => '#fd7e14', 'error' => '#dc3545', 'critical' => '#7b001c' }
3
+ status_colors = { 'open' => '#dc3545', 'in_progress' => '#fd7e14', 'resolved' => '#28a745', 'ignored' => '#6c757d' }
4
+ %>
5
+
6
+ <div class="sub"><%= link_to '← Back to dashboard', root_path %></div>
7
+
8
+ <div class="panel">
9
+ <h1 style="font-size:18px">
10
+ <span class="badge" style="background: <%= status_colors[@error.status] %>"><%= @error.status %></span>
11
+ <span class="badge" style="background: <%= sev_colors[@error.severity] %>"><%= @error.severity %></span>
12
+ <%= @error.source.presence || @error.error_class %>
13
+ </h1>
14
+ <div class="sub"><%= @error.category %> · ×<%= @error.occurrences %> times · last seen <%= @error.last_seen_at&.strftime('%Y/%m/%d %H:%M') %></div>
15
+
16
+ <table>
17
+ <tr><th style="width:160px">Error class</th><td><%= @error.error_class %></td></tr>
18
+ <tr><th>Message</th><td><%= @error.message %></td></tr>
19
+ <tr><th>First seen</th><td><%= @error.first_seen_at %></td></tr>
20
+ <tr><th>Last seen</th><td><%= @error.last_seen_at %></td></tr>
21
+ <% if @error.http_status || @error.api_code %>
22
+ <tr><th>HTTP / API code</th><td><%= @error.http_status %> / code=<%= @error.api_code %> subcode=<%= @error.api_subcode %></td></tr>
23
+ <tr><th>Request URL</th><td style="word-break:break-all"><%= @error.request_url %></td></tr>
24
+ <% end %>
25
+ <% if @error.resolved_at %>
26
+ <tr><th>Resolved</th><td><%= @error.resolved_at %> by <%= @error.resolved_by %></td></tr>
27
+ <tr><th>Note</th><td><%= @error.resolution_note %></td></tr>
28
+ <% end %>
29
+ </table>
30
+ </div>
31
+
32
+ <div class="panel" style="margin-top:16px">
33
+ <h2>Context</h2>
34
+ <pre style="white-space:pre-wrap;background:#f8fafc;padding:12px;border-radius:8px;max-height:320px;overflow:auto"><%= JSON.pretty_generate(@error.context || {}) %></pre>
35
+ </div>
36
+
37
+ <div class="panel" style="margin-top:16px">
38
+ <h2>Backtrace</h2>
39
+ <pre style="white-space:pre-wrap;background:#f8fafc;padding:12px;border-radius:8px;max-height:480px;overflow:auto"><%= @error.backtrace %></pre>
40
+ </div>
41
+
42
+ <div class="panel" style="margin-top:16px">
43
+ <h2>Change status</h2>
44
+ <% %w[open in_progress resolved ignored].each do |status| %>
45
+ <button class="status-btn" data-url="<%= error_status_path(@error) %>" data-status="<%= status %>"
46
+ style="padding:8px 14px;margin-right:8px;border:0;border-radius:8px;color:#fff;cursor:pointer;background: <%= status_colors[status] %>">
47
+ <%= status %>
48
+ </button>
49
+ <% end %>
50
+ </div>
51
+
52
+ <script>
53
+ (function () {
54
+ var token = document.querySelector('meta[name="csrf-token"]').content;
55
+ document.querySelectorAll('.status-btn').forEach(function (btn) {
56
+ btn.addEventListener('click', function () {
57
+ fetch(btn.dataset.url, {
58
+ method: 'PATCH',
59
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
60
+ body: JSON.stringify({ status: btn.dataset.status })
61
+ }).then(function (r) { return r.json(); })
62
+ .then(function (d) {
63
+ var t = document.getElementById('toast');
64
+ t.textContent = d.ok ? ('Moved → ' + d.status) : ('Error: ' + d.error);
65
+ t.style.background = d.ok ? '#16a34a' : '#dc2626';
66
+ t.classList.add('show');
67
+ setTimeout(function () { location.reload(); }, 700);
68
+ });
69
+ });
70
+ });
71
+ })();
72
+ </script>