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 +7 -0
- data/CHANGELOG.md +11 -0
- data/MIT-LICENSE +20 -0
- data/README.md +106 -0
- data/Rakefile +5 -0
- data/app/controllers/error_radar/application_controller.rb +23 -0
- data/app/controllers/error_radar/dashboard_controller.rb +82 -0
- data/app/models/error_radar/application_record.rb +7 -0
- data/app/models/error_radar/error_log.rb +95 -0
- data/app/views/error_radar/dashboard/index.html.erb +185 -0
- data/app/views/error_radar/dashboard/show.html.erb +72 -0
- data/app/views/layouts/error_radar/application.html.erb +48 -0
- data/config/routes.rb +7 -0
- data/lib/error_radar/configuration.rb +81 -0
- data/lib/error_radar/engine.rb +33 -0
- data/lib/error_radar/integrations/active_job.rb +32 -0
- data/lib/error_radar/integrations/rails_admin.rb +145 -0
- data/lib/error_radar/integrations/sidekiq.rb +35 -0
- data/lib/error_radar/middleware.rb +53 -0
- data/lib/error_radar/server_monitor.rb +94 -0
- data/lib/error_radar/tracking.rb +116 -0
- data/lib/error_radar/version.rb +5 -0
- data/lib/error_radar.rb +47 -0
- data/lib/generators/error_radar/install/install_generator.rb +38 -0
- data/lib/generators/error_radar/install/templates/create_error_radar_error_logs.rb.tt +45 -0
- data/lib/generators/error_radar/install/templates/initializer.rb +45 -0
- metadata +108 -0
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,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,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 & 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>
|