rails_nexus 2.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +430 -0
- data/Rakefile +6 -0
- data/app/assets/config/rails_nexus_manifest.js +2 -0
- data/app/assets/javascripts/rails_nexus/controllers.js +603 -0
- data/app/assets/stylesheets/rails_nexus/application.css +1964 -0
- data/app/controllers/rails_nexus/analytics_controller.rb +361 -0
- data/app/controllers/rails_nexus/application_controller.rb +15 -0
- data/app/controllers/rails_nexus/backup_controller.rb +91 -0
- data/app/controllers/rails_nexus/cron_jobs_controller.rb +64 -0
- data/app/controllers/rails_nexus/database_health_controller.rb +128 -0
- data/app/controllers/rails_nexus/logged_exceptions_controller.rb +227 -0
- data/app/controllers/rails_nexus/n1_patterns_controller.rb +10 -0
- data/app/controllers/rails_nexus/settings_controller.rb +100 -0
- data/app/controllers/rails_nexus/source_code_controller.rb +39 -0
- data/app/controllers/rails_nexus/stats_controller.rb +494 -0
- data/app/controllers/rails_nexus/workflow_controller.rb +96 -0
- data/app/helpers/rails_nexus/application_helper.rb +5 -0
- data/app/helpers/rails_nexus/logged_exceptions_helper.rb +254 -0
- data/app/helpers/rails_nexus/source_code_helper.rb +107 -0
- data/app/javascript/controllers/rails_nexus_controller.js +380 -0
- data/app/javascript/controllers/rails_nexus_detail_controller.js +165 -0
- data/app/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
- data/app/javascript/controllers/rails_nexus_theme_controller.js +31 -0
- data/app/javascript/rails_nexus/application.js +17 -0
- data/app/jobs/rails_nexus/application_job.rb +4 -0
- data/app/mailers/rails_nexus/application_mailer.rb +6 -0
- data/app/models/rails_nexus/application_record.rb +5 -0
- data/app/models/rails_nexus/backup.rb +107 -0
- data/app/models/rails_nexus/base_record.rb +26 -0
- data/app/models/rails_nexus/comment.rb +13 -0
- data/app/models/rails_nexus/cron_job.rb +72 -0
- data/app/models/rails_nexus/database_stat.rb +128 -0
- data/app/models/rails_nexus/event.rb +101 -0
- data/app/models/rails_nexus/logged_exception.rb +864 -0
- data/app/models/rails_nexus/logged_exception_n1.rb +87 -0
- data/app/models/rails_nexus/metric.rb +107 -0
- data/app/models/rails_nexus/nginx_metric.rb +112 -0
- data/app/models/rails_nexus/server_metric.rb +168 -0
- data/app/models/rails_nexus/webhook_delivery.rb +64 -0
- data/app/services/rails_nexus/backup_service.rb +275 -0
- data/app/views/layouts/rails_nexus/application.html.erb +185 -0
- data/app/views/rails_nexus/analytics/index.html.erb +363 -0
- data/app/views/rails_nexus/backup/files.html.erb +53 -0
- data/app/views/rails_nexus/backup/index.html.erb +211 -0
- data/app/views/rails_nexus/backup/settings.html.erb +158 -0
- data/app/views/rails_nexus/cron_jobs/index.html.erb +141 -0
- data/app/views/rails_nexus/database_health/index.html.erb +139 -0
- data/app/views/rails_nexus/logged_exceptions/_comments_list.html.erb +50 -0
- data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +190 -0
- data/app/views/rails_nexus/logged_exceptions/_feed.html.erb +3 -0
- data/app/views/rails_nexus/logged_exceptions/_filter_group.html.erb +10 -0
- data/app/views/rails_nexus/logged_exceptions/_show.html.erb +554 -0
- data/app/views/rails_nexus/logged_exceptions/_workflow_panel.html.erb +118 -0
- data/app/views/rails_nexus/logged_exceptions/clear.turbo_stream.erb +6 -0
- data/app/views/rails_nexus/logged_exceptions/destroy.turbo_stream.erb +2 -0
- data/app/views/rails_nexus/logged_exceptions/destroy_all.turbo_stream.erb +6 -0
- data/app/views/rails_nexus/logged_exceptions/feed.rss.builder +20 -0
- data/app/views/rails_nexus/logged_exceptions/index.html.erb +323 -0
- data/app/views/rails_nexus/logged_exceptions/query.html.erb +3 -0
- data/app/views/rails_nexus/logged_exceptions/query.turbo_stream.erb +6 -0
- data/app/views/rails_nexus/logged_exceptions/show.html.erb +3 -0
- data/app/views/rails_nexus/logged_exceptions/show.turbo_stream.erb +5 -0
- data/app/views/rails_nexus/n1_patterns/index.html.erb +153 -0
- data/app/views/rails_nexus/settings/index.html.erb +140 -0
- data/app/views/rails_nexus/stats/index.html.erb +569 -0
- data/config/importmap.rb +15 -0
- data/config/initializers/date_formats.rb +5 -0
- data/config/initializers/rails_nexus.rb +18 -0
- data/config/locales/en.yml +47 -0
- data/config/routes.rb +58 -0
- data/db/migrate/20240330122311_create_rails_nexus_logged_exceptions.rb +22 -0
- data/lib/generators/rails_nexus/backup_generator.rb +70 -0
- data/lib/generators/rails_nexus/customize_generator.rb +159 -0
- data/lib/generators/rails_nexus/install_generator.rb +61 -0
- data/lib/generators/rails_nexus/templates/INSTALL.md +9 -0
- data/lib/generators/rails_nexus/templates/backup/backup_helper.sh +152 -0
- data/lib/generators/rails_nexus/templates/backup/config.rb +27 -0
- data/lib/generators/rails_nexus/templates/backup/models/daily_backup.rb +24 -0
- data/lib/generators/rails_nexus/templates/backup/models/full_backup.rb +35 -0
- data/lib/generators/rails_nexus/templates/backup/models/sync_backup.rb +43 -0
- data/lib/generators/rails_nexus/templates/backup/mysql-config/db_config.cnf +15 -0
- data/lib/generators/rails_nexus/templates/backup/schedule.rb +28 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_controller.js +310 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_detail_controller.js +98 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_theme_controller.js +30 -0
- data/lib/generators/rails_nexus/templates/migration.rb +24 -0
- data/lib/generators/rails_nexus/templates/migration_advanced_features.rb +30 -0
- data/lib/generators/rails_nexus/templates/migration_cron_jobs.rb +22 -0
- data/lib/generators/rails_nexus/templates/migration_new_schema.rb +120 -0
- data/lib/generators/rails_nexus/templates/migration_platform_detection.rb +11 -0
- data/lib/generators/rails_nexus/templates/migration_webhook_deliveries.rb +21 -0
- data/lib/generators/rails_nexus/templates/migration_workflow.rb +29 -0
- data/lib/generators/rails_nexus/templates/rails_nexus.rb +96 -0
- data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +129 -0
- data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_exceptions.html.erb +123 -0
- data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_show.html.erb +297 -0
- data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/index.html.erb +113 -0
- data/lib/rails_nexus/breadcrumbs.rb +161 -0
- data/lib/rails_nexus/cleanup.rb +32 -0
- data/lib/rails_nexus/configuration.rb +319 -0
- data/lib/rails_nexus/engine.rb +135 -0
- data/lib/rails_nexus/logger.rb +188 -0
- data/lib/rails_nexus/notifications.rb +105 -0
- data/lib/rails_nexus/ransack_config.rb +4 -0
- data/lib/rails_nexus/storm_protection.rb +124 -0
- data/lib/rails_nexus/version.rb +3 -0
- data/lib/rails_nexus.rb +133 -0
- data/lib/tasks/rails_nexus.rake +230 -0
- data/lib/tasks/rails_nexus_tasks.rake +37 -0
- data/rails_nexus.gemspec +36 -0
- metadata +252 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module RailsNexus
|
|
8
|
+
module Notifications
|
|
9
|
+
class << self
|
|
10
|
+
# Called after a LoggedException is created.
|
|
11
|
+
# Triggers webhooks and the after_create callback.
|
|
12
|
+
def notify(exception_record)
|
|
13
|
+
config = RailsNexus.configuration
|
|
14
|
+
|
|
15
|
+
# Custom after_create callback
|
|
16
|
+
config.after_create&.call(exception_record)
|
|
17
|
+
|
|
18
|
+
# Webhooks
|
|
19
|
+
notify_webhooks(exception_record) if config.webhooks.any?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def notify_webhooks(exception_record)
|
|
25
|
+
config = RailsNexus.configuration
|
|
26
|
+
payload = build_webhook_payload(exception_record)
|
|
27
|
+
|
|
28
|
+
config.webhooks.each do |url|
|
|
29
|
+
deliver_webhook(url, payload, config)
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
Rails.logger.error("[RailsNexus] Webhook failed for #{url}: #{e.message}")
|
|
32
|
+
log_webhook_delivery(url: url, payload: payload, error: e.message, event_type: "exception.logged")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def deliver_webhook(url, payload, config)
|
|
37
|
+
uri = URI(url)
|
|
38
|
+
|
|
39
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
40
|
+
http.use_ssl = uri.scheme == "https"
|
|
41
|
+
http.open_timeout = config.webhook_timeout
|
|
42
|
+
http.read_timeout = config.webhook_timeout
|
|
43
|
+
|
|
44
|
+
request = Net::HTTP::Post.new(uri)
|
|
45
|
+
request["Content-Type"] = "application/json"
|
|
46
|
+
config.webhook_headers.each { |k, v| request[k] = v }
|
|
47
|
+
request.body = payload.to_json
|
|
48
|
+
|
|
49
|
+
response = http.request(request)
|
|
50
|
+
|
|
51
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
52
|
+
Rails.logger.error("[RailsNexus] Webhook #{url} returned #{response.code}")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def log_webhook_delivery(url:, payload:, status_code: nil, response_body: nil, error: nil, event_type: nil)
|
|
57
|
+
return unless defined?(RailsNexus::WebhookDelivery) && RailsNexus.configuration.log_webhook_deliveries
|
|
58
|
+
|
|
59
|
+
if error.present?
|
|
60
|
+
RailsNexus::WebhookDelivery.log_failure!(
|
|
61
|
+
url: url,
|
|
62
|
+
request_body: payload.to_json,
|
|
63
|
+
error_message: error,
|
|
64
|
+
event_type: event_type
|
|
65
|
+
)
|
|
66
|
+
else
|
|
67
|
+
RailsNexus::WebhookDelivery.log_success!(
|
|
68
|
+
url: url,
|
|
69
|
+
request_body: payload.to_json,
|
|
70
|
+
response_code: status_code,
|
|
71
|
+
response_body: response_body,
|
|
72
|
+
duration: 0,
|
|
73
|
+
event_type: event_type
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
Rails.logger.error("[RailsNexus] Failed to log webhook delivery: #{e.message}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_webhook_payload(exception_record)
|
|
81
|
+
{
|
|
82
|
+
event: "exception.logged",
|
|
83
|
+
timestamp: exception_record.created_at.iso8601,
|
|
84
|
+
exception: {
|
|
85
|
+
id: exception_record.id,
|
|
86
|
+
class: exception_record.exception_class,
|
|
87
|
+
message: exception_record.message&.truncate(500),
|
|
88
|
+
controller: exception_record.controller_name,
|
|
89
|
+
action: exception_record.action_name,
|
|
90
|
+
remote_ip: exception_record.remote_ip,
|
|
91
|
+
user_agent: exception_record.user_agent
|
|
92
|
+
},
|
|
93
|
+
dashboard_url: dashboard_url(exception_record)
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def dashboard_url(exception_record)
|
|
98
|
+
host = Rails.application.routes.default_url_options[:host] ||
|
|
99
|
+
ENV.fetch("APP_HOST", "localhost:3000")
|
|
100
|
+
protocol = Rails.env.production? ? "https" : "http"
|
|
101
|
+
"#{protocol}://#{host}/rails_nexus/logged_exceptions/#{exception_record.id}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
# Storm Protection: Circuit breaker + adaptive sampling
|
|
5
|
+
# Prevents error floods from crashing the app with DB writes
|
|
6
|
+
module StormProtection
|
|
7
|
+
# Default thresholds
|
|
8
|
+
DEFAULT_PER_PROCESS_THRESHOLD = 50 # errors per second per process
|
|
9
|
+
DEFAULT_GLOBAL_THRESHOLD = 200 # errors per second globally
|
|
10
|
+
DEFAULT_COOLDOWN_SECONDS = 60 # circuit breaker cooldown
|
|
11
|
+
DEFAULT_SAMPLE_RATE_CALM = 1.0 # 100% capture when calm
|
|
12
|
+
DEFAULT_SAMPLE_RATE_STORM = 0.1 # 10% capture during storm
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
attr_reader :circuit_open, :error_counts, :last_reset
|
|
16
|
+
|
|
17
|
+
def enabled?
|
|
18
|
+
RailsNexus.configuration.storm_protection_enabled
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def threshold
|
|
22
|
+
RailsNexus.configuration.storm_threshold_per_second || DEFAULT_PER_PROCESS_THRESHOLD
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def cooldown
|
|
26
|
+
RailsNexus.configuration.storm_cooldown_seconds || DEFAULT_COOLDOWN_SECONDS
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Check if we should capture this error
|
|
30
|
+
# Returns true if allowed, false if shed
|
|
31
|
+
def allow_capture?
|
|
32
|
+
return true unless enabled?
|
|
33
|
+
|
|
34
|
+
reset_counts_if_needed
|
|
35
|
+
|
|
36
|
+
# If circuit is open (storm detected), use sampling
|
|
37
|
+
if circuit_open?
|
|
38
|
+
return should_sample?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Increment count and check threshold
|
|
42
|
+
@error_counts[:total] += 1
|
|
43
|
+
@error_counts[:current_second] += 1
|
|
44
|
+
|
|
45
|
+
if @error_counts[:current_second] >= threshold
|
|
46
|
+
open_circuit!
|
|
47
|
+
return should_sample?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Record that an error was captured
|
|
54
|
+
def record_captured
|
|
55
|
+
@error_counts[:captured] += 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Record that an error was shed
|
|
59
|
+
def record_shed
|
|
60
|
+
@error_counts[:shed] += 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Get current stats
|
|
64
|
+
def stats
|
|
65
|
+
reset_counts_if_needed
|
|
66
|
+
{
|
|
67
|
+
enabled: enabled?,
|
|
68
|
+
circuit_open: circuit_open?,
|
|
69
|
+
errors_this_second: @error_counts[:current_second] || 0,
|
|
70
|
+
threshold: threshold,
|
|
71
|
+
total_errors: @error_counts[:total] || 0,
|
|
72
|
+
total_captured: @error_counts[:captured] || 0,
|
|
73
|
+
total_shed: @error_counts[:shed] || 0,
|
|
74
|
+
last_reset: @last_reset,
|
|
75
|
+
sample_rate: circuit_open? ? DEFAULT_SAMPLE_RATE_STORM : DEFAULT_SAMPLE_RATE_CALM
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reset circuit (manual or after cooldown)
|
|
80
|
+
def reset!
|
|
81
|
+
@circuit_open = false
|
|
82
|
+
@error_counts = { total: 0, current_second: 0, captured: 0, shed: 0 }
|
|
83
|
+
@last_reset = Time.now
|
|
84
|
+
@second_window = Time.now.to_i
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def circuit_open?
|
|
90
|
+
return false unless @circuit_open
|
|
91
|
+
|
|
92
|
+
# Auto-reset after cooldown
|
|
93
|
+
if Time.now - @circuit_open_at > cooldown
|
|
94
|
+
reset!
|
|
95
|
+
return false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def open_circuit!
|
|
102
|
+
@circuit_open = true
|
|
103
|
+
@circuit_open_at = Time.now
|
|
104
|
+
Rails.logger.warn("[RailsNexus] Storm protection: circuit opened at #{@error_counts[:current_second]} errors/sec")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def reset_counts_if_needed
|
|
108
|
+
current_second = Time.now.to_i
|
|
109
|
+
if current_second != @second_window
|
|
110
|
+
@error_counts[:current_second] = 0
|
|
111
|
+
@second_window = current_second
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def should_sample?
|
|
116
|
+
# Keep 10% of errors during storm
|
|
117
|
+
rand < DEFAULT_SAMPLE_RATE_STORM
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Initialize on load
|
|
122
|
+
reset!
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/rails_nexus.rb
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require "rails"
|
|
2
|
+
require "importmap-rails"
|
|
3
|
+
require "turbo-rails"
|
|
4
|
+
require "stimulus-rails"
|
|
5
|
+
require "pagy"
|
|
6
|
+
require "ipaddr"
|
|
7
|
+
require "socket"
|
|
8
|
+
require "rails_nexus/version"
|
|
9
|
+
require "rails_nexus/configuration"
|
|
10
|
+
require "rails_nexus/logger"
|
|
11
|
+
require "rails_nexus/engine"
|
|
12
|
+
|
|
13
|
+
module RailsNexus
|
|
14
|
+
class << self
|
|
15
|
+
attr_writer :configuration
|
|
16
|
+
|
|
17
|
+
def configuration
|
|
18
|
+
@configuration ||= Configuration.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def configure
|
|
22
|
+
yield(configuration)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Backward-compatible accessor for application_name
|
|
26
|
+
def application_name
|
|
27
|
+
configuration.application_name
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def application_name=(value)
|
|
31
|
+
configuration.application_name = value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Convenience logger accessor
|
|
35
|
+
def logger
|
|
36
|
+
RailsNexus::Logger
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Copyright (c) 2005 Jamis Buck
|
|
41
|
+
#
|
|
42
|
+
# Permission is hereby granted, free of charge, to any person obtaining
|
|
43
|
+
# a copy of this software and associated documentation files (the
|
|
44
|
+
# "Software"), to deal in the Software without restriction, including
|
|
45
|
+
# without limitation the rights to use, copy, modify, merge, publish,
|
|
46
|
+
# distribute, sublicense, and/or sell copies of the Software, and to
|
|
47
|
+
# permit persons to whom the Software is furnished to do so, subject to
|
|
48
|
+
# the following conditions:
|
|
49
|
+
#
|
|
50
|
+
# The above copyright notice and this permission notice shall be
|
|
51
|
+
# included in all copies or substantial portions of the Software.
|
|
52
|
+
#
|
|
53
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
54
|
+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
55
|
+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
56
|
+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
57
|
+
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
58
|
+
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
59
|
+
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
60
|
+
module ExceptionLoggable
|
|
61
|
+
def self.included(target)
|
|
62
|
+
target.extend(ClassMethods)
|
|
63
|
+
target.class_attribute :local_addresses, :exception_data
|
|
64
|
+
|
|
65
|
+
target.local_addresses = [IPAddr.new("127.0.0.1")]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
module ClassMethods
|
|
69
|
+
def consider_local(*args)
|
|
70
|
+
local_addresses.concat(args.flatten.map { |a| IPAddr.new(a) })
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def local_request?
|
|
75
|
+
remote = IPAddr.new(request.remote_ip)
|
|
76
|
+
!self.class.local_addresses.detect { |addr| addr.include?(remote) }.nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# we log the exception and raise it again, for the normal handling.
|
|
80
|
+
def log_exception_handler(exception)
|
|
81
|
+
log_exception(exception)
|
|
82
|
+
raise exception
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def rescue_action(exception)
|
|
86
|
+
status = response_code_for_rescue(exception)
|
|
87
|
+
log_exception(exception) if status != :not_found
|
|
88
|
+
super
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def filter_sub_parameters(params, rails_filter_parameters)
|
|
92
|
+
params.each do |key, value|
|
|
93
|
+
if(value.class != Hash and value.class != ActiveSupport::HashWithIndifferentAccess)
|
|
94
|
+
params[key] = '[FILTERED]' if rails_filter_parameters.include?(key.to_sym)
|
|
95
|
+
else
|
|
96
|
+
params[key] = filter_sub_parameters(value, rails_filter_parameters)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
params
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def log_exception(exception)
|
|
103
|
+
# Support both the legacy class_attribute and the new configuration DSL
|
|
104
|
+
deliverer = if self.class.exception_data
|
|
105
|
+
self.class.exception_data
|
|
106
|
+
elsif RailsNexus.configuration.exception_data
|
|
107
|
+
RailsNexus.configuration.exception_data
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
data = case deliverer
|
|
111
|
+
when nil then {}
|
|
112
|
+
when Symbol then send(deliverer)
|
|
113
|
+
when Proc then deliverer.call(self)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
rails_filter_parameters = defined?(::Rails) ? ::Rails.application.config.filter_parameters : []
|
|
117
|
+
|
|
118
|
+
self.request.parameters.each do |key, value|
|
|
119
|
+
if(value.class != Hash and value.class != ActiveSupport::HashWithIndifferentAccess)
|
|
120
|
+
self.request.parameters[key] = '[FILTERED]' if rails_filter_parameters.include?(key.to_sym)
|
|
121
|
+
else
|
|
122
|
+
self.request.parameters[key] = filter_sub_parameters(value, rails_filter_parameters)
|
|
123
|
+
end
|
|
124
|
+
end if rails_filter_parameters.any?
|
|
125
|
+
|
|
126
|
+
# Log with structured logger
|
|
127
|
+
RailsNexus::Logger.error(exception, controller: self, extra: data)
|
|
128
|
+
|
|
129
|
+
# Create the record
|
|
130
|
+
LoggedException.create_from_exception(self, exception, data)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :rails_nexus do
|
|
4
|
+
desc "Delete exceptions older than the configured retention period"
|
|
5
|
+
task cleanup: :environment do
|
|
6
|
+
require "rails_nexus/cleanup"
|
|
7
|
+
count = RailsNexus::Cleanup.run
|
|
8
|
+
if count
|
|
9
|
+
puts "✓ Cleaned up #{count} old exceptions."
|
|
10
|
+
else
|
|
11
|
+
puts "⚠ No retention_days configured. Set config.retention_days in your initializer."
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
desc "Show rails_nexus statistics"
|
|
16
|
+
task stats: :environment do
|
|
17
|
+
require "rails_nexus/logger"
|
|
18
|
+
|
|
19
|
+
stats = RailsNexus::Logger.stats
|
|
20
|
+
|
|
21
|
+
puts ""
|
|
22
|
+
puts " ⚡ RailsNexus v#{RailsNexus::VERSION}"
|
|
23
|
+
puts " ─────────────────────────────────────"
|
|
24
|
+
puts " Total exceptions: #{stats[:total]}"
|
|
25
|
+
puts ""
|
|
26
|
+
|
|
27
|
+
if stats[:top_exceptions].any?
|
|
28
|
+
puts " Top exceptions:"
|
|
29
|
+
stats[:top_exceptions].each do |klass, count|
|
|
30
|
+
puts " #{klass.ljust(40)} #{count}"
|
|
31
|
+
end
|
|
32
|
+
puts ""
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if stats[:by_controller].any?
|
|
36
|
+
puts " By controller:"
|
|
37
|
+
stats[:by_controller].sort_by { |_, v| -v }.first(10).each do |ctrl, count|
|
|
38
|
+
puts " #{ctrl.to_s.ljust(40)} #{count}"
|
|
39
|
+
end
|
|
40
|
+
puts ""
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
desc "Tail rails_nexus logs in real-time"
|
|
45
|
+
task tail: :environment do
|
|
46
|
+
log_file = RailsNexus.configuration.log_file
|
|
47
|
+
if log_file.blank?
|
|
48
|
+
puts "⚠ No log_file configured. Set config.log_file in your initializer."
|
|
49
|
+
puts " Example: config.log_file = 'log/rails_nexus.log'"
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
puts "Tailing #{log_file}... (Ctrl+C to stop)"
|
|
54
|
+
puts ""
|
|
55
|
+
|
|
56
|
+
IO.popen(["tail", "-f", log_file]) do |io|
|
|
57
|
+
while (line = io.gets)
|
|
58
|
+
begin
|
|
59
|
+
entry = JSON.parse(line)
|
|
60
|
+
level = entry["level"].to_s.upcase.ljust(5)
|
|
61
|
+
ts = entry["timestamp"].to_s.sub(/T/, " ").sub(/\+.*/, "")
|
|
62
|
+
msg = entry["message"]
|
|
63
|
+
cls = entry["exception_class"]
|
|
64
|
+
ctrl = entry["controller"]
|
|
65
|
+
|
|
66
|
+
line_out = "[#{level}] #{ts}"
|
|
67
|
+
line_out += " #{cls}:" if cls.present?
|
|
68
|
+
line_out += " #{msg}"
|
|
69
|
+
line_out += " [#{ctrl["class"]}##{ctrl["action"]}]" if ctrl.present?
|
|
70
|
+
|
|
71
|
+
puts line_out
|
|
72
|
+
rescue JSON::ParserError
|
|
73
|
+
puts line
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
desc "Export exceptions to JSON"
|
|
80
|
+
task export: :environment do
|
|
81
|
+
require "json"
|
|
82
|
+
|
|
83
|
+
output_file = ENV.fetch("OUTPUT", "rails_nexus_export.json")
|
|
84
|
+
since = ENV["SINCE"] ? Time.parse(ENV["SINCE"]) : nil
|
|
85
|
+
|
|
86
|
+
scope = RailsNexus::LoggedException.order(created_at: :desc)
|
|
87
|
+
scope = scope.where("created_at >= ?", since) if since.present?
|
|
88
|
+
|
|
89
|
+
data = scope.map do |e|
|
|
90
|
+
{
|
|
91
|
+
id: e.id,
|
|
92
|
+
exception_class: e.exception_class,
|
|
93
|
+
controller_name: e.controller_name,
|
|
94
|
+
action_name: e.action_name,
|
|
95
|
+
message: e.message,
|
|
96
|
+
backtrace: e.backtrace,
|
|
97
|
+
environment: e.environment,
|
|
98
|
+
request: e.request,
|
|
99
|
+
user_info: e.user_info,
|
|
100
|
+
remote_ip: e.remote_ip,
|
|
101
|
+
user_agent: e.user_agent,
|
|
102
|
+
created_at: e.created_at.iso8601,
|
|
103
|
+
updated_at: e.updated_at.iso8601
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
File.write(output_file, JSON.pretty_generate(data))
|
|
108
|
+
puts "✓ Exported #{data.size} exceptions to #{output_file}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
desc "Test webhook configuration"
|
|
112
|
+
task test_webhook: :environment do
|
|
113
|
+
require "rails_nexus/notifications"
|
|
114
|
+
|
|
115
|
+
config = RailsNexus.configuration
|
|
116
|
+
if config.webhooks.empty?
|
|
117
|
+
puts "⚠ No webhooks configured. Set config.webhooks in your initializer."
|
|
118
|
+
next
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
puts "Testing webhooks..."
|
|
122
|
+
config.webhooks.each do |url|
|
|
123
|
+
print " #{url}... "
|
|
124
|
+
begin
|
|
125
|
+
uri = URI(url)
|
|
126
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
127
|
+
http.use_ssl = uri.scheme == "https"
|
|
128
|
+
http.open_timeout = 5
|
|
129
|
+
http.read_timeout = 5
|
|
130
|
+
|
|
131
|
+
request = Net::HTTP::Post.new(uri)
|
|
132
|
+
request["Content-Type"] = "application/json"
|
|
133
|
+
config.webhook_headers.each { |k, v| request[k] = v }
|
|
134
|
+
request.body = { event: "test", message: "RailsNexus webhook test", timestamp: Time.current.iso8601 }.to_json
|
|
135
|
+
|
|
136
|
+
response = http.request(request)
|
|
137
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
138
|
+
puts "✓ (#{response.code})"
|
|
139
|
+
else
|
|
140
|
+
puts "✗ (#{response.code} #{response.message})"
|
|
141
|
+
end
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
puts "✗ (#{e.message})"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
namespace :rails_nexus do
|
|
150
|
+
desc "Run a task with cron job tracking"
|
|
151
|
+
task :cron, [:task_name] => :environment do |_t, args|
|
|
152
|
+
task_name = args[:task_name] || "unnamed_task"
|
|
153
|
+
job = nil
|
|
154
|
+
|
|
155
|
+
begin
|
|
156
|
+
job = RailsNexus::CronJob.start!(task_name)
|
|
157
|
+
puts "⚡ Starting cron job: #{task_name} (ID: #{job.id})"
|
|
158
|
+
|
|
159
|
+
job.succeed!(output: "Completed successfully")
|
|
160
|
+
puts "✓ Cron job completed: #{task_name} (#{job.duration.round(2)}s)"
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
job&.fail!(error: e.message)
|
|
163
|
+
puts "✗ Cron job failed: #{task_name} — #{e.message}"
|
|
164
|
+
raise e
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
desc "Wrap a rake task with RailsNexus cron tracking"
|
|
169
|
+
task :wrap_cron, [:task_name] => :environment do |_t, args|
|
|
170
|
+
task_name = args[:task_name]
|
|
171
|
+
if task_name.blank?
|
|
172
|
+
puts "Usage: rake rails_nexus:wrap_cron[rake:task:name]"
|
|
173
|
+
next
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
job = nil
|
|
177
|
+
begin
|
|
178
|
+
job = RailsNexus::CronJob.start!(task_name)
|
|
179
|
+
puts "⚡ Starting tracked task: #{task_name}"
|
|
180
|
+
|
|
181
|
+
Rake::Task[task_name].invoke
|
|
182
|
+
|
|
183
|
+
job.succeed!(output: "Completed successfully")
|
|
184
|
+
puts "✓ Task completed: #{task_name} (#{job.duration.round(2)}s)"
|
|
185
|
+
rescue StandardError => e
|
|
186
|
+
job&.fail!(error: e.message)
|
|
187
|
+
puts "✗ Task failed: #{task_name} — #{e.message}"
|
|
188
|
+
raise e
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
desc "Show cron job statistics"
|
|
193
|
+
task cron_stats: :environment do
|
|
194
|
+
stats = RailsNexus::CronJob.summary
|
|
195
|
+
|
|
196
|
+
puts ""
|
|
197
|
+
puts " ⏰ Cron Job Statistics"
|
|
198
|
+
puts " ─────────────────────────────────────"
|
|
199
|
+
puts " Total runs: #{stats[:total]}"
|
|
200
|
+
puts " Successful: #{stats[:successful]}"
|
|
201
|
+
puts " Failed: #{stats[:failed]}"
|
|
202
|
+
puts " Running: #{stats[:running]}"
|
|
203
|
+
puts " Failure rate: #{stats[:failure_rate]}%"
|
|
204
|
+
puts ""
|
|
205
|
+
|
|
206
|
+
if stats[:last_run]
|
|
207
|
+
last = stats[:last_run]
|
|
208
|
+
puts " Last run:"
|
|
209
|
+
puts " Name: #{last.name}"
|
|
210
|
+
puts " Status: #{last.status}"
|
|
211
|
+
puts " Time: #{last.created_at}"
|
|
212
|
+
puts " Duration: #{last.duration&.round(2)}s"
|
|
213
|
+
puts ""
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
desc "Clean up old cron job records (default: 30 days)"
|
|
218
|
+
task :cron_cleanup, [:days] => :environment do |_t, args|
|
|
219
|
+
days = (args[:days] || 30).to_i
|
|
220
|
+
count = RailsNexus::CronJob.where("created_at < ?", days.days.ago).delete_all
|
|
221
|
+
puts "✓ Deleted #{count} cron job records older than #{days} days"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
desc "Clean up old webhook delivery records (default: 30 days)"
|
|
225
|
+
task :webhook_cleanup, [:days] => :environment do |_t, args|
|
|
226
|
+
days = (args[:days] || 30).to_i
|
|
227
|
+
count = RailsNexus::WebhookDelivery.where("created_at < ?", days.days.ago).delete_all
|
|
228
|
+
puts "✓ Deleted #{count} webhook delivery records older than #{days} days"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
namespace :rails_nexus do
|
|
2
|
+
namespace :tailwind do
|
|
3
|
+
desc "Generate Tailwind v4 @source entries for RailsNexus views/helpers (OUT=app/assets/stylesheets/_rails_nexus_sources.css ENTRY=app/assets/stylesheets/application.tailwind.css)"
|
|
4
|
+
task sources: :environment do
|
|
5
|
+
root = RailsNexus::Engine.root
|
|
6
|
+
out = ENV["OUT"] || "app/assets/stylesheets/_rails_nexus_sources.css"
|
|
7
|
+
|
|
8
|
+
content = <<~CSS
|
|
9
|
+
/* Generated by bin/rails rails_nexus:tailwind:sources - do not edit. Re-run after gem updates or on new machines/containers. */
|
|
10
|
+
@source "#{root}/app/views/**/*.erb";
|
|
11
|
+
@source "#{root}/app/helpers/**/*.rb";
|
|
12
|
+
CSS
|
|
13
|
+
|
|
14
|
+
FileUtils.mkdir_p(File.dirname(out))
|
|
15
|
+
File.write(out, content)
|
|
16
|
+
puts "Wrote #{out}"
|
|
17
|
+
|
|
18
|
+
entry = ENV["ENTRY"]
|
|
19
|
+
entry ||= "app/assets/stylesheets/application.tailwind.css" if File.exist?("app/assets/stylesheets/application.tailwind.css")
|
|
20
|
+
|
|
21
|
+
if entry && File.file?(entry)
|
|
22
|
+
import_line = "@import \"./#{File.basename(out)}\";"
|
|
23
|
+
body = File.read(entry)
|
|
24
|
+
|
|
25
|
+
if body.include?(File.basename(out))
|
|
26
|
+
puts "#{entry} already imports #{File.basename(out)}"
|
|
27
|
+
else
|
|
28
|
+
File.write(entry, body.sub(/\A/, "#{import_line}\n"))
|
|
29
|
+
puts "Added #{import_line} to #{entry}"
|
|
30
|
+
end
|
|
31
|
+
elsif !entry
|
|
32
|
+
puts "Add this to your Tailwind entry stylesheet:"
|
|
33
|
+
puts " @import \"./#{File.basename(out)}\";"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/rails_nexus.gemspec
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require_relative "lib/rails_nexus/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "rails_nexus"
|
|
5
|
+
spec.version = RailsNexus::VERSION
|
|
6
|
+
spec.authors = ["Tamiru Hailu"]
|
|
7
|
+
spec.email = ["tamiruhailu@gmail.com"]
|
|
8
|
+
spec.homepage = "https://github.com/tamiru/rails_nexus"
|
|
9
|
+
spec.summary = "The extensible control plane for Rails applications"
|
|
10
|
+
spec.description = "RailsNexus is an extensible operations and administration console for Rails applications. It provides error monitoring, backup management, cron job tracking, server statistics, database health, real-time analytics, workflow management, source code viewing, N+1 query detection, and storm protection. Built with Hotwire, Stimulus, and Tailwind-compatible CSS."
|
|
11
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
12
|
+
spec.license = "MIT"
|
|
13
|
+
|
|
14
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
15
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
16
|
+
spec.metadata["source_code_uri"] = "https://github.com/tamiru/rails_nexus/tree/main"
|
|
17
|
+
spec.metadata["changelog_uri"] = "https://github.com/tamiru/rails_nexus/blob/main/CHANGELOG.md"
|
|
18
|
+
spec.metadata["funding_uri"] = "https://github.com/sponsors/tamiru"
|
|
19
|
+
|
|
20
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
21
|
+
Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md", "rails_nexus.gemspec"]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
spec.required_ruby_version = ">= 3.2.0"
|
|
25
|
+
|
|
26
|
+
spec.add_dependency "rails", "~> 8.0", ">= 8.0.0"
|
|
27
|
+
spec.add_dependency "stimulus-rails", "~> 1.3"
|
|
28
|
+
spec.add_dependency "turbo-rails", "~> 2.0"
|
|
29
|
+
spec.add_dependency "importmap-rails", "~> 2.0"
|
|
30
|
+
spec.add_dependency "pagy", "~> 6.5"
|
|
31
|
+
|
|
32
|
+
spec.add_dependency "ransack", "~> 4.0"
|
|
33
|
+
|
|
34
|
+
# Note: Backup management requires 'backup' (~> 4.4) and 'whenever' (~> 1.1)
|
|
35
|
+
# gems to be added to your host application's Gemfile.
|
|
36
|
+
end
|