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,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
module LoggedExceptionN1
|
|
5
|
+
# N+1 Query Pattern Detection
|
|
6
|
+
# Analyzes breadcrumbs for repeated similar SQL queries
|
|
7
|
+
def self.n_plus_one_patterns(limit: 20)
|
|
8
|
+
patterns = {}
|
|
9
|
+
|
|
10
|
+
LoggedException.where.not(breadcrumbs: nil)
|
|
11
|
+
.where("created_at >= ?", 7.days.ago)
|
|
12
|
+
.order(created_at: :desc)
|
|
13
|
+
.limit(500)
|
|
14
|
+
.find_each do |exception|
|
|
15
|
+
crumbs = parse_breadcrumbs(exception.breadcrumbs)
|
|
16
|
+
next unless crumbs.is_a?(Array)
|
|
17
|
+
|
|
18
|
+
sql_crumbs = crumbs.select { |c| c[:type] == "sql" }
|
|
19
|
+
next if sql_crumbs.empty?
|
|
20
|
+
|
|
21
|
+
sql_crumbs.group_by { |c| sql_fingerprint(c[:name]) }.each do |fingerprint, queries|
|
|
22
|
+
next if queries.length < 3
|
|
23
|
+
|
|
24
|
+
table = extract_table_name(queries.first[:name])
|
|
25
|
+
patterns[fingerprint] ||= {
|
|
26
|
+
fingerprint: fingerprint,
|
|
27
|
+
sql_sample: queries.first[:name]&.truncate(200),
|
|
28
|
+
table: table,
|
|
29
|
+
count: 0,
|
|
30
|
+
exception_ids: [],
|
|
31
|
+
avg_duration: 0
|
|
32
|
+
}
|
|
33
|
+
patterns[fingerprint][:count] += queries.length
|
|
34
|
+
patterns[fingerprint][:exception_ids] << exception.id
|
|
35
|
+
durations = queries.map { |q| q[:duration] }.compact
|
|
36
|
+
if durations.any?
|
|
37
|
+
patterns[fingerprint][:avg_duration] = (durations.sum / durations.size).round(2)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
patterns.values
|
|
43
|
+
.sort_by { |p| -p[:count] }
|
|
44
|
+
.first(limit)
|
|
45
|
+
.map { |p| p.merge(exception_ids: p[:exception_ids].uniq.first(5)) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.n_plus_one_summary
|
|
49
|
+
patterns = n_plus_one_patterns(limit: 100)
|
|
50
|
+
{
|
|
51
|
+
total_patterns: patterns.sum { |p| p[:count] },
|
|
52
|
+
unique_patterns: patterns.size,
|
|
53
|
+
worst_pattern: patterns.first,
|
|
54
|
+
top_tables: patterns.group_by { |p| p[:table] }
|
|
55
|
+
.transform_values { |ps| ps.sum { |p| p[:count] } }
|
|
56
|
+
.sort_by { |_, count| -count }
|
|
57
|
+
.first(5)
|
|
58
|
+
.map { |table, count| { table: table, count: count } }
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.parse_breadcrumbs(data)
|
|
63
|
+
return data if data.is_a?(Array)
|
|
64
|
+
JSON.parse(data, symbolize_names: true) rescue []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.sql_fingerprint(sql)
|
|
68
|
+
return "" if sql.blank?
|
|
69
|
+
sql.strip
|
|
70
|
+
.gsub(/\s+/, " ")
|
|
71
|
+
.gsub(/\d+/, "?")
|
|
72
|
+
.gsub(/'[^']*'/, "'?'")
|
|
73
|
+
.gsub(/\b\d+\.\d+\b/, "?")
|
|
74
|
+
.gsub(/\b0x[0-9a-f]+\b/i, "?")
|
|
75
|
+
.strip
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.extract_table_name(sql)
|
|
79
|
+
return "unknown" if sql.blank?
|
|
80
|
+
if sql =~ /(?:FROM|INTO|UPDATE|JOIN)\s+[`"']?(\w+)[`"']?/i
|
|
81
|
+
$1.downcase
|
|
82
|
+
else
|
|
83
|
+
"unknown"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
class Metric < BaseRecord
|
|
5
|
+
self.table_name = "rails_nexus_metrics"
|
|
6
|
+
|
|
7
|
+
validates :metric_type, presence: true
|
|
8
|
+
validates :value, presence: true
|
|
9
|
+
validates :recorded_at, presence: true
|
|
10
|
+
|
|
11
|
+
scope :recent, ->(hours = 24) { where("recorded_at >= ?", hours.hours.ago) }
|
|
12
|
+
scope :by_type, ->(type) { where(metric_type: type) }
|
|
13
|
+
scope :cpu, -> { by_type("cpu") }
|
|
14
|
+
scope :memory, -> { by_type("memory") }
|
|
15
|
+
scope :threads, -> { by_type("threads") }
|
|
16
|
+
scope :db_pool, -> { by_type("db_pool") }
|
|
17
|
+
scope :disk, -> { by_type("disk") }
|
|
18
|
+
|
|
19
|
+
# Collect current system metrics
|
|
20
|
+
def self.collect!
|
|
21
|
+
now = Time.current
|
|
22
|
+
|
|
23
|
+
# CPU usage
|
|
24
|
+
cpu_usage = detect_cpu_usage
|
|
25
|
+
create!(metric_type: "cpu", value: cpu_usage, unit: "%", recorded_at: now) if cpu_usage
|
|
26
|
+
|
|
27
|
+
# Memory
|
|
28
|
+
mem = detect_memory_usage
|
|
29
|
+
if mem
|
|
30
|
+
create!(metric_type: "memory_total", value: mem[:total], unit: "mb", recorded_at: now)
|
|
31
|
+
create!(metric_type: "memory_used", value: mem[:used], unit: "mb", recorded_at: now)
|
|
32
|
+
create!(metric_type: "memory_free", value: mem[:free], unit: "mb", recorded_at: now)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Threads
|
|
36
|
+
create!(metric_type: "threads", value: Thread.list.size, unit: "count", recorded_at: now)
|
|
37
|
+
|
|
38
|
+
# DB pool
|
|
39
|
+
db_pool = detect_db_pool
|
|
40
|
+
if db_pool
|
|
41
|
+
create!(metric_type: "db_pool_size", value: db_pool[:size], unit: "count", recorded_at: now)
|
|
42
|
+
create!(metric_type: "db_pool_busy", value: db_pool[:busy], unit: "count", recorded_at: now)
|
|
43
|
+
create!(metric_type: "db_pool_idle", value: db_pool[:idle], unit: "count", recorded_at: now)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# GC stats
|
|
47
|
+
gc = GC.stat
|
|
48
|
+
create!(metric_type: "gc_count", value: gc[:count].to_f, unit: "count", recorded_at: now)
|
|
49
|
+
create!(metric_type: "gc_heap_allocated", value: (gc[:heap_allocated_objects].to_f / 1_000_000), unit: "m", recorded_at: now)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get metric as a time series for charts
|
|
53
|
+
def self.time_series(type, hours: 24, interval: 15)
|
|
54
|
+
by_type(type)
|
|
55
|
+
.where("recorded_at >= ?", hours.hours.ago)
|
|
56
|
+
.order(:recorded_at)
|
|
57
|
+
.group_by { |m| (m.recorded_at.to_i / (interval * 60)) * (interval * 60) }
|
|
58
|
+
.map { |ts, records| { time: Time.at(ts), value: records.map(&:value).sum / records.size.to_f } }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Cleanup old metrics
|
|
62
|
+
def self.cleanup!(retention_hours: 168)
|
|
63
|
+
where("recorded_at < ?", retention_hours.hours.ago).delete_all
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def self.detect_cpu_usage
|
|
69
|
+
# Linux: read /proc/stat
|
|
70
|
+
if File.exist?("/proc/stat")
|
|
71
|
+
line = File.readlines("/proc/stat").first
|
|
72
|
+
values = line.split[1..].map(&:to_f)
|
|
73
|
+
idle = values[3]
|
|
74
|
+
total = values.sum
|
|
75
|
+
# Approximate — real implementation needs two readings
|
|
76
|
+
((total - idle) / total * 100).round(1)
|
|
77
|
+
end
|
|
78
|
+
rescue StandardError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.detect_memory_usage
|
|
83
|
+
if File.exist?("/proc/meminfo")
|
|
84
|
+
info = {}
|
|
85
|
+
File.readlines("/proc/meminfo").each do |line|
|
|
86
|
+
key, val = line.split(":")
|
|
87
|
+
info[key.strip] = val.strip.split.first.to_i if key && val
|
|
88
|
+
end
|
|
89
|
+
total = (info["MemTotal"] || 0) / 1024
|
|
90
|
+
free = (info["MemAvailable"] || info["MemFree"] || 0) / 1024
|
|
91
|
+
used = total - free
|
|
92
|
+
{ total: total.round, used: used.round, free: free.round }
|
|
93
|
+
end
|
|
94
|
+
rescue StandardError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.detect_db_pool
|
|
99
|
+
if ActiveRecord::Base.connection_pool
|
|
100
|
+
pool = ActiveRecord::Base.connection_pool
|
|
101
|
+
{ size: pool.size, busy: pool.connections.size, idle: pool.size - pool.connections.size }
|
|
102
|
+
end
|
|
103
|
+
rescue StandardError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
class NginxMetric < BaseRecord
|
|
5
|
+
self.table_name = "rails_nexus_nginx_metrics"
|
|
6
|
+
|
|
7
|
+
validates :status_code, presence: true
|
|
8
|
+
validates :recorded_at, presence: true
|
|
9
|
+
|
|
10
|
+
scope :recent, ->(hours = 24) { where("recorded_at >= ?", hours.hours.ago) }
|
|
11
|
+
scope :by_status, ->(code) { where(status_code: code) }
|
|
12
|
+
scope :errors, -> { where("status_code >= 400") }
|
|
13
|
+
scope :by_path, ->(path) { where(request_path: path) }
|
|
14
|
+
|
|
15
|
+
# Parse nginx access log file
|
|
16
|
+
def self.parse_log!(log_path, limit: 1000)
|
|
17
|
+
return unless File.exist?(log_path)
|
|
18
|
+
return unless File.readable?(log_path)
|
|
19
|
+
|
|
20
|
+
entries = []
|
|
21
|
+
File.foreach(log_path).last(limit).each do |line|
|
|
22
|
+
entry = parse_log_line(line)
|
|
23
|
+
entries << entry if entry
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
insert_all(entries) if entries.any?
|
|
27
|
+
entries.size
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get status code distribution
|
|
31
|
+
def self.status_distribution(hours: 24)
|
|
32
|
+
recent(hours)
|
|
33
|
+
.group(:status_code)
|
|
34
|
+
.order("count_all DESC")
|
|
35
|
+
.count
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get top slow endpoints
|
|
39
|
+
def self.slow_endpoints(hours: 24, limit: 10)
|
|
40
|
+
recent(hours)
|
|
41
|
+
.where.not(response_time: nil)
|
|
42
|
+
.group(:request_path)
|
|
43
|
+
.order("AVG(response_time) DESC")
|
|
44
|
+
.limit(limit)
|
|
45
|
+
.average(:response_time)
|
|
46
|
+
.map { |path, avg_time| { path: path, avg_response_time: avg_time&.round(3) } }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Get requests per minute over time
|
|
50
|
+
def self.requests_over_time(hours: 24, interval_minutes: 5)
|
|
51
|
+
recent(hours)
|
|
52
|
+
.order(:recorded_at)
|
|
53
|
+
.group_by { |r| (r.recorded_at.to_i / (interval_minutes * 60)) * (interval_minutes * 60) }
|
|
54
|
+
.map do |ts, records|
|
|
55
|
+
{
|
|
56
|
+
time: Time.at(ts),
|
|
57
|
+
total: records.size,
|
|
58
|
+
errors: records.count { |r| r.status_code >= 400 },
|
|
59
|
+
avg_response_time: records.filter_map(&:response_time).then { |t| t.any? ? (t.sum / t.size).round(3) : nil }
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get error rate
|
|
65
|
+
def self.error_rate(hours: 24)
|
|
66
|
+
total = recent(hours).count
|
|
67
|
+
return 0 if total.zero?
|
|
68
|
+
errors = recent(hours).errors.count
|
|
69
|
+
(errors.to_f / total * 100).round(2)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Cleanup old metrics
|
|
73
|
+
def self.cleanup!(retention_days: 7)
|
|
74
|
+
where("recorded_at < ?", retention_days.days.ago).delete_all
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Parse a single nginx log line (combined format)
|
|
80
|
+
# Format: $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" $request_time $upstream_response_time
|
|
81
|
+
def self.parse_log_line(line)
|
|
82
|
+
pattern = /^(\S+) \S+ (\S+) \[([^\]]+)\] "(\S+) (\S+)(?:\s+\S+)?" (\d{3}) (\d+|-) "([^"]*)" "([^"]*)" ?(\d+\.?\d*)? ?(\d+\.?\d*)?$/
|
|
83
|
+
|
|
84
|
+
match = line.match(pattern)
|
|
85
|
+
return unless match
|
|
86
|
+
|
|
87
|
+
status_code = match[6].to_i
|
|
88
|
+
response_time = match[10]&.to_f
|
|
89
|
+
upstream_time = match[11]&.to_f
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
status_code: status_code,
|
|
93
|
+
request_method: match[4],
|
|
94
|
+
request_path: match[5],
|
|
95
|
+
response_time: response_time,
|
|
96
|
+
upstream_time: upstream_time,
|
|
97
|
+
remote_addr: match[1],
|
|
98
|
+
user_agent: match[9],
|
|
99
|
+
referer: match[8] != "-" ? match[8] : nil,
|
|
100
|
+
recorded_at: parse_nginx_time(match[3])
|
|
101
|
+
}
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.parse_nginx_time(time_str)
|
|
107
|
+
Time.parse(time_str)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
Time.current
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
class ServerMetric < BaseRecord
|
|
5
|
+
self.table_name = "rails_nexus_server_metrics"
|
|
6
|
+
|
|
7
|
+
validates :recorded_at, presence: true
|
|
8
|
+
|
|
9
|
+
scope :recent, ->(hours = 24) { where("recorded_at >= ?", hours.hours.ago) }
|
|
10
|
+
scope :latest, -> { order(recorded_at: :desc).first }
|
|
11
|
+
|
|
12
|
+
# Collect current server metrics
|
|
13
|
+
def self.collect!
|
|
14
|
+
now = Time.current
|
|
15
|
+
data = {
|
|
16
|
+
hostname: Socket.gethostname,
|
|
17
|
+
ruby_version: RUBY_VERSION,
|
|
18
|
+
rails_version: defined?(Rails) ? Rails::VERSION::STRING : nil,
|
|
19
|
+
os_info: detect_os,
|
|
20
|
+
recorded_at: now
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# CPU
|
|
24
|
+
cpu = detect_cpu
|
|
25
|
+
data.merge!(cpu) if cpu
|
|
26
|
+
|
|
27
|
+
# Memory
|
|
28
|
+
mem = detect_memory
|
|
29
|
+
data.merge!(mem) if mem
|
|
30
|
+
|
|
31
|
+
# Load averages
|
|
32
|
+
load_avg = detect_load_average
|
|
33
|
+
data.merge!(load_avg) if load_avg
|
|
34
|
+
|
|
35
|
+
# Disk
|
|
36
|
+
disk = detect_disk
|
|
37
|
+
data.merge!(disk) if disk
|
|
38
|
+
|
|
39
|
+
# Processes
|
|
40
|
+
procs = detect_processes
|
|
41
|
+
data.merge!(procs) if procs
|
|
42
|
+
|
|
43
|
+
# Uptime
|
|
44
|
+
data[:uptime_seconds] = detect_uptime
|
|
45
|
+
|
|
46
|
+
create!(data)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Get latest metrics
|
|
50
|
+
def self.current
|
|
51
|
+
latest || collect!
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get metric trend over time
|
|
55
|
+
def self.trend(field, hours: 24)
|
|
56
|
+
where("recorded_at >= ?", hours.hours.ago)
|
|
57
|
+
.order(:recorded_at)
|
|
58
|
+
.pluck(:recorded_at, field.to_sym)
|
|
59
|
+
.map { |time, value| { time: time, value: value&.to_f } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Cleanup old metrics
|
|
63
|
+
def self.cleanup!(retention_days: 7)
|
|
64
|
+
where("recorded_at < ?", retention_days.days.ago).delete_all
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def self.detect_os
|
|
70
|
+
if File.exist?("/etc/os-release")
|
|
71
|
+
info = {}
|
|
72
|
+
File.readlines("/etc/os-release").each do |line|
|
|
73
|
+
key, val = line.split("=", 2)
|
|
74
|
+
info[key.strip] = val&.strip&.gsub('"', '') if key && val
|
|
75
|
+
end
|
|
76
|
+
"#{info['PRETTY_NAME'] || info['NAME']} #{info['VERSION']}"
|
|
77
|
+
elsif RUBY_PLATFORM =~ /darwin/
|
|
78
|
+
`sw_vers -productVersion`.strip
|
|
79
|
+
else
|
|
80
|
+
RUBY_PLATFORM
|
|
81
|
+
end
|
|
82
|
+
rescue StandardError
|
|
83
|
+
RUBY_PLATFORM
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.detect_cpu
|
|
87
|
+
return unless File.exist?("/proc/cpuinfo")
|
|
88
|
+
cores = File.readlines("/proc/cpuinfo").count { |l| l =~ /^processor\s*:/ }
|
|
89
|
+
{ cpu_cores: cores }
|
|
90
|
+
rescue StandardError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.detect_memory
|
|
95
|
+
return unless File.exist?("/proc/meminfo")
|
|
96
|
+
info = {}
|
|
97
|
+
File.readlines("/proc/meminfo").each do |line|
|
|
98
|
+
key, val = line.split(":")
|
|
99
|
+
next unless key && val
|
|
100
|
+
kb = val.strip.split.first.to_i
|
|
101
|
+
case key.strip
|
|
102
|
+
when "MemTotal" then info[:memory_total] = kb / 1024
|
|
103
|
+
when "MemAvailable" then info[:memory_free] = kb / 1024
|
|
104
|
+
when "MemFree" then info[:memory_free] ||= kb / 1024
|
|
105
|
+
when "SwapTotal" then info[:swap_total] = kb / 1024
|
|
106
|
+
when "SwapFree" then info[:swap_used] = ((info[:swap_total] || 0) - (kb / 1024))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
info[:memory_used] = (info[:memory_total] || 0) - (info[:memory_free] || 0) if info[:memory_total]
|
|
110
|
+
info
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.detect_load_average
|
|
116
|
+
if File.exist?("/proc/loadavg")
|
|
117
|
+
parts = File.read("/proc/loadavg").split
|
|
118
|
+
{ load_avg_1m: parts[0].to_f, load_avg_5m: parts[1].to_f, load_avg_15m: parts[2].to_f }
|
|
119
|
+
end
|
|
120
|
+
rescue StandardError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.detect_disk
|
|
125
|
+
output = `df -B1 / 2>/dev/null`.lines.last
|
|
126
|
+
return unless output
|
|
127
|
+
parts = output.split
|
|
128
|
+
total = parts[1].to_i
|
|
129
|
+
used = parts[2].to_i
|
|
130
|
+
{ disk_total: total, disk_used: used, disk_usage_percent: total > 0 ? (used.to_f / total * 100).round(1) : 0 }
|
|
131
|
+
rescue StandardError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.detect_processes
|
|
136
|
+
data = {}
|
|
137
|
+
|
|
138
|
+
# Puma
|
|
139
|
+
if defined?(Puma) && Puma.respond_to?(:stats)
|
|
140
|
+
stats = Puma.stats_hash rescue {}
|
|
141
|
+
data[:puma_workers] = stats[:workers]&.size || 0
|
|
142
|
+
data[:puma_threads] = stats[:workers]&.sum { |w| w[:last_status]&.dig(:max_threads) || 0 } || 0
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Sidekiq
|
|
146
|
+
if defined?(Sidekiq)
|
|
147
|
+
require "sidekiq/api"
|
|
148
|
+
stats = Sidekiq::Stats.new
|
|
149
|
+
data[:sidekiq_processed] = stats.processed
|
|
150
|
+
data[:sidekiq_failed] = stats.failed
|
|
151
|
+
data[:sidekiq_enqueued] = stats.enqueued
|
|
152
|
+
data[:sidekiq_workers] = Sidekiq::ProcessSet.new.size rescue 0
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
data
|
|
156
|
+
rescue StandardError
|
|
157
|
+
{}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.detect_uptime
|
|
161
|
+
if File.exist?("/proc/uptime")
|
|
162
|
+
File.read("/proc/uptime").split.first.to_f
|
|
163
|
+
end
|
|
164
|
+
rescue StandardError
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
class WebhookDelivery < BaseRecord
|
|
5
|
+
self.table_name = "rails_nexus_webhook_deliveries"
|
|
6
|
+
|
|
7
|
+
STATUSES = %w[pending success failed].freeze
|
|
8
|
+
|
|
9
|
+
validates :url, presence: true
|
|
10
|
+
validates :status, inclusion: { in: STATUSES }
|
|
11
|
+
|
|
12
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
13
|
+
scope :by_status, ->(status) { where(status: status) }
|
|
14
|
+
scope :failed, -> { by_status("failed") }
|
|
15
|
+
scope :successful, -> { by_status("success") }
|
|
16
|
+
scope :last_24h, -> { where("created_at >= ?", 24.hours.ago) }
|
|
17
|
+
scope :last_7d, -> { where("created_at >= ?", 7.days.ago) }
|
|
18
|
+
|
|
19
|
+
# Log a successful delivery
|
|
20
|
+
def self.log_success!(url:, request_body:, response_code:, response_body:, duration:, event_type: nil)
|
|
21
|
+
create!(
|
|
22
|
+
url: url,
|
|
23
|
+
status: "success",
|
|
24
|
+
request_body: request_body,
|
|
25
|
+
response_code: response_code,
|
|
26
|
+
response_body: response_body&.truncate(1000),
|
|
27
|
+
duration: duration,
|
|
28
|
+
event_type: event_type
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Log a failed delivery
|
|
33
|
+
def self.log_failure!(url:, request_body:, error_message:, response_code: nil, response_body: nil, duration: nil, event_type: nil)
|
|
34
|
+
create!(
|
|
35
|
+
url: url,
|
|
36
|
+
status: "failed",
|
|
37
|
+
request_body: request_body,
|
|
38
|
+
response_code: response_code,
|
|
39
|
+
response_body: response_body&.truncate(1000),
|
|
40
|
+
error_message: error_message,
|
|
41
|
+
duration: duration,
|
|
42
|
+
event_type: event_type
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Stats summary
|
|
47
|
+
def self.summary
|
|
48
|
+
{
|
|
49
|
+
total: count,
|
|
50
|
+
successful: successful.count,
|
|
51
|
+
failed: failed.count,
|
|
52
|
+
last_delivery: recent.first
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def failed?
|
|
57
|
+
status == "failed"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def success?
|
|
61
|
+
status == "success"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|