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.
Files changed (114) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +430 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/config/rails_nexus_manifest.js +2 -0
  6. data/app/assets/javascripts/rails_nexus/controllers.js +603 -0
  7. data/app/assets/stylesheets/rails_nexus/application.css +1964 -0
  8. data/app/controllers/rails_nexus/analytics_controller.rb +361 -0
  9. data/app/controllers/rails_nexus/application_controller.rb +15 -0
  10. data/app/controllers/rails_nexus/backup_controller.rb +91 -0
  11. data/app/controllers/rails_nexus/cron_jobs_controller.rb +64 -0
  12. data/app/controllers/rails_nexus/database_health_controller.rb +128 -0
  13. data/app/controllers/rails_nexus/logged_exceptions_controller.rb +227 -0
  14. data/app/controllers/rails_nexus/n1_patterns_controller.rb +10 -0
  15. data/app/controllers/rails_nexus/settings_controller.rb +100 -0
  16. data/app/controllers/rails_nexus/source_code_controller.rb +39 -0
  17. data/app/controllers/rails_nexus/stats_controller.rb +494 -0
  18. data/app/controllers/rails_nexus/workflow_controller.rb +96 -0
  19. data/app/helpers/rails_nexus/application_helper.rb +5 -0
  20. data/app/helpers/rails_nexus/logged_exceptions_helper.rb +254 -0
  21. data/app/helpers/rails_nexus/source_code_helper.rb +107 -0
  22. data/app/javascript/controllers/rails_nexus_controller.js +380 -0
  23. data/app/javascript/controllers/rails_nexus_detail_controller.js +165 -0
  24. data/app/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
  25. data/app/javascript/controllers/rails_nexus_theme_controller.js +31 -0
  26. data/app/javascript/rails_nexus/application.js +17 -0
  27. data/app/jobs/rails_nexus/application_job.rb +4 -0
  28. data/app/mailers/rails_nexus/application_mailer.rb +6 -0
  29. data/app/models/rails_nexus/application_record.rb +5 -0
  30. data/app/models/rails_nexus/backup.rb +107 -0
  31. data/app/models/rails_nexus/base_record.rb +26 -0
  32. data/app/models/rails_nexus/comment.rb +13 -0
  33. data/app/models/rails_nexus/cron_job.rb +72 -0
  34. data/app/models/rails_nexus/database_stat.rb +128 -0
  35. data/app/models/rails_nexus/event.rb +101 -0
  36. data/app/models/rails_nexus/logged_exception.rb +864 -0
  37. data/app/models/rails_nexus/logged_exception_n1.rb +87 -0
  38. data/app/models/rails_nexus/metric.rb +107 -0
  39. data/app/models/rails_nexus/nginx_metric.rb +112 -0
  40. data/app/models/rails_nexus/server_metric.rb +168 -0
  41. data/app/models/rails_nexus/webhook_delivery.rb +64 -0
  42. data/app/services/rails_nexus/backup_service.rb +275 -0
  43. data/app/views/layouts/rails_nexus/application.html.erb +185 -0
  44. data/app/views/rails_nexus/analytics/index.html.erb +363 -0
  45. data/app/views/rails_nexus/backup/files.html.erb +53 -0
  46. data/app/views/rails_nexus/backup/index.html.erb +211 -0
  47. data/app/views/rails_nexus/backup/settings.html.erb +158 -0
  48. data/app/views/rails_nexus/cron_jobs/index.html.erb +141 -0
  49. data/app/views/rails_nexus/database_health/index.html.erb +139 -0
  50. data/app/views/rails_nexus/logged_exceptions/_comments_list.html.erb +50 -0
  51. data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +190 -0
  52. data/app/views/rails_nexus/logged_exceptions/_feed.html.erb +3 -0
  53. data/app/views/rails_nexus/logged_exceptions/_filter_group.html.erb +10 -0
  54. data/app/views/rails_nexus/logged_exceptions/_show.html.erb +554 -0
  55. data/app/views/rails_nexus/logged_exceptions/_workflow_panel.html.erb +118 -0
  56. data/app/views/rails_nexus/logged_exceptions/clear.turbo_stream.erb +6 -0
  57. data/app/views/rails_nexus/logged_exceptions/destroy.turbo_stream.erb +2 -0
  58. data/app/views/rails_nexus/logged_exceptions/destroy_all.turbo_stream.erb +6 -0
  59. data/app/views/rails_nexus/logged_exceptions/feed.rss.builder +20 -0
  60. data/app/views/rails_nexus/logged_exceptions/index.html.erb +323 -0
  61. data/app/views/rails_nexus/logged_exceptions/query.html.erb +3 -0
  62. data/app/views/rails_nexus/logged_exceptions/query.turbo_stream.erb +6 -0
  63. data/app/views/rails_nexus/logged_exceptions/show.html.erb +3 -0
  64. data/app/views/rails_nexus/logged_exceptions/show.turbo_stream.erb +5 -0
  65. data/app/views/rails_nexus/n1_patterns/index.html.erb +153 -0
  66. data/app/views/rails_nexus/settings/index.html.erb +140 -0
  67. data/app/views/rails_nexus/stats/index.html.erb +569 -0
  68. data/config/importmap.rb +15 -0
  69. data/config/initializers/date_formats.rb +5 -0
  70. data/config/initializers/rails_nexus.rb +18 -0
  71. data/config/locales/en.yml +47 -0
  72. data/config/routes.rb +58 -0
  73. data/db/migrate/20240330122311_create_rails_nexus_logged_exceptions.rb +22 -0
  74. data/lib/generators/rails_nexus/backup_generator.rb +70 -0
  75. data/lib/generators/rails_nexus/customize_generator.rb +159 -0
  76. data/lib/generators/rails_nexus/install_generator.rb +61 -0
  77. data/lib/generators/rails_nexus/templates/INSTALL.md +9 -0
  78. data/lib/generators/rails_nexus/templates/backup/backup_helper.sh +152 -0
  79. data/lib/generators/rails_nexus/templates/backup/config.rb +27 -0
  80. data/lib/generators/rails_nexus/templates/backup/models/daily_backup.rb +24 -0
  81. data/lib/generators/rails_nexus/templates/backup/models/full_backup.rb +35 -0
  82. data/lib/generators/rails_nexus/templates/backup/models/sync_backup.rb +43 -0
  83. data/lib/generators/rails_nexus/templates/backup/mysql-config/db_config.cnf +15 -0
  84. data/lib/generators/rails_nexus/templates/backup/schedule.rb +28 -0
  85. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_controller.js +310 -0
  86. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_detail_controller.js +98 -0
  87. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
  88. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_theme_controller.js +30 -0
  89. data/lib/generators/rails_nexus/templates/migration.rb +24 -0
  90. data/lib/generators/rails_nexus/templates/migration_advanced_features.rb +30 -0
  91. data/lib/generators/rails_nexus/templates/migration_cron_jobs.rb +22 -0
  92. data/lib/generators/rails_nexus/templates/migration_new_schema.rb +120 -0
  93. data/lib/generators/rails_nexus/templates/migration_platform_detection.rb +11 -0
  94. data/lib/generators/rails_nexus/templates/migration_webhook_deliveries.rb +21 -0
  95. data/lib/generators/rails_nexus/templates/migration_workflow.rb +29 -0
  96. data/lib/generators/rails_nexus/templates/rails_nexus.rb +96 -0
  97. data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +129 -0
  98. data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_exceptions.html.erb +123 -0
  99. data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_show.html.erb +297 -0
  100. data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/index.html.erb +113 -0
  101. data/lib/rails_nexus/breadcrumbs.rb +161 -0
  102. data/lib/rails_nexus/cleanup.rb +32 -0
  103. data/lib/rails_nexus/configuration.rb +319 -0
  104. data/lib/rails_nexus/engine.rb +135 -0
  105. data/lib/rails_nexus/logger.rb +188 -0
  106. data/lib/rails_nexus/notifications.rb +105 -0
  107. data/lib/rails_nexus/ransack_config.rb +4 -0
  108. data/lib/rails_nexus/storm_protection.rb +124 -0
  109. data/lib/rails_nexus/version.rb +3 -0
  110. data/lib/rails_nexus.rb +133 -0
  111. data/lib/tasks/rails_nexus.rake +230 -0
  112. data/lib/tasks/rails_nexus_tasks.rake +37 -0
  113. data/rails_nexus.gemspec +36 -0
  114. 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